import path from "node:path"; import { lstat, readdir, readFile, readlink, realpath, rm, stat, } from "node:fs/promises"; import { writeJsonAtomic } from "../lib/jsonFile-server"; import { execa } from "execa"; import { formatRsyncProgressDetail, parseRsyncProgress, rsyncProgressFraction, } from "../jobs/progressParsers"; import { formatBytes } from "../lib/format"; import type { RelocationDirection, RelocationMarker, RelocationPhase, } from "../lib/channelMedia"; // MOVING A DIRECTORY TO ANOTHER DRIVE AND LEAVING A SYMLINK — the mechanism, // with no opinion about whose directory it is. // // This is `relocateChannelMedia.ts`'s core, lifted out unchanged so a SECOND // thing can be moved by it: the saved-video store. What stayed behind in that // file is everything specific to a channel — `config.mediaDir`, the parked // `media.relocated-*` siblings, the social-channel refusal, `inspectChannelMedia` // — because none of it generalises and pretending it did would be the worse // abstraction. // // What IS shared is the part the omnimirror incident and three rounds of resume // bugs were paid for: measure the tree, copy with `--partial` so an abort is // resumable, MIRROR the copy (`--delete` toward the destination only — the // realcandaceo incident, 2026-09-30), verify with a dry run AND a re-measure, // allow the source one change between the mirror and the check, and dispatch // every step past the copy on what is ON DISK rather than on what the previous // step was supposed to have done. // // THE SOURCE IS NEVER TOUCHED UNTIL THE COPY VERIFIES. That is the invariant // every caller inherits and none may weaken. // --------------------------------------------------------------------------- // Measuring // --------------------------------------------------------------------------- // One walk, used by the preview, the space check and the verify. Follows no // symlinks (a relocated tree is never the SOURCE of another relocation). export async function measureTree( dir: string, ): Promise<{ bytes: number; files: number }> { let bytes = 0; let files = 0; const stack = [dir]; while (stack.length > 0) { const cur = stack.pop() as string; let entries; try { entries = await readdir(cur, { withFileTypes: true }); } catch { continue; } for (const e of entries) { const p = path.join(cur, e.name); if (e.isDirectory()) { stack.push(p); } else if (e.isFile()) { try { const st = await stat(p); bytes += st.size; files++; } catch { /* vanished mid-walk; the verify is what catches real drift */ } } } } return { bytes, files }; } export async function pathExists(p: string): Promise { try { await stat(p); return true; } catch { return false; } } export async function isDirectory(p: string): Promise { try { return (await stat(p)).isDirectory(); } catch { return false; } } // WHAT A PATH ACTUALLY IS RIGHT NOW. lstat, never stat: a dangling symlink — // the exact state a half-finished move or an unmounted drive leaves behind — // reads as ABSENT through stat, and the code that then tries to create the link // fails with EEXIST on a path it was just told was not there. export type LinkOrDirState = | { kind: "missing" } | { kind: "real-dir" } | { kind: "link"; linkTarget: string } | { kind: "other" }; export async function linkOrDirState(p: string): Promise { let st; try { st = await lstat(p); } catch { return { kind: "missing" }; } if (st.isSymbolicLink()) { return { kind: "link", linkTarget: await readlink(p).catch(() => "") }; } if (st.isDirectory()) return { kind: "real-dir" }; return { kind: "other" }; } // --------------------------------------------------------------------------- // The marker // --------------------------------------------------------------------------- // THE MARKER FILE IS A CONTRACT WITH THE PROCESS THAT COMES NEXT, and it is // deliberately identical in shape for a channel and for the saved-video store: // `{ target, direction, startedAt, phase }`. A live editor mid-move has already // written these; a later binary must resume them, so nothing here may gain a // REQUIRED field. See common/lib/channelMedia.ts, which is where the type lives // and where the channel guards read it. export type DirRelocationMarker = RelocationMarker; export async function writeDirMarker( file: string, marker: DirRelocationMarker, ): Promise { await writeJsonAtomic(file, marker); } export async function clearDirMarker(file: string): Promise { await rm(file, { force: true }); } export async function readDirMarker( file: string, ): Promise { try { const raw = await readFile(file, "utf8"); const r = JSON.parse(raw) as Partial; if (typeof r.target !== "string") return null; return { target: r.target, direction: (r.direction === "back" ? "back" : "out") as RelocationDirection, startedAt: typeof r.startedAt === "string" ? r.startedAt : "", phase: r.phase === "swap" || r.phase === "reclaim" ? r.phase : ("copy" as RelocationPhase), // Optional (release 17): what a channel's marker is moving. Kept so a // resume rewrites what it read, and so the mover can tell a tier // migration's marker from its own. ...(r.scope === "media" || r.scope === "tier-migration" ? { scope: r.scope } : {}), }; } catch { return null; } } // --------------------------------------------------------------------------- // Progress // --------------------------------------------------------------------------- // WHAT A COPY IN FLIGHT LOOKS LIKE FROM OUTSIDE, once per rsync redraw. // // `fraction` is against the MEASURED tree, never rsync's own percentage — see // parseRsyncProgress for why that one walks backwards under incremental // recursion. export type RelocationProgress = { bytes: number; totalBytes: number; fraction: number; rate: string; etaSeconds: number; // "12.3 GB of 45.6 GB · 27 % · 110.50MB/s · ETA 5:32". One wording, shared by // the task detail and the decile log line. detail: string; }; // Turn rsync's redraws into `RelocationProgress`, and into one log line per // decile. The decile latch is why this is a closure and not a function: "every // 10 %" is a fact about the run, and a copy that resumes at 80 % must not // re-announce the eight deciles it did not do. export function makeProgressSink(opts: { totalBytes: number; // BYTES ALREADY ON THE FAR SIDE WHEN THIS RUN STARTED — a resumed copy's // offset, and 0 for a fresh one. // // rsync counts what IT transferred, not what is there: resume a copy that // died at 60 % and the bar climbs 0 → 40 % and stops, having moved every // remaining byte. The operator reads that as a copy that wedged four fifths // of the way through something. Adding the head start makes the fraction a // statement about the TREE, which is what the bar claims to be about, and it // is why the caller measures the destination before the copy rather than // letting rsync's own numbers stand. alreadyBytes?: number; log: (m: string) => void; onProgress?: (p: RelocationProgress) => void; }): (line: string) => void { let lastDecile = -1; const already = Math.max(0, opts.alreadyBytes ?? 0); return (line: string) => { const raw = parseRsyncProgress(line); if (!raw) return; // Clamped to the total: a resumed copy can re-send a partial file, so // `already + transferred` may exceed the tree by the size of one file. const done = Math.min(opts.totalBytes || Infinity, already + raw.bytes); const shifted = { ...raw, bytes: done }; const fraction = rsyncProgressFraction(shifted, opts.totalBytes); const detail = formatRsyncProgressDetail( shifted, opts.totalBytes, formatBytes, ); opts.onProgress?.({ bytes: done, totalBytes: opts.totalBytes, fraction, rate: raw.rate, etaSeconds: raw.etaSeconds, detail, }); const decile = Math.min(10, Math.floor(fraction * 10)); if (decile > lastDecile) { lastDecile = decile; opts.log(`Copying… ${detail}`); } }; } // --------------------------------------------------------------------------- // rsync // --------------------------------------------------------------------------- // rsync, exactly as backupSavedVideos does it: the real binary from // paths.rsyncBin, cancellable, streaming its output into the job log. export async function rsyncTree(opts: { rsyncBin: string; src: string; dest: string; args: string[]; log: (m: string) => void; // Fed every `--info=progress2` redraw. When set, those redraws are kept OUT // of the log: one 131 GB copy is several thousand frames of one line, and the // sink writes a decile line instead. Everything rsync says that is not a // progress frame still goes to the log verbatim. progress?: (line: string) => void; signal?: AbortSignal; }): Promise<{ exitCode: number; output: string }> { // Trailing slash on src: copy the CONTENTS, so / -> / and not // /data/. Getting this wrong is a silently nested corpus. const args = [...opts.args, `${opts.src}/`, `${opts.dest}/`]; opts.log(`$ ${opts.rsyncBin} ${args.join(" ")}`); const child = execa(opts.rsyncBin, args, { cancelSignal: opts.signal, all: true, buffer: false, reject: false, }); let output = ""; // THE TAIL OF THE LAST CHUNK, HELD BACK UNTIL ITS DELIMITER ARRIVES. // // A chunk boundary falls wherever the pipe decides, and it lands INSIDE a // progress frame often enough to matter on a long copy. Parsing the two // halves separately is not merely a dropped frame: ` 30,000,` matches the // number, the percent and the rate of nothing, and the leading ` 30` of // the next chunk can parse as a frame reporting THIRTY BYTES — so the bar // jumps back to 0 % and the decile latch has already spent its line. So the // trailing segment (the one with no delimiter after it) is carried into the // next chunk, which is what every line-oriented stream reader has to do. let pending = ""; child.all?.on("data", (c: Buffer) => { const text = c.toString("utf8"); // The verify reads this whole buffer back (driftLines), so it is // accumulated verbatim whatever the log ends up holding. output += text; if (!opts.progress) { opts.log(text); return; } // rsync rewrites the progress line in place with carriage returns, so one // chunk carries many frames. Split on both, exactly as taskHooks does for // yt-dlp — but keep the last segment back unless the chunk ended on a // delimiter, because only then is it a whole line. const segments = (pending + text).split(/[\r\n]+/); pending = /[\r\n]$/.test(text) ? "" : (segments.pop() ?? ""); let passedThrough = ""; for (const part of segments) { if (part.trim() === "") continue; if (parseRsyncProgress(part) === null) { passedThrough += `${part}\n`; continue; } opts.progress(part); } if (passedThrough) opts.log(passedThrough); }); const result = await child; // THE LAST LINE HAS NO DELIMITER AFTER IT. rsync's final `100%` frame is // exactly that, and it is the one the decile latch needs to reach 10. if (pending.trim() !== "") { if (opts.progress && parseRsyncProgress(pending) !== null) { opts.progress(pending); } else { opts.log(`${pending}\n`); } pending = ""; } return { exitCode: result.exitCode ?? 1, output }; } // The copy arguments, in one place so the two movers cannot drift: `-a` // preserves mtimes (which is what makes the LMDB index a no-op afterwards), // `--partial` keeps an aborted transfer resumable, `--info=progress2` is what // the sink above reads. export const COPY_ARGS = ["-a", "--partial", "--info=progress2"]; // THE MIRROR PASS: the destination copy is made to match the source exactly — // what is missing is sent, what changed is re-sent, and what the source no // longer has is DELETED FROM THE DESTINATION (release 16 slice RM). // // Why a copy needs it. The copy pass (`COPY_ARGS`) never deletes, so a file // that existed on the source while rsync passed it and was removed afterwards // stays on the destination forever. On 2026-09-30 that was a transcriber's // scratch directory (`v50t5yt/.audio.mp3.parakeet/`, five files) copied mid- // transcription and deleted from the source when the transcription finished: // the resume the next day copied everything else and refused on the counts, // 1755 files against 1750, with the source untouched — and nothing in the move // could ever settle it. `--delete` is what can. // // `--delete` IS NEVER POINTED AT THE LIVE MEDIA: run the other way round it // would delete from the channel's media every file the partial copy has not // reached yet. `mirrorTree` and `copyMirrorVerify` refuse, before rsync is // spawned, a destination that is, contains or sits inside the live media the // CALLER names (assertMirrorDirection). // // `--info=del` puts every removal in the job log (`deleting `): a file // taken off a copy is a fact the operator can read afterwards, not only a // count. export const MIRROR_ARGS = ["-a", "--delete", "--info=del"]; // The verify's dry run: what the mirror pass WOULD change, itemized, deletions // included. const VERIFY_ARGS = ["-a", "--dry-run", "--itemize-changes", "--delete"]; // 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 differs, by kind // --------------------------------------------------------------------------- // The differences a verify's dry run reports, sorted into what the operator can // act on: a path the destination has and the source does not ("extra on the // destination" — the realcandaceo scratch dir), one the destination lacks // ("missing on the destination"), and one both have that differs ("changed", // a directory's timestamp included). Paths are relative to the tree. export type CopyDifferences = { extra: string[]; missing: string[]; changed: string[]; }; // rsync's itemize line is `YXcstpoguax path`: Y the update type, X the file // type, then one column per attribute, `+` in every column for an item that // does not exist on the receiving side yet. A deletion is `*deleting path`. // Anything else (a warning rsync printed) is reported whole, as changed. export function classifyDrift(lines: ReadonlyArray): CopyDifferences { const out: CopyDifferences = { extra: [], missing: [], changed: [] }; for (const line of lines) { if (line.startsWith("*deleting")) { out.extra.push(line.replace(/^\S+\s+/, "")); } else if (/^[<>ch.][fdLDS]\+{5,}\s/.test(line)) { out.missing.push(line.replace(/^\S+\s+/, "")); } else if (/^[<>ch.][fdLDS]\S*\s/.test(line)) { out.changed.push(line.replace(/^\S+\s+/, "")); } else { out.changed.push(line); } } return out; } function countDifferences(d: CopyDifferences): number { return d.extra.length + d.missing.length + d.changed.length; } // "2 extra on the destination (v1/.x/meta.json, v1/.x/win-0000.json), 0 // missing on the destination, 1 changed (v2/transcript.json)" — at most five // paths of each kind, the rest counted. export function describeDifferences(d: CopyDifferences): string { const kind = (n: string[], words: string) => { if (n.length === 0) return `0 ${words}`; const shown = n.slice(0, 5).join(", "); const more = n.length > 5 ? `, … ${n.length - 5} more` : ""; return `${n.length} ${words} (${shown}${more})`; }; return [ kind(d.extra, "extra on the destination"), kind(d.missing, "missing on the destination"), kind(d.changed, "changed"), ].join(", "); } // A verify that the second mirror pass did not settle. Carries the differences // by kind, so a caller (and a test) can read them without parsing prose. export class CopyVerificationError extends Error { readonly differences: CopyDifferences; constructor(message: string, differences: CopyDifferences) { super(message); this.name = "CopyVerificationError"; this.differences = differences; } } // --------------------------------------------------------------------------- // The mirror pass, and the one direction it may run in // --------------------------------------------------------------------------- async function realOrResolved(p: string): Promise { return await realpath(p).catch(() => path.resolve(p)); } function isWithin(parent: string, child: string): boolean { const rel = path.relative(parent, child); return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); } export class MirrorDirectionError extends Error { constructor(message: string) { super(message); this.name = "MirrorDirectionError"; } } // THE ONE RULE `--delete` LIVES UNDER, asserted before rsync is spawned: the // destination is not the live media. `live` is supplied by the caller from // what it KNOWS is live — a channel's `channels//media` (resolved through // its link, so on the way back it is the relocated target) or the saved-video // store — and never derived from `src` or `dest`, so a call with the two // swapped is caught: out, the destination would be `media/` itself; back, it // would be the target `media` points at. Refused when the destination's real // path is the live media's, contains it, or sits inside it; and, belt and // braces, when the source and the destination contain one another. export async function assertMirrorDirection(opts: { src: string; dest: string; live: string; }): Promise { const [src, dest, live] = await Promise.all([ realOrResolved(opts.src), realOrResolved(opts.dest), realOrResolved(opts.live), ]); if (isWithin(live, dest) || isWithin(dest, live)) { throw new MirrorDirectionError( `Refusing to mirror into ${opts.dest}: it ${ dest === live ? "is" : isWithin(live, dest) ? "is inside" : "contains" } the live media at ${opts.live}${ live !== path.resolve(opts.live) ? ` (${live})` : "" }, and --delete would remove from it every file the copy lacks. ` + `Nothing has been touched.`, ); } if (isWithin(src, dest) || isWithin(dest, src)) { throw new MirrorDirectionError( `Refusing to mirror ${opts.src} into ${opts.dest}: one is inside the ` + `other, so --delete would reach the source. Nothing has been touched.`, ); } } // One `rsync -a --delete` pass, source → the copy. With a progress sink it // reports like the copy pass (Reconcile and resume's pass is the copy). export async function mirrorTree(opts: { rsyncBin: string; src: string; dest: string; live: string; log: (m: string) => void; progress?: (line: string) => void; signal?: AbortSignal; }): Promise<{ exitCode: number; output: string }> { await assertMirrorDirection(opts); return rsyncTree({ rsyncBin: opts.rsyncBin, src: opts.src, dest: opts.dest, args: opts.progress ? [...MIRROR_ARGS, "--info=progress2"] : MIRROR_ARGS, log: opts.log, progress: opts.progress, signal: opts.signal, }); } // What the destination copy differs from the source by, now, without changing // anything: Reconcile and resume says this before it mirrors. export async function diffTrees(opts: { rsyncBin: string; src: string; dest: string; log: (m: string) => void; signal?: AbortSignal; }): Promise { const { exitCode, output } = await rsyncTree({ ...opts, args: VERIFY_ARGS, // The listing is what this returns; the log gets the command line only. log: (m) => { if (m.startsWith("$ ")) opts.log(m); }, }); if (exitCode !== 0) { throw new Error(`The comparison rsync failed (exit ${exitCode})`); } return classifyDrift(driftLines(output)); } // --------------------------------------------------------------------------- // The copy phase, for both movers and both directions // --------------------------------------------------------------------------- // COPY, MIRROR, VERIFY — the whole copy phase, in one place so the channel // mover's two directions and the store mover's two cannot drift apart. // // 1. The copy pass (`COPY_ARGS`, progress-reporting, `--partial`): the bulk of // the bytes, resumable. Skipped by a reconcile, whose mirror pass reports // the progress instead. // 2. The mirror pass (`MIRROR_ARGS`) toward the copy under construction: what // changed on the source while pass 1 ran is sent, and what the source no // longer has is removed from the copy. // 3. The verify, in mirror mode: an empty itemized dry run AND equal counts; // one more mirror pass if the source moved under it, and a refusal naming // the differences by kind if it moved again. // // A resumed move takes the same path, so a destination left with stale files // by an interrupted move is mirrored clean rather than refused forever. export async function copyMirrorVerify(opts: { rsyncBin: string; src: string; // The copy under construction, which the caller has already created. dest: string; // The live media — see assertMirrorDirection. Asked before the first rsync, // so a swapped call copies nothing either. live: string; log: (m: string) => void; progress?: (line: string) => void; signal?: AbortSignal; // Reconcile and resume: no copy pass; the mirror pass carries the progress, // and the differences it is about to settle are logged by kind first. reconcile?: boolean; // The caller's sentence for a cancel, which names its own two directories. cancelled: () => Error; }): Promise<{ bytes: number; files: number; retried: boolean }> { const { rsyncBin, src, dest, live, log, signal } = opts; await assertMirrorDirection({ src, dest, live }); if (opts.reconcile) { const before = await diffTrees({ rsyncBin, src, dest, log, signal }); log( countDifferences(before) === 0 ? "Reconciling: the destination copy already matches the source." : `Reconciling: the destination copy differs from the source — ` + `${describeDifferences(before)}. The mirror pass makes it match ` + `the source; the source is not changed.`, ); } else { const { exitCode } = await rsyncTree({ rsyncBin, src, dest, args: COPY_ARGS, log, progress: opts.progress, signal, }); if (signal?.aborted) throw opts.cancelled(); if (exitCode !== 0) throw new Error(`rsync failed (exit ${exitCode})`); } log("Mirroring the copy (rsync --delete toward the destination)…"); const mirrored = await mirrorTree({ rsyncBin, src, dest, live, log, progress: opts.reconcile ? opts.progress : undefined, signal, }); if (signal?.aborted) throw opts.cancelled(); if (mirrored.exitCode !== 0) { throw new Error( `The mirror pass failed (exit ${mirrored.exitCode}). The source has NOT ` + `been touched.`, ); } log("Verifying the copy…"); return verifyCopy({ rsyncBin, src, dest, log, signal, mirror: { live }, }); } // 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. // // IN MIRROR MODE (`mirror` given; every copy phase, and a swap-phase re-verify // whose source is still the live media) the dry run carries `--delete`, so an // extra file on the destination is a difference too, and ANY difference gets // one more mirror pass — a source change seen between the pass and the check — // before a second difference refuses with the paths by kind. Without `mirror` // (a re-verify against a parked copy, which is no longer the live media and // must never be mirrored from) it is the verify as it always was: only // directory timestamps get a second pass, plain `rsync -a`. export async function verifyCopy(opts: { rsyncBin: string; src: string; dest: string; log: (m: string) => void; signal?: AbortSignal; mirror?: { live: string }; }): Promise<{ bytes: number; files: number; retried: boolean }> { if (opts.mirror) return verifyMirrored({ ...opts, mirror: opts.mirror }); 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.`, ); } return measuredEqual(opts.src, opts.dest, retried); } // The counts half of every verify: the two trees hold the same number of files // and the same bytes. async function measuredEqual( src: string, dest: string, retried: boolean, ): Promise<{ bytes: number; files: number; retried: boolean }> { const [a, b] = await Promise.all([measureTree(src), measureTree(dest)]); if (a.files !== b.files || a.bytes !== b.bytes) { throw new Error( `Verification failed: source has ${a.files} file(s)/${formatBytes(a.bytes)}, ` + `target has ${b.files}/${formatBytes(b.bytes)}. The source has NOT been touched.`, ); } return { ...a, retried }; } async function verifyMirrored(opts: { rsyncBin: string; src: string; dest: string; log: (m: string) => void; signal?: AbortSignal; mirror: { live: string }; }): Promise<{ bytes: number; files: number; retried: boolean }> { const { rsyncBin, src, dest, log, signal } = opts; let retried = false; // ONE more pass, never a loop: a source that changes again between that // pass and the next check is still being written to, and the answer is to // refuse and name what moved, not to chase it. for (;;) { const { exitCode, output } = await rsyncTree({ rsyncBin, src, dest, args: VERIFY_ARGS, log, signal, }); if (exitCode !== 0) { throw new Error(`Verification rsync failed (exit ${exitCode})`); } const drift = driftLines(output); if (drift.length === 0) break; const differences = classifyDrift(drift); if (!retried) { retried = true; log( drift.every((l) => DIR_MTIME_ONLY.test(l)) ? `Verification found ${drift.length} directory timestamp(s) differing and ` + `no content drift — running one more mirror pass to settle them.` : `Verification found the source changed during the copy — ` + `${describeDifferences(differences)} — running one more mirror pass.`, ); const again = await mirrorTree({ rsyncBin, src, dest, live: opts.mirror.live, log, signal, }); if (again.exitCode !== 0) { throw new Error( `Verification rsync failed (exit ${again.exitCode}). ` + `The source has NOT been touched.`, ); } continue; } const first = [ ...differences.extra, ...differences.missing, ...differences.changed, ][0]; throw new CopyVerificationError( `Verification failed: after a second mirror pass the copy still differs ` + `from the source — ${describeDifferences(differences)} (first: ${first}). ` + `Something is still writing into ${src}: stop it, then Reconcile and ` + `resume. The source has NOT been touched.`, differences, ); } return measuredEqual(src, dest, retried); }