Skip to content

Commit 0fb38f1

Browse files
authored
test: prune abandoned test-run tmp directories at run setup (#1834)
* test: prune abandoned test-run tmp directories at run setup A run killed before its teardown (tool-timeout SIGKILL, OOM, cancelled job) left /tmp/agent-device-test-run-<pid>-* behind, and check:tmpdir-leaks — which runs after test:unit in check:unit — flagged every dead-pid directory it found. It could not tell this run's leak from a historical one, so one killed run made every later, otherwise-green gate on the host fail. Both TMPDIR redirection entry points (the Vitest global setup and the node --test wrapper) now prune dead-pid run directories before creating their own, printing one [tmpdir] line when they did; the post-run check keeps its semantics and can now only ever name the run that just finished. Live owners (a concurrent run in another worktree) are never touched. The root/prefix constants move into check-tmpdir-leaks-model.ts, next to the liveness classification, so the setup can import the prune without a cycle. * test(tmpdir): a run directory is live while any process still holds it as TMPDIR, not only while its owner runs Review (P1): owner-pid liveness alone would prune a directory out from under the orphaned children of a SIGKILLed run — the node --test chain, Vitest forks, or a daemon a test spawned all keep running with that TMPDIR. The liveness model now reads every process's TMPDIR (ps -E on macOS, /proc/<pid>/environ on Linux) and treats a run directory as live while its owner pid is alive OR any process's TMPDIR points into it; both the prune and the post-run leak check use it. Regression: a wrapped probe spawns a detached long-lived child, only the wrapper is SIGKILLed, the next prune preserves the directory; after every consumer exits, the next prune removes it. Planted red with owner-only liveness: the orphaned directory is pruned.
1 parent 423927f commit 0fb38f1

8 files changed

Lines changed: 478 additions & 38 deletions

docs/agents/testing.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ cleanup for a directory they created — that's the global teardown's job, and p
7878
already existed for other reasons should stay (it's the fallback global sweep that's new, not a
7979
replacement for tests being tidy).
8080

81+
A run killed before its teardown (a tool timeout's SIGKILL, OOM, a cancelled job) leaves its
82+
`/tmp/agent-device-test-run-<pid>-*` directory behind. The next run on the host prunes every such
83+
directory that is genuinely abandoned (`pruneAbandonedRunDirectories`, called by both the Vitest
84+
global setup and the `node --test` wrapper) and prints one `[tmpdir] pruned …` line, so
85+
`check:tmpdir-leaks` after `test:unit` can only ever name the run that just finished. Abandoned
86+
means nobody owns it **and nobody uses it**: the owner pid in the name is dead and no live process
87+
has a `TMPDIR` inside it (read from `ps -E` on macOS, `/proc/<pid>/environ` on Linux). That second
88+
half matters because a SIGKILL of the wrapper or Vitest main process leaves its `node --test` chain,
89+
forked workers, and any daemon a test spawned running with that `TMPDIR` — they keep the directory
90+
until the last of them exits. A concurrent run in another worktree is live by both tests. If the
91+
check fails, the leak is this run's: a teardown that did not execute, not history.
92+
8193
Keep tests behavioral. Do not assert shapes or cases TypeScript already proves.
8294

8395
A test added as a regression pin must be shown to fail without the change it pins — vacuity is the

scripts/check-tmpdir-leaks-model.test.ts

Lines changed: 98 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@ import fs from 'node:fs';
33
import os from 'node:os';
44
import path from 'node:path';
55
import { test } from 'node:test';
6-
import { findLeakedRunDirectories } from './check-tmpdir-leaks-model.ts';
6+
import {
7+
findLeakedRunDirectories,
8+
pruneAbandonedRunDirectories,
9+
runDirectoryNameOf,
10+
} from './check-tmpdir-leaks-model.ts';
11+
12+
const NONE: ReadonlySet<string> = new Set();
713

814
function withScratchRoot(fn: (root: string) => void): void {
915
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'check-tmpdir-leaks-model-test-'));
@@ -17,15 +23,18 @@ function withScratchRoot(fn: (root: string) => void): void {
1723
test('a directory owned by a still-running process is not reported as a leak', () => {
1824
withScratchRoot((root) => {
1925
fs.mkdirSync(path.join(root, 'agent-device-test-run-4242-abcdef'));
20-
const leaks = findLeakedRunDirectories(root, (pid) => pid === 4242);
26+
const leaks = findLeakedRunDirectories(root, {
27+
isAlive: (pid) => pid === 4242,
28+
consumers: NONE,
29+
});
2130
assert.deepEqual(leaks, []);
2231
});
2332
});
2433

2534
test('a directory whose owning process has exited is reported as a leak', () => {
2635
withScratchRoot((root) => {
2736
fs.mkdirSync(path.join(root, 'agent-device-test-run-4242-abcdef'));
28-
const leaks = findLeakedRunDirectories(root, () => false);
37+
const leaks = findLeakedRunDirectories(root, { isAlive: () => false, consumers: NONE });
2938
assert.deepEqual(leaks, ['agent-device-test-run-4242-abcdef']);
3039
});
3140
});
@@ -38,7 +47,10 @@ test('a concurrent run from another worktree does not fail the check for this on
3847
fs.mkdirSync(path.join(root, 'agent-device-test-run-1111-aaaaaa'));
3948
fs.mkdirSync(path.join(root, 'agent-device-test-run-2222-bbbbbb'));
4049
const alivePids = new Set([1111, 2222]);
41-
const leaks = findLeakedRunDirectories(root, (pid) => alivePids.has(pid));
50+
const leaks = findLeakedRunDirectories(root, {
51+
isAlive: (pid) => alivePids.has(pid),
52+
consumers: NONE,
53+
});
4254
assert.deepEqual(leaks, []);
4355
});
4456
});
@@ -47,15 +59,18 @@ test('a mix of active and abandoned directories reports only the abandoned one',
4759
withScratchRoot((root) => {
4860
fs.mkdirSync(path.join(root, 'agent-device-test-run-1111-aaaaaa')); // alive
4961
fs.mkdirSync(path.join(root, 'agent-device-test-run-3333-cccccc')); // exited
50-
const leaks = findLeakedRunDirectories(root, (pid) => pid === 1111);
62+
const leaks = findLeakedRunDirectories(root, {
63+
isAlive: (pid) => pid === 1111,
64+
consumers: NONE,
65+
});
5166
assert.deepEqual(leaks, ['agent-device-test-run-3333-cccccc']);
5267
});
5368
});
5469

