Archilyzer · Source

archilyzer

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

commit 875418da67cb789dd55867a7dcf01529b2ac1094
parent 5ebe1e5917062b49755bb7294dbba67547c0fdcd
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 11 Sep 2026 12:12:58 -0400

relocate: a rollback that undid a step it had not recorded, and four smaller edges

THE UNLINK IS A STEP. `renameChannel` records `movedMedia` and `relinked` so the
catch replays only what ran — but the `unlink` of `data/` between them was
untracked, so a throw from the `symlink` (EACCES on a read-only channel dir,
ENOSPC) rolled the media directory back while `data/` stayed DELETED: config
still naming the old target, nothing on disk pointing at it. That is
`inconsistent` — which, as of the previous commit, is a state move-back refuses,
so the rollback left a channel with no way back through the UI. The flag is
recorded and the catch restores the link for it. Not covered by a case: forcing
`unlink` to succeed and the `symlink` after it to fail needs an injection seam
this module does not have, and adding one to test a three-line flag is the worse
trade.

THE MISSING RESUME CELL. out @ {swap, reclaim} and back @ {swap before, swap
after, reclaim} all had cases; back @ copy had none — the only resume where the
rerun finishes an rsync rather than a rename, and the only one where the space
check is asked to credit a partial copy. It asserts the surviving file keeps its
mtime, which is what says rsync skipped it instead of re-sending it.

THE PREVIEW NEVER CHECKED THE ROOT EXISTED. `getFreeBytes` walks up to the
nearest existing ancestor, so a typo'd root statfs'd its parent and previewed
with perfectly plausible numbers; the job then refused, after the operator had
read a confirmation.

MOVE BACK'S SPACE CHECK used neither the resume margin move-out uses — so a
move back could land the media exactly at the disk gate's floor — nor credit for
the bytes already in `data.incoming`, which refused reruns on a volume that had
room for the remainder.

Three nits: `moveBack`'s swap now refuses a `data/` of kind "other" the way
`moveOut` does, which is what keeps its `unlink` meaning "remove the symlink"
and nothing else; `sweepParked`'s `keep` parameter was never passed by either
caller; the channel page read the marker file a second time when
`inspectChannelMedia`'s answer already carried it, and told the operator to
delete `.relocating.json` by hand in the paragraph directly above the button
that does it.

common 968/968.

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

Diffstat:
Mcommon/controller/relocateChannelMedia.test.ts | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/relocateChannelMedia.ts | 40++++++++++++++++++++++++++++++++++++----
Mcommon/controller/renameChannel.ts | 15+++++++++++++--
Meditor/app/channels/[slug]/page.tsx | 16+++++++---------
4 files changed, 104 insertions(+), 15 deletions(-)

