The suite allocated temp dirs at 68 sites across 21 files and removed none,
accumulating ~6700 directories and 189MB per machine-day, some holding age
keys. All 68 now go through a single `tmp()` helper allocating inside a
per-run root that vitest's globalSetup teardown removes wholesale, and a
class-guard test fails if `mkdtempSync` appears under test/ outside the
helpers.
The per-worker `process.once("exit")` reaper that suggests itself here does
not work under vitest and fails silently: the pool recycles workers by
killing them, so exit handlers registered in a test file never run. Measured
— a probe test writing from an exit hook produced no file, and a full run
with per-worker hooks still left 750 directories. globalSetup's teardown runs
in the main process, after every worker, and vitest awaits it.
Separately, and contrary to #117's framing that "cast itself does not leak":
resolveCheckout() mkdtemps an `infra-checkout-` dir, clones the infra repo
into it, and never removes it, so every `cast apply`/`diff`/`capture` without
--path leaked a full clone. The box that reported #117 was holding 602 such
directories, 73MB of real .git trees, from the same day. The leak fires on
the failure path too, since the dir is created before the clone runs.
Ephemeral checkouts are now reaped on process exit — the lifetime that fits,
since callers read the tree after resolveCheckout returns; a --path checkout
is the operator's own tree and is never registered.
Empirical: /tmp/cast-* + /tmp/infra-* count is 0 before and 0 after a full
`npm test`, against 750 with the exit-hook design. 626 tests green.
Refs #117
52 lines
2.1 KiB
TypeScript
52 lines
2.1 KiB
TypeScript
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
import { join, relative } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
const TEST_DIR = fileURLToPath(new URL(".", import.meta.url));
|
|
|
|
// The only files allowed to call the raw API: the allocator and the per-run
|
|
// root that global setup reaps. Everything else goes through `tmp()`.
|
|
const HELPER = "helpers/tmp.ts";
|
|
const ALLOWED = new Set([HELPER, "helpers/global-setup.ts"]);
|
|
|
|
// Assembled at runtime rather than written out, so THIS file is not itself a
|
|
// hit. Exempting the guard by path instead would punch a permanent hole in the
|
|
// very check it performs.
|
|
const NEEDLE = ["mkdtemp", "Sync"].join("");
|
|
|
|
function walk(dir: string, acc: string[] = []): string[] {
|
|
for (const entry of readdirSync(dir)) {
|
|
if (entry === "node_modules") continue;
|
|
const full = join(dir, entry);
|
|
if (statSync(full).isDirectory()) walk(full, acc);
|
|
else acc.push(full);
|
|
}
|
|
return acc;
|
|
}
|
|
|
|
// The class guard the issue asks for (#117): the suite leaked 6731 temp dirs
|
|
// because cleanup was opt-in and 68 sites opted out. Making `tmp()` the only
|
|
// way to allocate is what keeps the next site clean by default — and this test
|
|
// is what stops the next raw call surviving review, the same shape as box#112's
|
|
// eof_guard_sweep. It is a source-text check on purpose: it fails at the point
|
|
// the habit returns, not after a machine-day of accumulation.
|
|
describe("temp dir allocation", () => {
|
|
it(`uses the tmp() helper everywhere — no raw ${NEEDLE} under test/`, () => {
|
|
const offenders = walk(TEST_DIR)
|
|
.filter((f) => /\.(ts|js|mjs|sh)$/.test(f))
|
|
.filter((f) => !ALLOWED.has(relative(TEST_DIR, f)))
|
|
.filter((f) => readFileSync(f, "utf8").includes(NEEDLE))
|
|
.map((f) => relative(TEST_DIR, f))
|
|
.sort();
|
|
|
|
expect(offenders).toEqual([]);
|
|
});
|
|
|
|
it("keeps the helper as the single allocation point", () => {
|
|
const helper = readFileSync(join(TEST_DIR, HELPER), "utf8");
|
|
expect(helper).toContain(NEEDLE);
|
|
// and it must actually reap
|
|
expect(helper).toContain("rmSync");
|
|
});
|
|
});
|