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:
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