5570
test('a directory with no parseable pid is conservatively reported as a leak', () => {
5671
withScratchRoot((root) => {
5772
fs.mkdirSync(path.join(root, 'agent-device-test-run-not-a-pid'));
58-
const leaks = findLeakedRunDirectories(root, () => true);
73+
const leaks = findLeakedRunDirectories(root, { isAlive: () => true, consumers: NONE });
5974
assert.deepEqual(leaks, ['agent-device-test-run-not-a-pid']);
6075
});
6176
});
@@ -64,7 +79,83 @@ test('non-matching directories and files are ignored', () => {
6479
withScratchRoot((root) => {
6580
fs.mkdirSync(path.join(root, 'unrelated-directory'));
6681
fs.writeFileSync(path.join(root, 'agent-device-test-run-4242-loose-file'), '');
67-
const leaks = findLeakedRunDirectories(root, () => false);
82+
const leaks = findLeakedRunDirectories(root, { isAlive: () => false, consumers: NONE });
6883
assert.deepEqual(leaks, []);
6984
});
7085
});
86+
87+
test('pruning removes only abandoned directories and returns their names', () => {
88+
withScratchRoot((root) => {
89+
fs.mkdirSync(path.join(root, 'agent-device-test-run-1111-aaaaaa')); // alive
90+
fs.mkdirSync(path.join(root, 'agent-device-test-run-3333-cccccc', 'nested'), {
91+
recursive: true,
92+
}); // exited, non-empty
93+
fs.mkdirSync(path.join(root, 'agent-device-test-run-not-a-pid')); // no owner
94+
fs.mkdirSync(path.join(root, 'unrelated-directory'));
95+
const pruned = pruneAbandonedRunDirectories(root, {
96+
isAlive: (pid) => pid === 1111,
97+
consumers: NONE,
98+
});
99+
assert.deepEqual(pruned.sort(), [
100+
'agent-device-test-run-3333-cccccc',
101+
'agent-device-test-run-not-a-pid',
102+
]);
103+
assert.deepEqual(fs.readdirSync(root).sort(), [
104+
'agent-device-test-run-1111-aaaaaa',
105+
'unrelated-directory',
106+
]);
107+
// Once pruned, the post-run check has nothing historical left to report.
108+
assert.deepEqual(
109+
findLeakedRunDirectories(root, { isAlive: (pid) => pid === 1111, consumers: NONE }),
110+
[],
111+
);
112+
});
113+
});
114+
115+
test('pruning nothing is a no-op that reports nothing', () => {
116+
withScratchRoot((root) => {
117+
fs.mkdirSync(path.join(root, 'agent-device-test-run-1111-aaaaaa'));
118+
assert.deepEqual(
119+
pruneAbandonedRunDirectories(root, { isAlive: () => true, consumers: NONE }),
120+
[],
121+
);
122+
assert.deepEqual(fs.readdirSync(root), ['agent-device-test-run-1111-aaaaaa']);
123+
});
124+
});
125+
126+
test('a directory whose owner is dead but which some live process still holds as TMPDIR is not a leak', () => {
127+
withScratchRoot((root) => {
128+
// The motivating case: the wrapper/vitest owner was SIGKILLed, its node --test chain or
129+
// forked workers (or a daemon a test spawned) are still running with TMPDIR inside the
130+
// directory. Nobody may prune it until the last of them exits.
131+
fs.mkdirSync(path.join(root, 'agent-device-test-run-4242-orphaned'));
132+
fs.mkdirSync(path.join(root, 'agent-device-test-run-5555-finished'));
133+
const consumers = new Set(['agent-device-test-run-4242-orphaned']);
134+
assert.deepEqual(findLeakedRunDirectories(root, { isAlive: () => false, consumers }), [
135+
'agent-device-test-run-5555-finished',
136+
]);
137+
assert.deepEqual(pruneAbandonedRunDirectories(root, { isAlive: () => false, consumers }), [
138+
'agent-device-test-run-5555-finished',
139+
]);
140+
assert.deepEqual(fs.readdirSync(root), ['agent-device-test-run-4242-orphaned']);
141+
// Once the consumers are gone it is an ordinary abandoned directory.
142+
assert.deepEqual(
143+
pruneAbandonedRunDirectories(root, { isAlive: () => false, consumers: NONE }),
144+
['agent-device-test-run-4242-orphaned'],
145+
);
146+
assert.deepEqual(fs.readdirSync(root), []);
147+
});
148+
});
149+
150+
test('a consumer is identified by the run directory its TMPDIR sits inside, at any depth', () => {
151+
assert.equal(
152+
runDirectoryNameOf('/tmp/agent-device-test-run-123-abc'),
153+
'agent-device-test-run-123-abc',
154+
);
155+
assert.equal(
156+
runDirectoryNameOf('/tmp/agent-device-test-run-123-abc/nested/deeper'),
157+
'agent-device-test-run-123-abc',
158+
);
159+
assert.equal(runDirectoryNameOf('/tmp/other-123-abc'), undefined);
160+
assert.equal(runDirectoryNameOf('/var/folders/x/T/agent-device-test-run-1-a'), undefined);
161+
});

