commit d75e71202184e49c7aff6f031c0e19f3f8f3d186
parent 828265ccbe471894ee444380fbddb8f100ad4d70
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 21 Sep 2026 02:23:42 -0400
relocate: the space check must not depend on how much RAM is free
Eight tests in `relocateChannelMedia.test.ts` fail on plain main on this
machine: "Not enough space on /tmp/ttb-relocate-XXXX/platter: 1.78 GB free,
2.9 KB to move plus a 2 GB resume margin = 2.00 GB required". The fixtures live
under `os.tmpdir()`, which on Linux is a TMPFS sized from RAM, and the mover
demands `bytes + resumeMarginGB` free on the destination. So a suite with
nothing to do with disk space failed on a box whose tmpfs was below 2 GB — and
would pass again after a reboot, which is the worst kind of red.
`relocateChannelMedia` now takes the same `io?: { read }` settings seam
`relocateSavedVideos` already had for the same reason, threaded into both
movers' space checks and into the root-presence guard's settings read.
Production passes nothing and gets `getSettings()`, unchanged.
The tests set `minFreeDiskGB: 0`, which is the honest way to say it rather than
shrinking the margin to a fake value: the margin exists to clear the disk
gate's floor, and with the gate off there is no floor — that is production's own
rule (`minFreeDiskGB > 0 ? resumeMarginGB : 0`), not a special case invented for
the test. `resumeMarginGB` keeps its real value, so none of the arithmetic is
faked. Their `storage` block has no locations, which makes the root-presence
guard stat-only — what a hand-typed tmpdir root is in production too.
`relocateSavedVideos` needed nothing: it already read settings through `io`,
which is why none of its tests were in the failing set.
30/30 in that file and 1449/1449 across `common` with TMPDIR left alone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 82 insertions(+), 4 deletions(-)
diff --git a/common/controller/relocateChannelMedia.test.ts b/common/controller/relocateChannelMedia.test.ts
@@ -18,6 +18,7 @@ import {
import { tmpdir } from "node:os";
import path from "node:path";
import type { Paths } from "../lib/paths";
+import type { SiteSettings } from "../lib/settings";
import {
inspectChannelMedia,
readRelocationMarker,
@@ -47,6 +48,32 @@ import { readChannelConfig } from "./channels";
const MTIME = new Date("2021-03-04T05:06:07.000Z");
+// THE SETTINGS SEAM, AND WHY EVERY CALL BELOW PASSES IT.
+//
+// The mover's space check demands `bytes + resumeMarginGB` free on the
+// destination, and the shipped default is 2 GB. These fixtures live under
+// `os.tmpdir()`, which on Linux is a TMPFS sized from RAM — with under 2 GB
+// free on it, eight of these tests failed with "2.9 KB to move plus a 2 GB
+// resume margin", on a machine and in a suite that had nothing to do with disk
+// space. A test that fails because of how much RAM is left is not testing the
+// mover.
+//
+// `minFreeDiskGB: 0` is the honest way to say it: the margin exists to clear
+// the disk gate's floor, and with the gate off there is no floor to clear —
+// which is production's own rule (`minFreeDiskGB > 0 ? resumeMarginGB : 0`),
+// not a special case invented here. The margin ITSELF still has its real value,
+// so nothing about the arithmetic is faked.
+const TEST_IO = {
+ read: () =>
+ ({
+ minFreeDiskGB: 0,
+ resumeMarginGB: 2,
+ // No configured locations: the root-presence guard is then stat-only,
+ // which is what a hand-typed tmpdir root is in production too.
+ storage: { locations: [], defaultLocationId: "" },
+ }) as unknown as SiteSettings,
+};
+
async function withTmp(
fn: (paths: Paths, root: string, dir: string) => Promise<void>,
): Promise<void> {
@@ -115,6 +142,7 @@ test("out: copies, links, records the target, keeps mtimes and reclaims the sour
const target = relocatedDataDir(root, "alpha");
const res = await relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
@@ -178,6 +206,7 @@ test("abort from an onLog hook leaves the source intact, and the rerun completes
await assert.rejects(
() =>
relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
@@ -210,6 +239,7 @@ test("abort from an onLog hook leaves the source intact, and the rerun completes
// The rerun finishes it. It is the ONE caller allowed to look past its own
// marker.
const res = await relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
@@ -238,6 +268,7 @@ test("a verify failure keeps the source and does not write the config", async ()
await assert.rejects(
() =>
relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
@@ -263,6 +294,7 @@ test("back: restores a real directory, clears the config and reclaims the target
});
const target = relocatedDataDir(root, "alpha");
await relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
@@ -271,6 +303,7 @@ test("back: restores a real directory, clears the config and reclaims the target
});
const res = await relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "back",
@@ -309,6 +342,7 @@ test("moving back a channel that was never moved is refused", async () => {
await assert.rejects(
() =>
relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "back",
@@ -323,6 +357,7 @@ test("a relocated channel is refused a second move without a move back", async (
await withTmp(async (paths, root, dir) => {
await seed(paths, "alpha", { v1: { "audio.m4a": "one" } });
await relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
@@ -334,6 +369,7 @@ test("a relocated channel is refused a second move without a move back", async (
await assert.rejects(
() =>
relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
@@ -351,6 +387,7 @@ test("an unmounted root is refused before anything is written", async () => {
await assert.rejects(
() =>
relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
@@ -370,6 +407,7 @@ test("a social channel has no media to relocate", async () => {
await assert.rejects(
() =>
relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "poster",
direction: "out",
@@ -488,6 +526,7 @@ test("out @ swap: crash before the rename — the rerun re-verifies and complete
await seedMarker(paths, "alpha", { target, direction: "out", phase: "swap" });
const res = await relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
@@ -522,6 +561,7 @@ test("out @ swap: crash after the config write — the first run's parked copy i
await seedMarker(paths, "alpha", { target, direction: "out", phase: "swap" });
await relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
@@ -557,6 +597,7 @@ test("out @ reclaim: the rerun sweeps every parked copy and clears the marker",
});
await relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
@@ -593,6 +634,7 @@ test("back @ copy: a half-copied data.incoming is resumed, not restarted", async
await seedMarker(paths, "alpha", { target, direction: "back", phase: "copy" });
const res = await relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "back",
@@ -633,6 +675,7 @@ test("back @ swap: crash before the rename — the rerun finishes the swap", asy
await seedMarker(paths, "alpha", { target, direction: "back", phase: "swap" });
const res = await relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "back",
@@ -663,6 +706,7 @@ test("back @ swap: crash AFTER the rename — the rerun does not ENOENT forever"
await seedMarker(paths, "alpha", { target, direction: "back", phase: "swap" });
await relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "back",
@@ -693,6 +737,7 @@ test("back @ reclaim: the config is already clear, and the rerun still finishes"
});
await relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "back",
@@ -713,6 +758,7 @@ test("a marker for the other direction is never resumed into", async () => {
await seedMarker(paths, "alpha", { target, direction: "out", phase: "swap" });
await assert.rejects(
relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "back",
@@ -751,6 +797,7 @@ test("back: an inconsistent channel is refused, and the target keeps its bytes",
});
const target = relocatedDataDir(root, "alpha");
await relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
@@ -769,6 +816,7 @@ test("back: an inconsistent channel is refused, and the target keeps its bytes",
await assert.rejects(
() =>
relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "back",
@@ -801,6 +849,7 @@ test("back: an unreachable channel is refused", async () => {
await seed(paths, "alpha", { v1: { "audio.m4a": "one" } });
const target = relocatedDataDir(root, "alpha");
await relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
@@ -816,6 +865,7 @@ test("back: an unreachable channel is refused", async () => {
await assert.rejects(
() =>
relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "back",
@@ -847,6 +897,7 @@ test("a root inside the corpus is refused by the job and by the preview", async
await assert.rejects(
() =>
relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
@@ -891,6 +942,7 @@ test("a root that links back into the channel dir is refused", async () => {
await assert.rejects(
() =>
relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
@@ -941,6 +993,7 @@ test("out @ swap: a directory timestamp is settled by one more pass, not refused
const lines: string[] = [];
const res = await relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
@@ -984,6 +1037,7 @@ test("out @ swap: a file the target is missing is still a refusal", async () =>
await assert.rejects(
() =>
relocateChannelMedia({
+ io: TEST_IO,
paths,
slug: "alpha",
direction: "out",
diff --git a/common/controller/relocateChannelMedia.ts b/common/controller/relocateChannelMedia.ts
@@ -38,7 +38,7 @@ import {
type VolumeBins,
} from "../lib/storageVolumes";
import { getFreeBytes } from "../lib/diskSpace";
-import { getSettings } from "../lib/settings";
+import { getSettings, type SiteSettings } from "../lib/settings";
import { formatBytes } from "../lib/format";
import {
inspectChannelMedia,
@@ -103,6 +103,20 @@ type RelocateOpts = {
// nothing and pays only the parse.
onProgress?: (p: RelocationProgress) => void;
signal?: AbortSignal;
+ // THE SETTINGS READ, INJECTABLE — the same seam `relocateSavedVideos` already
+ // takes for the same reason, and the reason is not style.
+ //
+ // The space check demands `bytes + resumeMarginGB` free on the destination,
+ // and `resumeMarginGB` defaults to 2 GB. Every test in this file builds its
+ // corpus and its "platter" under `os.tmpdir()`, which on a Linux box is a
+ // TMPFS sized from RAM — 1.8 GB free here — so eight of them failed with
+ // "Not enough space ... 2.9 KB to move plus a 2 GB resume margin", on a
+ // machine, in a suite, that had nothing to do with disk space. A test that
+ // fails because of how much RAM the machine happens to have left is not
+ // testing the mover.
+ //
+ // Production passes nothing and gets `getSettings()`, unchanged.
+ io?: { read: () => SiteSettings };
};
export type RelocationPreview = {
@@ -433,6 +447,7 @@ export async function relocateChannelMedia(
): Promise<RelocateChannelMediaResult> {
const { paths, slug, direction, signal } = opts;
const log = opts.onLog ?? ((m: string) => console.log(m));
+ const io = opts.io ?? { read: getSettings };
const onProgress = opts.onProgress;
const config = await readChannelConfig(paths, slug);
if (!config) throw new Error(`Channel "${slug}" not found`);
@@ -478,6 +493,7 @@ export async function relocateChannelMedia(
return moveBack({
paths,
slug,
+ io,
config,
channelDir,
dataDir,
@@ -495,7 +511,12 @@ export async function relocateChannelMedia(
// relocationRootProblem. The action and the preview ask the same question
// earlier so the operator does not find out from a job log, but this is the
// one that is load-bearing.
- const rootProblem = await relocationRootProblem({ paths, slug, root });
+ const rootProblem = await relocationRootProblem({
+ paths,
+ slug,
+ root,
+ storage: io.read().storage,
+ });
if (rootProblem) throw new Error(rootProblem);
const target = relocatedDataDir(root, slug);
if (existingMarker && existingMarker.target !== target) {
@@ -522,6 +543,7 @@ export async function relocateChannelMedia(
return moveOut({
paths,
slug,
+ io,
config,
channelDir,
dataDir,
@@ -540,6 +562,7 @@ export async function relocateChannelMedia(
async function moveOut(args: {
paths: Paths;
slug: string;
+ io: { read: () => SiteSettings };
config: NonNullable<Awaited<ReturnType<typeof readChannelConfig>>>;
channelDir: string;
dataDir: string;
@@ -612,7 +635,7 @@ async function moveOut(args: {
// is ZERO when the gate is switched off, because the margin exists to clear
// a bar that then does not exist. Demanding headroom for a gate nobody
// armed would refuse a move on a disk with room for it.
- const settings = getSettings();
+ const settings = args.io.read();
const marginGB =
settings.minFreeDiskGB > 0 ? settings.resumeMarginGB : 0;
const needed = measured.bytes + marginGB * BYTES_PER_GB;
@@ -774,6 +797,7 @@ async function moveOut(args: {
async function moveBack(args: {
paths: Paths;
slug: string;
+ io: { read: () => SiteSettings };
config: NonNullable<Awaited<ReturnType<typeof readChannelConfig>>>;
channelDir: string;
dataDir: string;
@@ -843,7 +867,7 @@ async function moveBack(args: {
// 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 settings = args.io.read();
const marginGB = settings.minFreeDiskGB > 0 ? settings.resumeMarginGB : 0;
const already = (await isDirectory(incoming))
? (await measureTree(incoming)).bytes