diff --git a/common/controller/relocateChannelMedia.test.ts b/common/controller/relocateChannelMedia.test.ts @@ -555,6 +555,54 @@ test("out @ reclaim: the rerun sweeps every parked copy and clears the marker", }); }); +// THE MISSING CELL of the resume matrix: out @ {swap, reclaim} and back @ +// {swap(before), swap(after), reclaim} all had cases, and back @ copy had none — +// the only one where the rerun has to finish an rsync rather than a rename, and +// the only one where the space check is asked to credit a PARTIAL copy. +test("back @ copy: a half-copied data.incoming is resumed, not restarted", async () => { + await withTmp(async (paths, root) => { + const channelDir = await seed(paths, "alpha", { + v1: { "audio.m4a": "one".repeat(500), "transcript.json": "{}" }, + v2: { "audio.m4a": "two".repeat(500) }, + }); + const dataDir = path.join(channelDir, "data"); + const target = relocatedDataDir(root, "alpha"); + await copyTree(dataDir, target); + await rm(dataDir, { recursive: true, force: true }); + await symlink(target, dataDir); + await setConfigDataDir(paths, "alpha", target); + // The copy got one video in and died. The marker says `copy`, so the rerun + // resumes the rsync — and the surviving file keeps its mtime, which is what + // says rsync skipped it rather than re-sending it. + const incoming = path.join(channelDir, "data.incoming"); + await copyTree(path.join(target, "v1"), path.join(incoming, "v1")); + await seedMarker(paths, "alpha", { target, direction: "back", phase: "copy" }); + + const res = await relocateChannelMedia({ + paths, + slug: "alpha", + direction: "back", + onLog: () => {}, + }); + assert.equal(res.resumed, true); + assert.equal(res.files, 3); + assert.equal((await inspectChannelMedia(paths, "alpha")).status, "in-place"); + assert.ok((await lstat(dataDir)).isDirectory()); + assert.equal( + await readFile(path.join(dataDir, "v2", "audio.m4a"), "utf8"), + "two".repeat(500), + ); + assert.equal( + (await stat(path.join(dataDir, "v1", "audio.m4a"))).mtime.getTime(), + MTIME.getTime(), + ); + assert.equal((await readChannelConfig(paths, "alpha"))?.dataDir, undefined); + assert.equal(await pathThere(target), false); + assert.deepEqual(await leftoverCopies(channelDir), []); + assert.equal(await readRelocationMarker(paths, "alpha"), null); + }); +}); + test("back @ swap: crash before the rename — the rerun finishes the swap", async () => { await withTmp(async (paths, root) => { const channelDir = await seed(paths, "alpha", { diff --git a/common/controller/relocateChannelMedia.ts b/common/controller/relocateChannelMedia.ts @@ -256,10 +256,8 @@ async function parkedSiblings(channelDir: string): Promise<string[]> { async function sweepParked( channelDir: string, log: (m: string) => void, - keep?: string, ): Promise<void> { for (const p of await parkedSiblings(channelDir)) { - if (keep && path.resolve(p) === path.resolve(keep)) continue; log(`Reclaiming ${p}`); await rm(p, { recursive: true, force: true }); } @@ -388,6 +386,16 @@ export async function previewRelocation({ // the worst possible answer. const problem = await relocationRootProblem({ paths, slug, root }); if (problem) throw new Error(problem); + // EXISTENCE, HERE AS WELL AS IN THE JOB. getFreeBytes walks up to the nearest + // existing ancestor, so a typo'd root statfs's its parent and previews with + // perfectly plausible numbers — and the move is then refused by the job, after + // the operator has already read a confirmation. + if (!(await isDirectory(root))) { + throw new Error( + `The destination root ${root} does not exist or is not a directory ` + + `(is the drive mounted?)`, + ); + } const source = path.join(paths.channelsDir, slug, "data"); const target = relocatedDataDir(root, slug); const [measured, freeOnRoot, freeOnSource, existingPartial] = await Promise.all([ @@ -793,11 +801,27 @@ async function moveBack(args: { let phase = args.phase; if (phase === "copy") { + // THE SAME BAR MOVE-OUT USES, and for the same reason: landing the media + // with nothing to spare puts the corpus volume under the disk gate's floor + // the moment it arrives. And a resumed move-back must only be charged for + // what is still MISSING — the bytes already sitting in `data.incoming` are + // not about to be written twice, and counting them refused reruns on a disk + // that had room for the remainder. + const settings = getSettings(); + const marginGB = settings.minFreeDiskGB > 0 ? settings.resumeMarginGB : 0; + const already = (await isDirectory(incoming)) + ? (await measureTree(incoming)).bytes + : 0; + const needed = + Math.max(0, measured.bytes - already) + marginGB * BYTES_PER_GB; const free = await getFreeBytes(paths.channelsDir); - if (free < measured.bytes) { + if (free < needed) { throw new Error( `Not enough space on the corpus volume: ${formatBytes(free)} free, ` + - `${formatBytes(measured.bytes)} to move back`, + `${formatBytes(Math.max(0, measured.bytes - already))} still to move back` + + (marginGB > 0 + ? ` plus a ${marginGB} GB resume margin = ${formatBytes(needed)} required` + : " required"), ); } await mkdir(incoming, { recursive: true }); @@ -845,6 +869,14 @@ async function moveBack(args: { // The rename already committed. Nothing to swap; the leftovers are the // reclaim's business. log(`${dataDir} is already a real directory — the swap had completed`); + } else if (state.kind === "other") { + // A regular file (or a socket, or a fifo) where `data/` should be is not + // a link to replace and not a directory to keep. moveOut refuses the same + // shape at its own swap; refusing here too is what keeps `unlink` below + // meaning "remove the symlink" and nothing else. + throw new Error( + `${dataDir} is neither a directory nor a symlink — refusing to replace it`, + ); } else { if (!(await isDirectory(incoming))) { throw new Error( diff --git a/common/controller/renameChannel.ts b/common/controller/renameChannel.ts @@ -143,6 +143,13 @@ export async function renameChannel( // it had not made. Each step records that it happened; the catch replays // only those, in reverse. let movedMedia = false; + // THE UNLINK IS A STEP TOO. It was untracked, so a throw from the symlink + // below (EACCES on a read-only channel dir, ENOSPC) rolled the media + // directory back while `data/` stayed DELETED — config still naming the old + // target, nothing on disk pointing at it: `inconsistent`, which is now a + // state move-back refuses. The catch replays only what ran, and removing the + // link ran. + let unlinked = false; let relinked = false; try { if (await pathExists(path.dirname(newTarget))) { @@ -150,7 +157,11 @@ export async function renameChannel( } await rename(path.dirname(conventional), path.dirname(newTarget)); movedMedia = true; - await unlink(path.join(newChannelDir, "data")).catch(() => {}); + await unlink(path.join(newChannelDir, "data")) + .then(() => { + unlinked = true; + }) + .catch(() => {}); await symlink(newTarget, path.join(newChannelDir, "data")); relinked = true; // Re-read: the channel dir has already moved, so this is the file that @@ -165,7 +176,7 @@ export async function renameChannel( // failure between the symlink and the config write left `data/` pointing // at <root>/<newSlug>/data while everything else was rolled back to the // old slug — a dangling link, which reads as an unmounted drive. - if (relinked) { + if (relinked || unlinked) { await unlink(path.join(newChannelDir, "data")).catch(() => {}); await symlink(conventional, path.join(newChannelDir, "data")).catch( () => {}, diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx @@ -37,10 +37,7 @@ import { type ShardOp, } from "yt-dlp-transcript-common/controller/shard"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { - inspectChannelMedia, - readRelocationMarker, -} from "yt-dlp-transcript-common/lib/channelMedia"; +import { inspectChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia"; import { getFreeBytes } from "yt-dlp-transcript-common/lib/diskSpace"; import { platformQueueKey, @@ -519,10 +516,11 @@ export default async function ChannelDetailPage({ // read first. const volumeDir = media.status === "ok" && media.target ? media.target : paths.channelsDir; - const [freeBytes, marker] = await Promise.all([ - getFreeBytes(volumeDir), - readRelocationMarker(paths, slug), - ]); + // `media` is inspectChannelMedia's answer and it already CARRIES the + // marker — reading the file again here was a second read of the same + // bytes that could disagree with the status rendered beside it. + const marker = media.marker ?? null; + const freeBytes = await getFreeBytes(volumeDir); const activeJobs = runningJobs.filter( (j) => j.status === "running" || j.status === "queued", ).length; @@ -534,7 +532,7 @@ export default async function ChannelDetailPage({ activeJobs > 0 ? `Finish or cancel ${activeJobs} running/queued job(s) for this channel before moving its media.` : marker - ? `A relocation (${marker.direction}) to ${marker.target} is in flight, or was interrupted at phase "${marker.phase}". A channel in transition is not moved again from here — the running job finishes it, and an interrupted one is resumed by removing ${".relocating.json"} from the channel dir only once you have confirmed nothing is copying.` + ? `A relocation (${marker.direction}) to ${marker.target} is in flight, or was interrupted at phase "${marker.phase}". A channel in transition is not moved again from here — the running job finishes it, and an interrupted one is resumed by rerunning the move.` : null; return ( <StorageStage