scripts/check-tmpdir-leaks-model.ts

Lines changed: 130 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
import fs from 'node:fs';
2-
import { TEST_RUN_TMP_PREFIX } from './vitest-tmpdir-global-setup.ts';
2+
import path from 'node:path';
3+
import { runCmdSync } from '../src/utils/exec.ts';
4+
5+
// Every test run (Vitest via scripts/vitest-tmpdir-global-setup.ts, node --test
6+
// via scripts/node-test-tmpdir.ts) redirects TMPDIR into one disposable,
7+
// pid-tagged directory under this root and removes it at teardown.
8+
//
9+
// Rooted at /tmp rather than nested inside the current os.tmpdir(): macOS's
10+
// per-user TMPDIR (/var/folders/.../T/) is already close to the 104-byte
11+
// sun_path limit AF_UNIX sockets need, and tests that bind real sockets
12+
// (e.g. runner-usbmux.test.ts) started hitting EINVAL once nested one level
13+
// deeper. /tmp is short enough to leave headroom for those.
14+
//
15+
// Both redirection mechanisms and check-tmpdir-leaks.ts import these two from
16+
// here rather than recomputing them, so they can't drift onto different
17+
// directories (os.tmpdir() != /tmp on macOS, where TMPDIR is a deep per-user
18+
// path).
19+
export const TEST_RUN_TMP_ROOT = '/tmp';
20+
export const TEST_RUN_TMP_PREFIX = 'agent-device-test-run-';
321

