/** * scratch-dirs.mjs — the one definition of "a throwaway copy of this checkout". * * Section 75 of `test-all.mjs` copies the whole checkout into * `mkdtempSync(join(ROOT, '.tmp-script-test-'))` so the script smoke tests can * run against a tree they are free to damage, and removes it in a `finally`. A * run killed between the two — a timeout, Ctrl-C, a crash — never reaches that * `finally` and leaves the copy behind. * * Nothing then notices. `.gitignore` carries a directory rule for the prefix, so * `git status` is empty; the copy excludes `.git`, so `isNestedCheckout()` in * `mjs-files.mjs` cannot recognise it either. Every walker reads the stale copy * as part of the repository, and the guards that grade file LAYOUT are the ones * that break: a full second copy of `tests/` is, to * `tests/core-test-layout.test.mjs`, several hundred suites sitting outside * `tests/`. Measured on a clean `main`: 264 failures locally against 0 in CI, * naming paths under `.tmp-script-test-OP9Bzd/.tmp-script-test-tBjFgy/…` * (#3940). The nesting in that path is the second half of the bug — the copy * loop did not exclude an existing scratch either, so each run wrapped the * previous leftover inside its own. * * That failure shape is worse than a plain false red. It is unreproducible for * anyone else, it points at correct files, it appears on a commit the developer * did not write, and the state causing it is invisible to every command they * would think to run. * * The prefix lives here rather than at its three use sites because it was * already written twice — the `mkdtempSync` call and `.gitignore` — and had * begun to drift: one of them knew the scratch directory existed and the other * did not. A consumer that must recognise a scratch directory imports * `isScratchDir`; it cannot be spelled slightly differently in a fourth place. * * `.gitignore` is the one copy that cannot import this, since git reads it. Its * rule is the prefix followed by a glob and a directory slash, and * `tests/scratch-dirs.test.mjs` pins the two against each other by asking git * itself whether it ignores a directory named from this constant — a textual * comparison would pass on a rule that no longer matches anything. */ import { readdirSync, rmSync, statSync, readFileSync, writeFileSync } from 'fs'; import { join } from 'path'; /** * The prefix `mkdtempSync` is called with, and therefore the only thing a * scratch directory is guaranteed to have in common with the next one — the * six random characters after it are the point of `mkdtemp`. */ export const SCRATCH_PREFIX = '.tmp-script-test-'; /** * Is this directory NAME a throwaway copy left by the script smoke tests? * * Takes a bare name, not a path, because both walkers that consult it are * already iterating `readdirSync` entries and a path-taking predicate would * invite a caller to pass a path and match a legitimate directory that merely * sits inside a scratch tree. * * @param {string} name - A single path segment. * @returns {boolean} */ export function isScratchDir(name) { return name.startsWith(SCRATCH_PREFIX); } /** * How long a scratch directory must have sat untouched before a sweep will * delete it. * * The sweep is housekeeping, not correctness. What keeps a leftover from * breaking a run is `isScratchDir` in the walkers — the guards never read one * whether it is swept or not. All the sweep does is reclaim the disk, so it can * afford to be timid, and being timid is the whole point here: a second * `test-all.mjs` running in the same checkout has a scratch directory of its * own, and deleting THAT crashes a run that was doing nothing wrong. * * An hour, against a full suite that takes ~4 minutes locally and well under 30 * on the slowest CI runner. The leftovers in #3940 were dated 7-aug and 15-aug, * so nothing is being kept that anyone wanted. */ export const MIN_SCRATCH_AGE_MS = 60 * 60 * 1000; /** * Name of the marker a run drops inside its own scratch directory, holding the * pid that owns it. * * Age alone is a proxy for "is anybody using this", and it is wrong in one * direction that matters: a run paused or blocked after its copy finished stops * touching the tree, so its mtime stales while the run is very much alive. This * turns the question into one the operating system can answer. */ export const SCRATCH_OWNER_FILE = '.owner-pid'; /** * The age past which a scratch directory is removed even if its marker claims a * living owner. * * `kill(pid, 0)` answers "is that pid running", not "is that pid the run that * wrote this marker". After a crash the pid is free to be reused, and a marker * pointing at a recycled pid reads as alive for as long as that unrelated * process exists. The ceiling bounds how long that claim can prevent cleanup. * * This is a cleanup tradeoff, not proof that the owner has stopped. A test run * paused or alive after its scratch directory has gone untouched for seven * days can lose that directory to another run's sweep and fail. We retain this * limit rather than add cross-platform process identity or locking machinery * to this helper. The walkers skip scratch directories regardless of cleanup, * so preventing leftover copies from causing layout failures does not depend * on this ceiling. */ export const MAX_SCRATCH_AGE_MS = 7 * 24 * 60 * 60 * 1000; /** * Record this process as the owner of `dir`. * * Throws if the marker cannot be written. Callers must claim the directory * before using it and arrange cleanup on failure: continuing without a marker * would allow another run to sweep an active directory after the minimum age. * * @param {string} dir - The scratch directory. * @param {number} [pid] */ export function markScratchOwner(dir, pid = process.pid) { writeFileSync(join(dir, SCRATCH_OWNER_FILE), `${pid}\n`); } /** * Is the process that owns this scratch directory still running? * * `kill(pid, 0)` sends no signal; it asks whether the pid can be signalled. * ESRCH means gone. EPERM means it exists and belongs to somebody else, which * for this question is a yes — and is the safer reading regardless, since the * caller is deciding whether to delete a directory. * * Unknown (no marker, unreadable, not a number) is deliberately NOT "alive": a * leftover from before this marker existed has none, and treating that as a * live run would prevent their automatic cleanup. Returning false here does * not prove the owner is dead: a missing or unreadable marker can also belong * to an active run, whose directory then becomes eligible after minAgeMs. * * @param {string} dir * @param {(pid: number) => void} [signal] - Seam for the liveness probe. * @returns {boolean} */ export function scratchOwnerAlive(dir, signal = (pid) => process.kill(pid, 0)) { let pid; try { pid = Number.parseInt(readFileSync(join(dir, SCRATCH_OWNER_FILE), 'utf-8').trim(), 10); } catch { return false; } if (!Number.isInteger(pid) || pid <= 0) return false; try { signal(pid); return true; } catch (err) { return err?.code === 'EPERM'; } } /** * Remove every scratch directory directly under `root` that has been untouched * for at least `minAgeMs`, and say which. * * Called at the start of a run rather than only at the end of one: the leftover * exists precisely because the previous run's cleanup did not get to execute, * so a second cleanup path scheduled the same way would not have run either. * The next run's startup is the first moment that is guaranteed to happen. * * Below the age ceiling, the sweep checks liveness before the minimum age: * a run marks its own scratch with its pid (`markScratchOwner`), and a directory whose * owner appears alive is protected only below `maxAgeMs`. That matters * because mtime stops advancing once the copy that filled the tree has * finished, so a run that then blocks — a debugger, a suspended process, a * laptop that slept — goes on aging while it is very much alive. * * Age is the fallback, for a directory with no usable marker: leftovers that * predate this mechanism, and any run whose marker could not be written. It is * also the ceiling — past `maxAgeMs` a living-owner claim is overruled, because * a reused pid can keep a dead run's tree protected. This can also remove a * genuinely active tree; see MAX_SCRATCH_AGE_MS for the accepted risk. * * Top level only. A scratch directory is created directly under ROOT, so that * is where a stale one is; recursing would mean walking into a tree this * function is about to delete, and a `.tmp-script-test-` name deeper in the * repository belongs to whoever put it there. * * Symlinks are never followed, and never removed: `entry.isDirectory()` is * false for one, so a link named like a scratch directory is skipped before any * of this. That is load-bearing — this function authorises a recursive delete, * and following a link would move that delete somewhere nobody asked for — so * `tests/scratch-dirs.test.mjs` pins it rather than leaving it to be inferred * from `withFileTypes`. * * Returns the names rather than printing them, so the caller decides whether a * sweep is worth a line of output. Silence would be wrong — a run that quietly * deleted a directory is its own small mystery — but that is the caller's call * to make, not this function's. * * A directory that cannot be removed is reported, not thrown: a sweep is * housekeeping in front of the real work, and failing the whole suite because a * stale copy is momentarily locked would replace a false red with another one. * The walkers skip it regardless, so the run stays correct either way. * * @param {string} root - Absolute path to sweep. * @param {object} [options] * @param {number} [options.minAgeMs] - Age floor; see MIN_SCRATCH_AGE_MS. * @param {number} [options.maxAgeMs] - Age past which a living-owner claim is * overruled; see MAX_SCRATCH_AGE_MS. * @param {() => number} [options.now] - Clock seam, so the age gate is testable * without sleeping for an hour. * @param {(dir: string) => void} [options.remove] - Removal seam, so a test can * drive the failure branch without arranging an undeletable directory. * @param {(dir: string) => boolean} [options.isAlive] - Liveness seam. * @returns {{removed: string[], kept: string[], failed: {name: string, error: string}[]}} */ export function sweepScratchDirs(root, options = {}) { const { minAgeMs = MIN_SCRATCH_AGE_MS, maxAgeMs = MAX_SCRATCH_AGE_MS, now = Date.now, remove = (dir) => rmSync(dir, { recursive: true, force: true }), isAlive = scratchOwnerAlive, } = options; let entries; try { entries = readdirSync(root, { withFileTypes: true }); } catch { // An unreadable root is the caller's problem, and it is about to be their // very loud problem regardless: everything after this reads the same tree. return { removed: [], kept: [], failed: [] }; } const removed = []; const kept = []; const failed = []; const at = now(); for (const entry of entries) { if (!entry.isDirectory() || !isScratchDir(entry.name)) continue; const dir = join(root, entry.name); let age; try { age = at - statSync(dir).mtimeMs; } catch { // Gone between readdir and stat, or unreadable. Either way this sweep has // nothing to do with it: not removed, and not claimed as kept. continue; } // Liveness is asked before age, because it is the real question — but it is // overruled past maxAgeMs, since a marker can outlive its run through pid // reuse and would otherwise protect the directory forever. if (age < maxAgeMs && isAlive(dir)) { kept.push(entry.name); continue; } if (age < minAgeMs) { // Young enough to belong to a run that is still going. Left alone, and // named, so a caller that wonders why the directory is still there can be // told rather than left guessing. kept.push(entry.name); continue; } try { remove(dir); removed.push(entry.name); } catch (err) { failed.push({ name: entry.name, error: err?.message ?? String(err) }); } } return { removed: removed.sort(), kept: kept.sort(), failed }; }