cast/test/helpers/tmp.ts

55 lines
1.8 KiB
TypeScript
Raw Permalink Normal View History

fix: reap temp dirs — a runtime clone leak in resolveCheckout, and 68 uncleaned test sites 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
2026-07-19 23:38:53 +00:00
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { RUN_ROOT_ENV } from "./global-setup.js";
// Every temp dir this process has handed out, in creation order.
const created: string[] = [];
// Where to allocate. Under `vitest run` this is the per-run root that
// global-setup.ts created and will remove wholesale when the run ends; the
// fallback keeps `tmp()` usable if a file is ever executed outside that config.
function base(): string {
return process.env[RUN_ROOT_ENV] ?? tmpdir();
}
// Belt-and-braces reaper for the fallback case only.
//
// It is deliberately NOT the primary mechanism. Vitest recycles its pool workers
// by killing them, so an `exit` handler registered from a test file does not run
// — verified with a probe test, and by a full suite run that still leaked 750
// directories with this hook in place. The real cleanup is global-setup.ts's
// teardown, which runs in the main process where an exit IS orderly. This hook
// only earns its keep when `tmp()` is called with no run root set, where nothing
// else would ever remove the directory.
let armed = false;
function arm(): void {
if (armed) return;
armed = true;
process.once("exit", () => {
for (const dir of created) {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// ignore
}
}
created.length = 0;
});
}
/**
* Create a temp dir and register it for cleanup. Drop-in replacement for
* `mkdtempSync(join(tmpdir(), prefix))` the prefix survives as the directory's
* basename, so paths stay as greppable as they were.
*
* @param prefix e.g. `"cast-home-"` mkdtemp appends six random characters.
*/
export function tmp(prefix: string): string {
arm();
const dir = mkdtempSync(join(base(), prefix));
created.push(dir);
return dir;
}