422
const PID_SUFFIX = new RegExp(`^${TEST_RUN_TMP_PREFIX}(\\d+)-`);
523

@@ -14,22 +32,129 @@ function isProcessAlive(pid: number): boolean {
1432
}
1533

1634
/**
17-
* Directories owned by a still-running process are a concurrent vitest run
18-
* (e.g. another worktree), not a leak — only report the ones whose owning
19-
* process has already exited without cleaning up after itself.
35+
* The run directories some live process is still using: every process whose TMPDIR points
36+
* into one. That is the ownership signal every consumer actually carries — the run's own
37+
* setup exports it and every child (Vitest forks, the node --test chain, daemons a test
38+
* spawned) inherits it — so a run whose owner was SIGKILLed while its children kept running
39+
* is still "in use" until the last of them exits. Read from `ps -E` on macOS (environment
40+
* is shown for the caller's own processes) and /proc/<pid>/environ on Linux; on either, a
41+
* process this user cannot inspect contributes nothing, and its directory is then judged by
42+
* its owner pid alone.
43+
*/
44+
export function liveRunDirectoryConsumers(): ReadonlySet<string> {
45+
const consumers = new Set<string>();
46+
for (const value of readAllProcessTmpdirs()) {
47+
const name = runDirectoryNameOf(value);
48+
if (name !== undefined) consumers.add(name);
49+
}
50+
return consumers;
51+
}
52+
53+
/** `/tmp/agent-device-test-run-123-abc/nested` → `agent-device-test-run-123-abc`; else undefined. */
54+
export function runDirectoryNameOf(tmpdir: string): string | undefined {
55+
const rootPrefix = `${TEST_RUN_TMP_ROOT}/`;
56+
if (!tmpdir.startsWith(rootPrefix)) return undefined;
57+
const name = tmpdir.slice(rootPrefix.length).split('/')[0] ?? '';
58+
return name.startsWith(TEST_RUN_TMP_PREFIX) ? name : undefined;
59+
}
60+
61+
function readAllProcessTmpdirs(): string[] {
62+
return process.platform === 'linux' ? readProcTmpdirs() : readPsTmpdirs();
63+
}
64+
65+
function readProcTmpdirs(): string[] {
66+
return fs
67+
.readdirSync('/proc')
68+
.filter((entry) => /^\d+$/.test(entry))
69+
.flatMap((pid) => tmpdirsOfEnviron(readEnvironOrEmpty(pid)));
70+
}
71+
72+
/** Another user's process, or one that exited mid-scan, contributes nothing. */
73+
function readEnvironOrEmpty(pid: string): string {
74+
try {
75+
return fs.readFileSync(`/proc/${pid}/environ`, 'latin1');
76+
} catch {
77+
return '';
78+
}
79+
}
80+
81+
function tmpdirsOfEnviron(environ: string): string[] {
82+
return environ
83+
.split('\0')
84+
.filter((pair) => pair.startsWith('TMPDIR='))
85+
.map((pair) => pair.slice('TMPDIR='.length));
86+
}
87+
88+
// macOS (and other BSDs): -E appends the environment to each command line. Every process's
89+
// environment is a few MB on a busy host — well past spawnSync's 1 MB default.
90+
function readPsTmpdirs(): string[] {
91+
const listing = runCmdSync('ps', ['-axEww', '-o', 'command='], {
92+
allowFailure: true,
93+
maxBuffer: 64 * 1024 * 1024,
94+
});
95+
if (listing.exitCode !== 0) return [];
96+
return [...listing.stdout.matchAll(/(?:^|\s)TMPDIR=(\S+)/g)].map((match) => match[1] as string);
97+
}
98+
99+
export type RunDirectoryLiveness = Readonly<{
100+
isAlive?: (pid: number) => boolean;
101+
consumers?: ReadonlySet<string>;
102+
}>;
103+
104+
/**
105+
* A run directory is live while its owning process (the pid in its name) runs, OR while any
106+
* process still holds it as TMPDIR — a concurrent run in another worktree, or the orphaned
107+
* children of a killed run. Only the rest are leaks: nobody owns them and nobody uses them.
20108
*/
21109
export function findLeakedRunDirectories(
22110
root: string,
23-
isAlive: (pid: number) => boolean = isProcessAlive,
111+
liveness: RunDirectoryLiveness = {},
24112
): string[] {
113+
const isAlive = liveness.isAlive ?? isProcessAlive;
114+
const consumers = liveness.consumers ?? liveRunDirectoryConsumers();
25115
return fs
26116
.readdirSync(root, { withFileTypes: true })
27117
.filter((entry) => entry.isDirectory() && entry.name.startsWith(TEST_RUN_TMP_PREFIX))
28118
.filter((entry) => {
119+
if (consumers.has(entry.name)) return false;
29120
const match = PID_SUFFIX.exec(entry.name);
30121
// No parseable pid means it didn't come from setup() as written — treat it as a leak.
31122
if (!match) return true;
32123
return !isAlive(Number(match[1]));
33124
})
34125
.map((entry) => entry.name);
35126
}
127+
128+
/**
129+
* Removes the run directories an earlier, already-exited run left behind and
130+
* returns their names. A run's setup calls this before creating its own
131+
* directory, so the post-run leak check (check-tmpdir-leaks.ts) can only ever
132+
* report the run that just finished: a directory abandoned by an earlier run
133+
* that was killed before its teardown (SIGKILL on a tool timeout, OOM, a
134+
* cancelled CI job) is by construction the same thing that teardown would have
135+
* removed, and leaving it in place made every later, otherwise-green gate on
136+
* the host fail for a run it never ran. Live owners are never touched, so a
137+
* concurrent run in another worktree keeps its directory.
138+
*/
139+
export function pruneAbandonedRunDirectories(
140+
root: string,
141+
liveness: RunDirectoryLiveness = {},
142+
): string[] {
143+
const abandoned = findLeakedRunDirectories(root, liveness);
144+
for (const name of abandoned) {
145+
fs.rmSync(path.join(root, name), { recursive: true, force: true });
146+
}
147+
return abandoned;
148+
}
149+
150+
/**
151+
* One stderr line, only when something was pruned: an earlier run on this
152+
* host died before its teardown, which the operator should know (a tool
153+
* timeout killed it, say) without it being a failure of this run.
154+
*/
155+
export function reportPrunedRunDirectories(pruned: readonly string[]): void {
156+
if (pruned.length === 0) return;
157+
process.stderr.write(
158+
`[tmpdir] pruned ${pruned.length} abandoned ${TEST_RUN_TMP_PREFIX}* director${pruned.length === 1 ? 'y' : 'ies'} left by an earlier killed run\n`,
159+
);
160+
}

