Archilyzer · Source

archilyzer

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

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:
Mcommon/controller/relocateChannelMedia.test.ts | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/relocateChannelMedia.ts | 32++++++++++++++++++++++++++++----
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