Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 6a84cc393b08c5afacabd5f3604eadbddd7a8565
parent cc7b95b3d5a63c9c23b961779b9706c36899c851
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 17 Sep 2026 13:42:41 -0400

relocate: a directory timestamp is not content drift

`.d..t` is rsync every-time-itemization for "this is a directory and only its
mtime differs" — nothing to send, and no byte of the copy in question. It is
exactly what a sidecar written into one video directory mid-copy leaves behind,
and it is what refused the omnimirror move after 131 GB had landed correctly.

verifyCopy now runs ONE more `rsync -a` pass when EVERY drift line is that
shape, then re-verifies. One retry, never a loop: a second pass that does not
settle it means something is still writing, and the answer is to refuse rather
than to chase. Any other itemization is still the existing refusal, with "The
source has NOT been touched" unchanged.

The flag rides out on the result (`retried`) so a log and a test can both see
that the copy needed a settle pass rather than inferring it from prose.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/controller/relocateChannelMedia.ts | 87++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
1 file changed, 69 insertions(+), 18 deletions(-)

diff --git a/common/controller/relocateChannelMedia.ts b/common/controller/relocateChannelMedia.ts @@ -60,6 +60,9 @@ export type RelocateChannelMediaResult = { // True when the run picked up an interrupted one from its marker rather than // starting from scratch. resumed: boolean; + // True when the verify found ONLY directory-mtime drift and one extra + // `rsync -a` pass settled it. Content drift is still a refusal, never this. + retried: boolean; }; type RelocateOpts = { @@ -334,26 +337,62 @@ async function rsyncTree(opts: { // nothing left to send, AND the two trees measure the same. The dry run alone // would accept a target that is byte-identical for the wrong reason; the counts // alone would accept two trees of equal size with different contents. +// A DIRECTORY MTIME IS NOT CONTENT. `.d..t` is rsync's itemization for "this is +// a directory and only its modification time differs" — nothing to send, and no +// byte of the copy is in question. It is what the omnimirror move hit +// (2026-09-13): a sidecar written into one video directory while the copy was +// already past it bumped that directory's mtime on the SOURCE and left the +// target's behind, the verify saw one drift line, and a 131 GB copy refused at +// the last step with nothing actually wrong. +const DIR_MTIME_ONLY = /^\.d\.\.t/; + +function driftLines(output: string): string[] { + return output + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith("sending incremental")) + .filter((l) => !/^(sent|total size|$)/.test(l)); +} + +// What a copy has to clear before the swap: rsync itself agrees there is +// nothing left to send, AND the two trees measure the same. The dry run alone +// would accept a target that is byte-identical for the wrong reason; the counts +// alone would accept two trees of equal size with different contents. async function verifyCopy(opts: { paths: Paths; src: string; dest: string; log: (m: string) => void; signal?: AbortSignal; -}): Promise<{ bytes: number; files: number }> { - const { exitCode, output } = await rsyncTree({ - ...opts, - args: ["-a", "--dry-run", "--itemize-changes"], - }); - if (exitCode !== 0) { - throw new Error(`Verification rsync failed (exit ${exitCode})`); - } - const drift = output - .split("\n") - .map((l) => l.trim()) - .filter((l) => l.length > 0 && !l.startsWith("sending incremental")) - .filter((l) => !/^(sent|total size|$)/.test(l)); - if (drift.length > 0) { +}): Promise<{ bytes: number; files: number; retried: boolean }> { + let retried = false; + // ONE retry, never a loop: if a second pass does not settle it, something is + // still writing into the tree and the answer is to refuse, not to chase it. + for (;;) { + const { exitCode, output } = await rsyncTree({ + ...opts, + args: ["-a", "--dry-run", "--itemize-changes"], + }); + if (exitCode !== 0) { + throw new Error(`Verification rsync failed (exit ${exitCode})`); + } + const drift = driftLines(output); + if (drift.length === 0) break; + if (!retried && drift.every((l) => DIR_MTIME_ONLY.test(l))) { + retried = true; + opts.log( + `Verification found ${drift.length} directory timestamp(s) differing and ` + + `no content drift — running one more rsync pass to settle them.`, + ); + const again = await rsyncTree({ ...opts, args: ["-a"] }); + if (again.exitCode !== 0) { + throw new Error( + `Verification rsync failed (exit ${again.exitCode}). ` + + `The source has NOT been touched.`, + ); + } + continue; + } throw new Error( `Verification failed: ${drift.length} file(s) still differ ` + `(first: ${drift[0]}). The source has NOT been touched.`, @@ -366,7 +405,7 @@ async function verifyCopy(opts: { `target has ${b.files}/${formatBytes(b.bytes)}. The source has NOT been touched.`, ); } - return a; + return { ...a, retried }; } // What the operator sees before committing to a move. Cheap enough to run on a @@ -585,6 +624,9 @@ async function moveOut(args: { } const measured = await measureTree(dataDir); + // Sticky across both verify points below: a retry at either one is the fact + // the caller wants reported, and neither overwrites the other's answer. + let verifyRetried = false; log( `Relocating ${slug}: ${measured.files} file(s), ${formatBytes(measured.bytes)} ` + `-> ${target}`, @@ -640,7 +682,9 @@ async function moveOut(args: { if (exitCode !== 0) throw new Error(`rsync failed (exit ${exitCode})`); log("Verifying the copy…"); - await verifyCopy({ paths, src: dataDir, dest: target, log, signal }); + verifyRetried = + (await verifyCopy({ paths, src: dataDir, dest: target, log, signal })) + .retried || verifyRetried; phase = "swap"; } @@ -668,7 +712,9 @@ async function moveOut(args: { state.kind === "real-dir" ? dataDir : (parked[0] ?? null); if (verifySrc) { log("Verifying the copy…"); - await verifyCopy({ paths, src: verifySrc, dest: target, log, signal }); + verifyRetried = + (await verifyCopy({ paths, src: verifySrc, dest: target, log, signal })) + .retried || verifyRetried; } if (state.kind === "real-dir") { @@ -734,6 +780,7 @@ async function moveOut(args: { bytes: measured.bytes, files: measured.files, resumed: args.resumed, + retried: verifyRetried, }; } @@ -789,6 +836,7 @@ async function moveBack(args: { ); } // Measured off whichever copy still exists, in the order they stop existing. + let verifyRetried = false; const measured = (await isDirectory(target)) ? await measureTree(target) : (await isDirectory(incoming)) @@ -847,7 +895,9 @@ async function moveBack(args: { } if (exitCode !== 0) throw new Error(`rsync failed (exit ${exitCode})`); log("Verifying the copy…"); - await verifyCopy({ paths, src: target, dest: incoming, log, signal }); + verifyRetried = ( + await verifyCopy({ paths, src: target, dest: incoming, log, signal }) + ).retried; phase = "swap"; } @@ -922,5 +972,6 @@ async function moveBack(args: { bytes: measured.bytes, files: measured.files, resumed: args.resumed, + retried: verifyRetried, }; }