scripts/check-tmpdir-leaks.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
// Fails if any agent-device-test-run-* directory under TEST_RUN_TMP_ROOT is
22
// abandoned — its owning process has exited without running its cleanup
3-
// (crash, OOM, timeout kill). Directories owned by a still-running process
4-
// are left alone: a concurrent `vitest run` (or node --test lane, wrapped by
5-
// scripts/node-test-tmpdir.ts) in another worktree keeps its own directory
6-
// present until its own teardown, which is not a leak. See
7-
// check-tmpdir-leaks-model.ts for the liveness check.
3+
// (crash, OOM, timeout kill) AND no live process still holds it as TMPDIR.
4+
// Directories owned by a still-running process are left alone: a concurrent
5+
// `vitest run` (or node --test lane, wrapped by scripts/node-test-tmpdir.ts) in
6+
// another worktree keeps its own directory present until its own teardown,
7+
// which is not a leak; so are the orphaned children of a killed run, until the
8+
// last of them exits. See check-tmpdir-leaks-model.ts for the liveness model.
89
//
910
// Covers both redirection mechanisms sharing this root/prefix: Vitest's
1011
// globalSetup/globalTeardown (scripts/vitest-tmpdir-global-setup.ts) and the
1112
// node --test wrapper (scripts/node-test-tmpdir.ts, #1595) that every
12-
// `node --test` package.json script now runs through.
13+
// `node --test` package.json script now runs through. Both prune what an
14+
// earlier killed run left behind before creating their own directory, so a
15+
// failure here names the run that just finished — never a historical one.
1316

1417
import path from 'node:path';
15-
import { findLeakedRunDirectories } from './check-tmpdir-leaks-model.ts';
16-
import { TEST_RUN_TMP_ROOT } from './vitest-tmpdir-global-setup.ts';
18+
import { TEST_RUN_TMP_ROOT, findLeakedRunDirectories } from './check-tmpdir-leaks-model.ts';
1719

1820
const leaks = findLeakedRunDirectories(TEST_RUN_TMP_ROOT);
1921

0 commit comments

Comments
 (0)