commit a7a21831d144eb1f033d38ef9f3fdee8b12ceef0
parent 93d0eefb55df91b60f68c9d01f0593bfd882b801
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 21 Sep 2026 02:10:16 -0400
relocate: a move must not materialise the mountpoint
Every absolute-path mkdir in both movers is `{recursive: true}`. Point a move at
`/mnt/platter/media` with the platter unplugged and the mkdir cheerfully builds
that path ON THE ROOT FILESYSTEM, rsync fills it, the channel's symlink is
rewritten to it, and the operator has silently moved a channel onto the disk the
move existed to free — with the real media still on the unmounted platter, and
`/mnt/platter` now non-empty so the platter can no longer mount there. The
saved-video store had the identical hole.
`assertRelocationRootPresent(root, storage, bins)` is two checks:
1. `stat(root)` must be a directory. A move creates `<root>/<slug>` and
`<root>/<slug>/data`, never the root — a root is a fact about the machine.
2. When the root belongs to a LOCATION carrying a learned `volume.uuid`, the
probe must answer `available` with a KNOWN identity whose uuid matches. An
empty mountpoint directory on the root filesystem passes check 1 and fails
this one, because findmnt -T reports the root filesystem's uuid.
It FAILS OPEN on unknown identity (a container has no block devices; refusing
every move on a machine that cannot answer would break relocation for exactly
the deployments that need it) and a root nobody named as a location is
STAT-ONLY — there is no recorded identity to compare against. The nudge out of
that gap is to name the root on /storage.
Called from `relocationRootProblem` — so the preview the operator reads and the
action that enqueues give the same answer — and again immediately before the
mkdir in each of the four copy phases, because a job can sit in the queue for
hours and the drive that was mounted at click time may not be at copy time. On
the two move-BACK paths the guard goes on the LOCATION ROOT, not on `incoming`
(a corpus directory a move-back is entitled to create): the existing
`isDirectory(target)` precondition covers existence, this covers IDENTITY.
The refusal reuses the pre-existing "does not exist or is not a directory"
sentence rather than minting a second wording for one condition, and adds the
location id so the operator knows which drive to plug in.
THE PROBE MEMO MOVED to `lib/storageVolumes.ts` and is re-exported from
`controller/storageLocations.ts`, where every caller still names it. It had to:
the guard probes and is called per channel by the bulk move and the re-point
preflight, but `controller/storageLocations.ts` imports `relocationRootProblem`
from `relocateChannelMedia.ts`, so reaching the memo through it would be an
import cycle. Nothing about a ten-second probe memo is controller-level.
Risk stated: this changes the relocate preflight. Both movers' refusal text is
now specced end to end — `channel-storage.spec.ts` and `storage-locations.spec.ts`
each assert the message AND that nothing was created on the way to it, which is
the actual claim.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
7 files changed, 591 insertions(+), 49 deletions(-)
diff --git a/common/controller/relocateChannelMedia.test.ts b/common/controller/relocateChannelMedia.test.ts
@@ -1,6 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
+ chmod,
lstat,
mkdir,
mkdtemp,
@@ -22,7 +23,20 @@ import {
readRelocationMarker,
relocatedDataDir,
} from "../lib/channelMedia";
-import { previewRelocation, relocateChannelMedia } from "./relocateChannelMedia";
+import {
+ assertRelocationRootPresent,
+ previewRelocation,
+ relocateChannelMedia,
+ relocationRootPresenceProblem,
+} from "./relocateChannelMedia";
+import type {
+ StorageLocation,
+ StorageSettings,
+} from "../lib/storageLocations";
+import {
+ resetStorageProbeMemo,
+ type VolumeBins,
+} from "../lib/storageVolumes";
import { readChannelConfig } from "./channels";
// Run with:
@@ -989,3 +1003,222 @@ test("out @ swap: a file the target is missing is still a refusal", async () =>
assert.equal((await readChannelConfig(paths, "alpha"))?.dataDir, undefined);
});
});
+
+// ---------------------------------------------------------------------------
+// assertRelocationRootPresent — the move must not MATERIALISE the mountpoint
+// ---------------------------------------------------------------------------
+//
+// Every absolute mkdir in the two movers is `{recursive: true}`, so a move
+// aimed at an unmounted platter used to build the whole path on the root
+// filesystem and fill it. The fake findmnt is the same one
+// `lib/storageVolumes.test.ts` uses, for the same reason: a real one needs a
+// real disk.
+
+const FAKE_FINDMNT = `#!/usr/bin/env node
+import { readFileSync } from "node:fs";
+import path from "node:path";
+const control = JSON.parse(
+ readFileSync(path.join(import.meta.dirname, "control.json"), "utf8"),
+);
+const argv = process.argv.slice(2);
+if (argv.includes("--fstab")) {
+ if (!control.fstab) process.exit(1);
+ process.stdout.write(control.fstab + "\\n");
+ process.exit(0);
+}
+if (argv.includes("-S")) {
+ if (!control.uuidTarget) process.exit(1);
+ process.stdout.write(control.uuidTarget + "\\n");
+ process.exit(0);
+}
+if (control.identity === null) process.exit(1);
+process.stdout.write(
+ JSON.stringify({ filesystems: [control.identity] }) + "\\n",
+);
+`;
+
+const PLATTER_UUID = "11111111-2222-3333-4444-555555555555";
+const OTHER_UUID = "99999999-8888-7777-6666-555555555555";
+
+async function withProbe(
+ fn: (
+ ctx: {
+ dir: string;
+ bins: VolumeBins;
+ byUuidDir: string;
+ control: (c: Record<string, unknown>) => Promise<void>;
+ },
+ ) => Promise<void>,
+): Promise<void> {
+ const dir = await mkdtemp(path.join(tmpdir(), "ttb-rootpresent-"));
+ const bin = path.join(dir, "fake-findmnt.mjs");
+ await writeFile(bin, FAKE_FINDMNT);
+ await chmod(bin, 0o755);
+ const byUuidDir = path.join(dir, "by-uuid");
+ await mkdir(byUuidDir, { recursive: true });
+ try {
+ await fn({
+ dir,
+ byUuidDir,
+ bins: { findmntBin: bin, udisksctlBin: path.join(dir, "no-udisksctl") },
+ control: (c) =>
+ writeFile(path.join(dir, "control.json"), JSON.stringify(c)),
+ });
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}
+
+function storageWith(locations: StorageLocation[]): StorageSettings {
+ return { locations, defaultLocationId: "" };
+}
+
+function platterLocation(root: string, uuid?: string): StorageLocation {
+ return {
+ id: "platter",
+ label: "Platter",
+ root,
+ autoRepoint: false,
+ ...(uuid
+ ? {
+ volume: {
+ uuid,
+ fstype: "ext4",
+ mountpoint: path.dirname(root),
+ relPath: path.basename(root),
+ },
+ }
+ : {}),
+ };
+}
+
+test("root present: a missing root is refused before anything is created", async () => {
+ await withProbe(async (h) => {
+ resetStorageProbeMemo();
+ const root = path.join(h.dir, "mnt", "platter", "media");
+ await h.control({ identity: null });
+ const problem = await relocationRootPresenceProblem(
+ root,
+ storageWith([platterLocation(root, PLATTER_UUID)]),
+ h.bins,
+ { byUuidDir: h.byUuidDir, findmntTimeoutMs: 1_000 },
+ );
+ assert.match(String(problem), /does not exist or is not a directory/);
+ assert.match(String(problem), /location "platter"/);
+ // And nothing was created on the way to finding out — that IS the bug.
+ await assert.rejects(() => stat(path.join(h.dir, "mnt")));
+ });
+});
+
+test("root present: a directory on the WRONG volume is refused", async () => {
+ await withProbe(async (h) => {
+ resetStorageProbeMemo();
+ // Exactly the shape the guard exists for: the mountpoint directory is
+ // there, empty, on the root filesystem — the platter is unplugged.
+ const root = path.join(h.dir, "mnt", "platter", "media");
+ await mkdir(root, { recursive: true });
+ await h.control({
+ identity: {
+ target: "/",
+ fstype: "ext4",
+ label: "ROOTFS",
+ uuid: OTHER_UUID,
+ },
+ fstab: `UUID=${OTHER_UUID}`,
+ });
+ const problem = await relocationRootPresenceProblem(
+ root,
+ storageWith([platterLocation(root, PLATTER_UUID)]),
+ h.bins,
+ { byUuidDir: h.byUuidDir, findmntTimeoutMs: 1_000 },
+ );
+ assert.match(String(problem), new RegExp(OTHER_UUID));
+ assert.match(String(problem), new RegExp(PLATTER_UUID));
+ });
+});
+
+test("root present: the right volume passes", async () => {
+ await withProbe(async (h) => {
+ resetStorageProbeMemo();
+ const root = path.join(h.dir, "mnt", "platter", "media");
+ await mkdir(root, { recursive: true });
+ await h.control({
+ identity: {
+ target: path.join(h.dir, "mnt", "platter"),
+ fstype: "ext4",
+ label: "PLATTER",
+ uuid: PLATTER_UUID,
+ },
+ fstab: `UUID=${PLATTER_UUID}`,
+ });
+ assert.equal(
+ await relocationRootPresenceProblem(
+ root,
+ storageWith([platterLocation(root, PLATTER_UUID)]),
+ h.bins,
+ { byUuidDir: h.byUuidDir, findmntTimeoutMs: 1_000 },
+ ),
+ null,
+ );
+ });
+});
+
+test("root present: a root nobody named is stat-only", async () => {
+ await withProbe(async (h) => {
+ resetStorageProbeMemo();
+ const root = path.join(h.dir, "hand-typed");
+ await mkdir(root, { recursive: true });
+ // No location covers it, so there is no recorded identity to compare
+ // against — and a hand-typed root the operator just made must not be
+ // refused for failing a comparison that cannot be made. The findmnt here
+ // would answer a MISMATCH if it were asked; it must not be asked.
+ await h.control({
+ identity: { target: "/", fstype: "ext4", uuid: OTHER_UUID },
+ });
+ assert.equal(
+ await relocationRootPresenceProblem(
+ root,
+ storageWith([platterLocation(path.join(h.dir, "elsewhere"), PLATTER_UUID)]),
+ h.bins,
+ { byUuidDir: h.byUuidDir, findmntTimeoutMs: 1_000 },
+ ),
+ null,
+ );
+ });
+});
+
+test("root present: unknown identity FAILS OPEN — a container has no block devices", async () => {
+ await withProbe(async (h) => {
+ resetStorageProbeMemo();
+ const root = path.join(h.dir, "mnt", "platter", "media");
+ await mkdir(root, { recursive: true });
+ // findmnt exits 1: no identity at all. Refusing here would break
+ // relocation on exactly the deployments that cannot answer the question.
+ await h.control({ identity: null });
+ assert.equal(
+ await relocationRootPresenceProblem(
+ root,
+ storageWith([platterLocation(root, PLATTER_UUID)]),
+ h.bins,
+ { byUuidDir: h.byUuidDir, findmntTimeoutMs: 1_000 },
+ ),
+ null,
+ );
+ });
+});
+
+test("assertRelocationRootPresent throws the problem it finds", async () => {
+ await withProbe(async (h) => {
+ resetStorageProbeMemo();
+ await assert.rejects(
+ () =>
+ assertRelocationRootPresent(
+ path.join(h.dir, "nope"),
+ storageWith([]),
+ h.bins,
+ { byUuidDir: h.byUuidDir, findmntTimeoutMs: 1_000 },
+ ),
+ /does not exist or is not a directory/,
+ );
+ });
+});
diff --git a/common/controller/relocateChannelMedia.ts b/common/controller/relocateChannelMedia.ts
@@ -27,6 +27,16 @@ import {
type RelocationProgress,
} from "./relocateDir";
import { isSocialChannel } from "../lib/channelConfig";
+import {
+ locationOfDataDir,
+ type StorageLocation,
+ type StorageSettings,
+} from "../lib/storageLocations";
+import {
+ probeLocationMemo,
+ type ProbeOptions,
+ type VolumeBins,
+} from "../lib/storageVolumes";
import { getFreeBytes } from "../lib/diskSpace";
import { getSettings } from "../lib/settings";
import { formatBytes } from "../lib/format";
@@ -142,6 +152,10 @@ export async function relocationRootProblem(opts: {
paths: Paths;
slug: string;
root: string;
+ // Test seam only. Defaults to the live settings, because the whole point of
+ // asking here is that the preview, the action and the job give one answer.
+ storage?: StorageSettings;
+ probeOpts?: ProbeOptions;
}): Promise<string | null> {
const root = opts.root.trim();
if (!root) return "No destination root given";
@@ -180,9 +194,142 @@ export async function relocationRootProblem(opts: {
`other drive.`
);
}
+ // LAST, because containment is the destructive answer and presence is the
+ // merely-wrong one. Same call the job makes immediately before its mkdir, so
+ // the preview the operator reads and the run that moves the bytes cannot
+ // disagree about whether the drive is there.
+ return relocationRootPresenceProblem(
+ root,
+ opts.storage ?? getSettings().storage,
+ opts.paths,
+ opts.probeOpts,
+ );
+}
+
+// A MOVE MUST NEVER MATERIALISE A MOUNT, and `mkdir -p` is exactly what does.
+//
+// Every absolute-path `mkdir(…, {recursive: true})` in this file and in
+// relocateSavedVideos.ts creates whatever is missing above it. Point a move at
+// `/mnt/platter/archilyzer-media` with the platter unplugged and the mkdir
+// cheerfully builds that path ON THE ROOT FILESYSTEM, rsync fills it, the
+// channel's symlink is rewritten to it, and the operator has silently moved a
+// channel onto the disk the move existed to free — with the real media still on
+// the unmounted platter, and `/mnt/platter` now non-empty so the platter can no
+// longer mount there.
+//
+// So: before any of those mkdirs, and from `relocationRootProblem` so the
+// PREVIEW the operator reads gives the same answer the action does.
+//
+// Two checks, and the second is the one that catches the case above:
+//
+// 1. `stat(root)` must be a directory. The move creates `<root>/<slug>` and
+// `<root>/<slug>/data`, never the root itself — a root is a fact about the
+// machine, not something a move gets to invent.
+// 2. When the root belongs to a LOCATION that has learned a `volume.uuid`,
+// the probe must answer `available` with a KNOWN identity whose uuid
+// matches. An empty mountpoint directory sitting on the root filesystem
+// passes check 1 and fails this one, because findmnt -T reports the root
+// filesystem's uuid, which is not the platter's.
+//
+// FAILS OPEN ON UNKNOWN IDENTITY, deliberately. `probeLocation`'s identity is
+// unknown when findmnt is missing or wedged (a container has no block devices
+// at all), and refusing every move on a machine that cannot answer the question
+// would break relocation for exactly the deployments that most need it. Only a
+// KNOWN MISMATCH refuses.
+//
+// A root nobody named as a location is STAT-ONLY: there is no recorded identity
+// to compare against, so there is nothing to compare. That is the documented
+// gap and the nudge out of it — an unmounted fstab mountpoint directory is
+// precisely what a location protects you from, so add it on /storage.
+function locationForRoot(
+ root: string,
+ locations: StorageLocation[],
+): StorageLocation | null {
+ // `root + "/x"` rather than `root`: `locationOfDataDir` is deliberately
+ // STRICT ("under", not "equal to"), because a channel's dataDir is always
+ // `<root>/<slug>/data` and equality there only ever means a misconfiguration.
+ // Here equality is the ordinary case — the destination root IS the location
+ // root — so the question is asked about a path one level inside it.
+ return locationOfDataDir(path.join(root, "x"), locations);
+}
+
+export async function relocationRootPresenceProblem(
+ root: string,
+ storage: StorageSettings,
+ bins: VolumeBins,
+ probeOpts?: ProbeOptions,
+): Promise<string | null> {
+ const r = root.trim();
+ if (!r) return "No destination root given";
+ const named = locationForRoot(r, storage.locations);
+ const where = named ? ` (location "${named.id}")` : "";
+
+ let isDir = false;
+ try {
+ isDir = (await stat(r)).isDirectory();
+ } catch {
+ isDir = false;
+ }
+ if (!isDir) {
+ // THE SAME SENTENCE the pre-existing existence check uses, deliberately:
+ // two refusals for one condition that read differently is two bugs to
+ // report. This one fires first and adds where the root was supposed to be.
+ return (
+ `The destination root ${r} does not exist or is not a directory${where}. ` +
+ `A move creates <root>/<slug>, never the root itself — ` +
+ (named
+ ? `mount the drive or re-point the location first.`
+ : `create it first.`)
+ );
+ }
+
+ if (!named?.volume?.uuid) return null;
+
+ // MEMOIZED, because this is called once per channel by the bulk move and by
+ // the re-point preflight — seventy-one rows times three subprocesses is the
+ // thing the memo exists to stop. Ten seconds; `refresh` is what the operator
+ // presses after plugging a disk in.
+ const probe = await probeLocationMemo(named, bins, probeOpts);
+ if (probe.status !== "available") {
+ return (
+ `The destination root ${r} is ${probe.status}${where}. ` +
+ `Mount it or re-point the location first.`
+ );
+ }
+ if (probe.identity.known && probe.identity.uuid !== named.volume.uuid) {
+ return (
+ `The destination root ${r}${where} is on volume ` +
+ `${probe.identity.uuid}, not the ${named.volume.uuid} this location was ` +
+ `last seen on — the drive is not mounted there. Mount it or re-point ` +
+ `the location first.`
+ );
+ }
return null;
}
+// The inverse of `relocatedDataDir`: `<root>/<slug>/data` -> `<root>`. The
+// suffix is fixed (channelMedia.ts says so, and deleteChannel recognises a
+// target by it), so this is two dirnames and not a guess.
+export function rootOfRelocatedDataDir(target: string, slug: string): string {
+ const parent = path.dirname(target);
+ return path.basename(parent) === slug ? path.dirname(parent) : parent;
+}
+
+export async function assertRelocationRootPresent(
+ root: string,
+ storage: StorageSettings,
+ bins: VolumeBins,
+ probeOpts?: ProbeOptions,
+): Promise<void> {
+ const problem = await relocationRootPresenceProblem(
+ root,
+ storage,
+ bins,
+ probeOpts,
+ );
+ if (problem) throw new Error(problem);
+}
+
// Every leftover a crashed run can have parked next to `data/`, in one list.
// The reclaim phase sweeps ALL of them rather than the one name the run that is
// finishing happens to hold: a crash between the config write and the reclaim
@@ -479,6 +626,11 @@ async function moveOut(args: {
: " required"),
);
}
+ // THE LAST THING BEFORE THE MKDIR THAT WOULD INVENT THE MOUNTPOINT.
+ // Re-asked here and not only at enqueue time: a job can sit in the queue
+ // for hours behind other work, and the drive that was mounted when the
+ // operator clicked may not be mounted when the copy starts.
+ await assertRelocationRootPresent(root, settings.storage, paths);
await mkdir(target, { recursive: true });
// WHAT A PREVIOUS ATTEMPT ALREADY LANDED, so the bar is about the TREE and
// not about this process's share of it. rsync counts only what it sends: a
@@ -708,6 +860,18 @@ async function moveBack(args: {
: " required"),
);
}
+ // THE GUARD GOES ON THE LOCATION ROOT, NOT ON `incoming`. `incoming` is
+ // `channels/<slug>/data.incoming`, a corpus directory a move-back is
+ // entitled to create. What must be present is the SOURCE side: the
+ // `isDirectory(target)` precondition above covers existence, and this
+ // covers IDENTITY — an empty mountpoint directory with the platter
+ // unplugged is a directory, and copying it back would report a successful
+ // move of zero bytes and then delete the target.
+ await assertRelocationRootPresent(
+ rootOfRelocatedDataDir(target, slug),
+ settings.storage,
+ paths,
+ );
await mkdir(incoming, { recursive: true });
await writeMarker(paths, slug, {
target,
diff --git a/common/controller/relocateSavedVideos.ts b/common/controller/relocateSavedVideos.ts
@@ -36,6 +36,7 @@ import {
writeDirMarker,
type RelocationProgress,
} from "./relocateDir";
+import { assertRelocationRootPresent } from "./relocateChannelMedia";
// MOVING THE SAVED-VIDEO STORE TO ANOTHER DRIVE.
//
@@ -358,6 +359,11 @@ async function moveStoreOut(a: Inner): Promise<SavedVideosRelocateResult> {
: " required"),
);
}
+ // THE MOVE MUST NOT MATERIALISE THE MOUNTPOINT — see
+ // `assertRelocationRootPresent`. `mkdir(target, {recursive: true})` below
+ // would otherwise build `<root>/saved-videos` on the root filesystem with
+ // the platter unplugged, and every pinned container would land there.
+ await assertRelocationRootPresent(loc.root, settings.storage, paths);
await mkdir(target, { recursive: true });
// What a previous attempt already landed — see the channel mover.
const already = (await measureTree(target)).bytes;
@@ -565,6 +571,17 @@ async function moveStoreBack(a: Inner): Promise<SavedVideosRelocateResult> {
: " required"),
);
}
+ // ON THE SOURCE SIDE'S LOCATION ROOT, not on `incoming` — `incoming` is a
+ // corpus directory a move-back is entitled to create. `isDirectory(target)`
+ // above covers existence; this covers IDENTITY, because an empty
+ // mountpoint directory with the drive unplugged IS a directory, and
+ // copying it back would report a clean move of zero bytes and then delete
+ // the target.
+ await assertRelocationRootPresent(
+ path.dirname(target),
+ settings.storage,
+ paths,
+ );
await mkdir(incoming, { recursive: true });
await stampMarker(markerFile, target, "back", "copy");
const { exitCode } = await rsyncTree({
diff --git a/common/controller/storageLocations.ts b/common/controller/storageLocations.ts
@@ -14,6 +14,10 @@ import {
} from "../lib/storageLocations";
import {
probeLocation,
+ probeLocationMemo,
+ resetStorageProbeMemo,
+ PROBE_MEMO_MS,
+ type MemoizedProbe,
type ProbeOptions,
type StorageLocationProbe,
type VolumeBins,
@@ -238,55 +242,21 @@ export async function volumeFreeBytes(opts: {
// ---------------------------------------------------------------------------
// The probe memo
// ---------------------------------------------------------------------------
-
-export type MemoizedProbe = StorageLocationProbe & {
- // When this answer was taken, ms since epoch. The page renders it as an age.
- probedAt: number;
-};
-
-export const PROBE_MEMO_MS = 10_000;
-
-type MemoEntry = { root: string; probedAt: number; probe: StorageLocationProbe };
-const probeMemo = new Map<string, MemoEntry>();
-
-// Test seam, and the escape hatch for a process that has just written a root.
-export function resetStorageProbeMemo(): void {
- probeMemo.clear();
-}
-
-// Probe a location, at most once per PROBE_MEMO_MS.
//
-// KEYED BY ID, INVALIDATED BY ROOT. /storage renders on every navigation and a
-// probe is up to three subprocesses; ten seconds is short enough that a disk
-// the operator just plugged in shows up on the next reload and long enough that
-// a page with six locations does not fork eighteen processes per click. The
-// root is carried in the entry because a re-point changes it under the same id,
-// and answering for the old root would show the operator the state they just
-// left.
-//
-// `refresh` BYPASSES the memo — that is what the Refresh button is for. It is
-// not the same as a short TTL: the operator pressing Refresh has just done
-// something physical (plugged the disk in, mounted it) and is asking for an
-// answer taken after it.
-export async function probeLocationMemo(
- loc: StorageLocation,
- bins: VolumeBins,
- opts: ProbeOptions & { refresh?: boolean; now?: number } = {},
-): Promise<MemoizedProbe> {
- const now = opts.now ?? Date.now();
- const hit = probeMemo.get(loc.id);
- if (
- !opts.refresh &&
- hit &&
- hit.root === loc.root &&
- now - hit.probedAt < PROBE_MEMO_MS
- ) {
- return { ...hit.probe, probedAt: hit.probedAt };
- }
- const probe = await probeLocation(loc, bins, opts);
- probeMemo.set(loc.id, { root: loc.root, probedAt: now, probe });
- return { ...probe, probedAt: now };
-}
+// IT LIVES IN `lib/storageVolumes.ts` NOW, and is re-exported here because
+// every caller names it through this module. It had to move: the relocation
+// movers' root-presence guard (`assertRelocationRootPresent`) probes, and it is
+// called per channel from the bulk move and the re-point preflight — but THIS
+// module imports `relocationRootProblem` from `relocateChannelMedia.ts`, so a
+// memo reached from there through here would be an import cycle. Nothing about
+// a ten-second probe memo is controller-level; it is volume machinery.
+
+export {
+ probeLocationMemo,
+ resetStorageProbeMemo,
+ PROBE_MEMO_MS,
+ type MemoizedProbe,
+};
export async function probeAllLocations(
locations: readonly StorageLocation[],
diff --git a/common/lib/storageVolumes.ts b/common/lib/storageVolumes.ts
@@ -361,3 +361,56 @@ export async function mountByUuid(
const m = /\bat\s+(.+?)\.?\s*$/m.exec(res.stdout.trim());
return { ok: true, mountpoint: m ? m[1] : undefined };
}
+
+// ---------------------------------------------------------------------------
+// The probe memo
+// ---------------------------------------------------------------------------
+
+export type MemoizedProbe = StorageLocationProbe & {
+ // When this answer was taken, ms since epoch. The page renders it as an age.
+ probedAt: number;
+};
+
+export const PROBE_MEMO_MS = 10_000;
+
+type MemoEntry = { root: string; probedAt: number; probe: StorageLocationProbe };
+const probeMemo = new Map<string, MemoEntry>();
+
+// Test seam, and the escape hatch for a process that has just written a root.
+export function resetStorageProbeMemo(): void {
+ probeMemo.clear();
+}
+
+// Probe a location, at most once per PROBE_MEMO_MS.
+//
+// KEYED BY ID, INVALIDATED BY ROOT. /storage renders on every navigation and a
+// probe is up to three subprocesses; ten seconds is short enough that a disk
+// the operator just plugged in shows up on the next reload and long enough that
+// a page with six locations does not fork eighteen processes per click. The
+// root is carried in the entry because a re-point changes it under the same id,
+// and answering for the old root would show the operator the state they just
+// left.
+//
+// `refresh` BYPASSES the memo — that is what the Refresh button is for. It is
+// not the same as a short TTL: the operator pressing Refresh has just done
+// something physical (plugged the disk in, mounted it) and is asking for an
+// answer taken after it.
+export async function probeLocationMemo(
+ loc: StorageLocation,
+ bins: VolumeBins,
+ opts: ProbeOptions & { refresh?: boolean; now?: number } = {},
+): Promise<MemoizedProbe> {
+ const now = opts.now ?? Date.now();
+ const hit = probeMemo.get(loc.id);
+ if (
+ !opts.refresh &&
+ hit &&
+ hit.root === loc.root &&
+ now - hit.probedAt < PROBE_MEMO_MS
+ ) {
+ return { ...hit.probe, probedAt: hit.probedAt };
+ }
+ const probe = await probeLocation(loc, bins, opts);
+ probeMemo.set(loc.id, { root: loc.root, probedAt: now, probe });
+ return { ...probe, probedAt: now };
+}
diff --git a/editor/e2e/channel-storage.spec.ts b/editor/e2e/channel-storage.spec.ts
@@ -618,3 +618,54 @@ test("Resume move finishes an interrupted move and clears its marker", async ({
);
expect(file.status()).toBe(200);
});
+
+// A MOVE MUST NOT MATERIALISE THE MOUNTPOINT.
+//
+// `mkdir(target, {recursive: true})` builds every missing level, so a move at a
+// location whose drive is not mounted used to create `<root>/<slug>/data` on
+// the ROOT FILESYSTEM, rsync into it, and rewrite the channel's symlink — the
+// media silently moved onto the disk the move existed to free, while the real
+// copy sat on the unplugged platter and `<root>` was now non-empty, so the
+// platter could no longer mount there.
+//
+// The preview and the job ask the SAME question (relocationRootProblem calls
+// the guard), so the refusal the operator reads before committing is the one
+// the job would have given.
+test("a move to an unmounted root refuses before it creates anything", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(90_000);
+ await resetData("one-youtube-channel-with-data");
+ await generateReport(page, SLUG);
+ await quiet(page);
+ // Deliberately NOT created. An unmounted mountpoint whose parent does not
+ // exist either is the sharpest version: mkdir -p would have made both.
+ const root = join(testInfo.outputPath("never-mounted"), "platter");
+ await writeSettings({
+ adminTitle: "Test Admin",
+ minFreeDiskGB: 0,
+ storage: {
+ locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }],
+ defaultLocationId: "cold",
+ },
+ });
+
+ await page.goto(channelStage(SLUG, "storage"));
+ await page.getByLabel("destination root").fill(root);
+ await page.getByRole("button", { name: "Preview" }).click();
+ const alert = page.getByRole("alert");
+ await expect(alert).toContainText(/does not exist or is not a directory/, {
+ timeout: 15_000,
+ });
+ // It names the location, so the operator knows WHICH drive to plug in.
+ await expect(alert).toContainText('location "cold"');
+ // And the move stays gated: no preview, no Move.
+ await expect(page.getByLabel("relocation preview")).toHaveCount(0);
+ await expect(page.getByRole("button", { name: "Move media" })).toBeDisabled();
+
+ // NOTHING WAS CREATED on the way to finding out — that IS the bug.
+ expect(await pathExists(root)).toBe(false);
+ expect(await pathExists(join(root, SLUG))).toBe(false);
+ // The media is still a real directory in the corpus, unmoved.
+ expect((await lstat(dataDir())).isDirectory()).toBe(true);
+});
diff --git a/editor/e2e/storage-locations.spec.ts b/editor/e2e/storage-locations.spec.ts
@@ -504,3 +504,57 @@ test("an interrupted store move is resumable from the page", async ({
await pathExists("test-transcripts/.relocating-saved-videos.json"),
).toBe(false);
});
+
+// A MOVE MUST NOT MATERIALISE THE MOUNTPOINT.
+//
+// Every absolute mkdir in both movers is `{recursive: true}`, so a store move
+// aimed at an unmounted platter used to build `<root>/saved-videos` on the ROOT
+// FILESYSTEM and fill it: the operator's pinned containers land on the disk the
+// move existed to free, the real store stays on the unplugged drive, and
+// `<root>` is now non-empty so the drive can no longer mount there.
+//
+// The claim is "refuses BEFORE it creates anything", so the assertion is on the
+// filesystem as much as on the message.
+test("a store move to an unmounted root refuses before it creates anything", async ({
+ page,
+}, testInfo) => {
+ test.setTimeout(90_000);
+ await resetData("one-youtube-channel-with-data");
+ // The root the location names is NOT created — that is the whole point.
+ const root = join(testInfo.outputPath("never-mounted"), "platter");
+ const storeDir = resolvePath("test-transcripts/saved-videos");
+ await mkdir(join(storeDir, SLUG, VIDEO), { recursive: true });
+ await writeFile(
+ join(storeDir, SLUG, VIDEO, "source-media.mp4"),
+ "not really an mp4",
+ );
+ await writeSettings({
+ adminTitle: "Test Admin",
+ minFreeDiskGB: 0,
+ storage: {
+ locations: [{ id: "cold", label: "Cold", root, autoRepoint: false }],
+ defaultLocationId: "cold",
+ },
+ });
+
+ await page.goto("/storage");
+ const card = page.getByLabel("saved video store");
+ await expect(card).toBeVisible();
+ await expect(card.getByLabel("saved videos destination")).toHaveValue("cold");
+ await page.getByRole("button", { name: "Move the store" }).click();
+ await expect(page.getByLabel("Move the store output")).toContainText(
+ /does not exist or is not a directory/,
+ { timeout: 60_000 },
+ );
+
+ // NOTHING WAS CREATED on the way to finding out, and the store is untouched.
+ expect(await pathExists(root)).toBe(false);
+ expect(await pathExists(join(root, "saved-videos"))).toBe(false);
+ expect(
+ await readFile(join(storeDir, SLUG, VIDEO, "source-media.mp4"), "utf8"),
+ ).toBe("not really an mp4");
+ const settings = await readJson<{
+ storage: { savedVideosLocationId?: string };
+ }>("test-settings.json");
+ expect(settings.storage.savedVideosLocationId ?? "").toBe("");
+});