lib/scratch-dirs.mjs
Rama main · réplica de lectura del repositorio original.
/**
* 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 };
}