Skip to content

Commit 24191db

Browse files
committed
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.
1 parent 9a0d6de commit 24191db

8 files changed

Lines changed: 220 additions & 23 deletions

docs/agents/testing.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ 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 whose owning pid is dead (`pruneAbandonedRunDirectories`, called by both the Vitest global
84+
setup and the `node --test` wrapper) and prints one `[tmpdir] pruned …` line, so `check:tmpdir-leaks`
85+
after `test:unit` can only ever name the run that just finished. Directories owned by a live pid —
86+
a concurrent run in another worktree — are never touched. If the check fails, the leak is this
87+
run's: a teardown that did not execute, not history.
88+
8189
Keep tests behavioral. Do not assert shapes or cases TypeScript already proves.
8290

8391
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: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ 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+
} from './check-tmpdir-leaks-model.ts';
710

811
function withScratchRoot(fn: (root: string) => void): void {
912
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'check-tmpdir-leaks-model-test-'));
@@ -68,3 +71,39 @@ test('non-matching directories and files are ignored', () => {
6871
assert.deepEqual(leaks, []);
6972
});
7073
});
74+
75+
test('pruning removes only abandoned directories and returns their names', () => {
76+
withScratchRoot((root) => {
77+
fs.mkdirSync(path.join(root, 'agent-device-test-run-1111-aaaaaa')); // alive
78+
fs.mkdirSync(path.join(root, 'agent-device-test-run-3333-cccccc', 'nested'), {
79+
recursive: true,
80+
}); // exited, non-empty
81+
fs.mkdirSync(path.join(root, 'agent-device-test-run-not-a-pid')); // no owner
82+
fs.mkdirSync(path.join(root, 'unrelated-directory'));
83+
const pruned = pruneAbandonedRunDirectories(root, (pid) => pid === 1111);
84+
assert.deepEqual(pruned.sort(), [
85+
'agent-device-test-run-3333-cccccc',
86+
'agent-device-test-run-not-a-pid',
87+
]);
88+
assert.deepEqual(fs.readdirSync(root).sort(), [
89+
'agent-device-test-run-1111-aaaaaa',
90+
'unrelated-directory',
91+
]);
92+
// Once pruned, the post-run check has nothing historical left to report.
93+
assert.deepEqual(
94+
findLeakedRunDirectories(root, (pid) => pid === 1111),
95+
[],
96+
);
97+
});
98+
});
99+
100+
test('pruning nothing is a no-op that reports nothing', () => {
101+
withScratchRoot((root) => {
102+
fs.mkdirSync(path.join(root, 'agent-device-test-run-1111-aaaaaa'));
103+
assert.deepEqual(
104+
pruneAbandonedRunDirectories(root, () => true),
105+
[],
106+
);
107+
assert.deepEqual(fs.readdirSync(root), ['agent-device-test-run-1111-aaaaaa']);
108+
});
109+
});

scripts/check-tmpdir-leaks-model.ts

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

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

@@ -14,7 +31,7 @@ function isProcessAlive(pid: number): boolean {
1431
}
1532

1633
/**
17-
* Directories owned by a still-running process are a concurrent vitest run
34+
* Directories owned by a still-running process are a concurrent test run
1835
* (e.g. another worktree), not a leak — only report the ones whose owning
1936
* process has already exited without cleaning up after itself.
2037
*/
@@ -33,3 +50,37 @@ export function findLeakedRunDirectories(
3350
})
3451
.map((entry) => entry.name);
3552
}
53+
54+
/**
55+
* Removes the run directories an earlier, already-exited run left behind and
56+
* returns their names. A run's setup calls this before creating its own
57+
* directory, so the post-run leak check (check-tmpdir-leaks.ts) can only ever
58+
* report the run that just finished: a directory abandoned by an earlier run
59+
* that was killed before its teardown (SIGKILL on a tool timeout, OOM, a
60+
* cancelled CI job) is by construction the same thing that teardown would have
61+
* removed, and leaving it in place made every later, otherwise-green gate on
62+
* the host fail for a run it never ran. Live owners are never touched, so a
63+
* concurrent run in another worktree keeps its directory.
64+
*/
65+
export function pruneAbandonedRunDirectories(
66+
root: string,
67+
isAlive: (pid: number) => boolean = isProcessAlive,
68+
): string[] {
69+
const abandoned = findLeakedRunDirectories(root, isAlive);
70+
for (const name of abandoned) {
71+
fs.rmSync(path.join(root, name), { recursive: true, force: true });
72+
}
73+
return abandoned;
74+
}
75+
76+
/**
77+
* One stderr line, only when something was pruned: an earlier run on this
78+
* host died before its teardown, which the operator should know (a tool
79+
* timeout killed it, say) without it being a failure of this run.
80+
*/
81+
export function reportPrunedRunDirectories(pruned: readonly string[]): void {
82+
if (pruned.length === 0) return;
83+
process.stderr.write(
84+
`[tmpdir] pruned ${pruned.length} abandoned ${TEST_RUN_TMP_PREFIX}* director${pruned.length === 1 ? 'y' : 'ies'} left by an earlier killed run\n`,
85+
);
86+
}

scripts/check-tmpdir-leaks.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99
// Covers both redirection mechanisms sharing this root/prefix: Vitest's
1010
// globalSetup/globalTeardown (scripts/vitest-tmpdir-global-setup.ts) and the
1111
// node --test wrapper (scripts/node-test-tmpdir.ts, #1595) that every
12-
// `node --test` package.json script now runs through.
12+
// `node --test` package.json script now runs through. Both prune what an
13+
// earlier killed run left behind before creating their own directory, so a
14+
// failure here names the run that just finished — never a historical one.
1315

1416
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';
17+
import { TEST_RUN_TMP_ROOT, findLeakedRunDirectories } from './check-tmpdir-leaks-model.ts';
1718

1819
const leaks = findLeakedRunDirectories(TEST_RUN_TMP_ROOT);
1920

scripts/node-test-tmpdir.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import path from 'node:path';
66
import { test } from 'node:test';
77
import { fileURLToPath } from 'node:url';
88
import { runCmd } from '../src/utils/exec.ts';
9-
import { TEST_RUN_TMP_PREFIX, TEST_RUN_TMP_ROOT } from './vitest-tmpdir-global-setup.ts';
9+
import { TEST_RUN_TMP_PREFIX, TEST_RUN_TMP_ROOT } from './check-tmpdir-leaks-model.ts';
1010

1111
const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
1212
const WRAPPER = path.join(REPOSITORY_ROOT, 'scripts', 'node-test-tmpdir.ts');
@@ -250,3 +250,49 @@ test('every node --test package.json script routes through scripts/node-test-tmp
250250
`NODE_TEST_WRAPPER_BYPASS_ALLOWLIST above with a reason if one must legitimately bypass it.`,
251251
);
252252
});
253+
254+
// INT32_MAX exceeds every platform's pid range (Linux pid_max caps at 2^22,
255+
// macOS at 99999), so kill(pid, 0) is ESRCH by construction — an owner that
256+
// is dead and can never be reused mid-test, unlike a freshly exited child's pid.
257+
const NEVER_A_PID = 2_147_483_647;
258+
259+
test('the wrapper prunes a run directory abandoned by an earlier killed run and keeps a live one', async () => {
260+
const stamp = crypto.randomUUID();
261+
const abandoned = path.join(
262+
TEST_RUN_TMP_ROOT,
263+
`${TEST_RUN_TMP_PREFIX}${NEVER_A_PID}-planted-${stamp}`,
264+
);
265+
const live = path.join(
266+
TEST_RUN_TMP_ROOT,
267+
`${TEST_RUN_TMP_PREFIX}${process.pid}-planted-${stamp}`,
268+
);
269+
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'node-test-tmpdir-prune-'));
270+
const probePath = writeProbe(path.join(evidenceRoot, 'child-tmpdir.txt'));
271+
fs.mkdirSync(path.join(abandoned, 'nested'), { recursive: true });
272+
fs.writeFileSync(path.join(abandoned, 'nested', 'leftover.txt'), 'from a killed run');
273+
fs.mkdirSync(live);
274+
275+
try {
276+
const result = await runCmd(
277+
process.execPath,
278+
['--experimental-strip-types', WRAPPER, '--experimental-strip-types', '--test', probePath],
279+
{ cwd: REPOSITORY_ROOT, timeoutMs: 30_000 },
280+
);
281+
assert.equal(result.exitCode, 0, `probe run failed:\n${result.stdout}\n${result.stderr}`);
282+
assert.equal(
283+
fs.existsSync(abandoned),
284+
false,
285+
'the wrapper must prune the abandoned run directory',
286+
);
287+
assert.equal(
288+
fs.existsSync(live),
289+
true,
290+
'the wrapper must never touch a live owner’s run directory',
291+
);
292+
} finally {
293+
fs.rmSync(abandoned, { recursive: true, force: true });
294+
fs.rmSync(live, { recursive: true, force: true });
295+
fs.rmSync(probePath, { force: true });
296+
fs.rmSync(evidenceRoot, { recursive: true, force: true });
297+
}
298+
});

scripts/node-test-tmpdir.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,20 @@
2020
// process forwards to a still-running child (Ctrl-C locally, or a CI job
2121
// cancellation): the signal handlers below call `process.exit()`, which
2222
// triggers 'exit' synchronously before the process actually terminates.
23-
// Only a SIGKILL against this wrapper itself bypasses all of that — the same
24-
// residual case check-tmpdir-leaks-model.ts already tolerates via its
25-
// pid-liveness check, since it shares this directory's root and prefix.
23+
// Only a SIGKILL against this wrapper itself bypasses all of that; the next
24+
// run on the host prunes what such a kill left behind (see
25+
// pruneAbandonedRunDirectories), since both lanes share this directory's root
26+
// and prefix.
2627
import fs from 'node:fs';
2728
import os from 'node:os';
2829
import path from 'node:path';
2930
import { runCmdBackground } from '../src/utils/exec.ts';
30-
import { TEST_RUN_TMP_PREFIX, TEST_RUN_TMP_ROOT } from './vitest-tmpdir-global-setup.ts';
31+
import {
32+
TEST_RUN_TMP_PREFIX,
33+
TEST_RUN_TMP_ROOT,
34+
pruneAbandonedRunDirectories,
35+
reportPrunedRunDirectories,
36+
} from './check-tmpdir-leaks-model.ts';
3137

3238
const forwardedArgs = process.argv.slice(2);
3339
if (forwardedArgs.length === 0) {
@@ -36,6 +42,7 @@ if (forwardedArgs.length === 0) {
3642
);
3743
}
3844

45+
reportPrunedRunDirectories(pruneAbandonedRunDirectories(TEST_RUN_TMP_ROOT));
3946
const testRunTmpDir = fs.mkdtempSync(
4047
path.join(TEST_RUN_TMP_ROOT, `${TEST_RUN_TMP_PREFIX}${process.pid}-`),
4148
);

scripts/vitest-tmpdir-global-setup.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import assert from 'node:assert/strict';
2+
import crypto from 'node:crypto';
23
import fs from 'node:fs';
34
import os from 'node:os';
45
import path from 'node:path';
56
import { test } from 'node:test';
67
import { fileURLToPath } from 'node:url';
78
import { runCmd } from '../src/utils/exec.ts';
8-
import { TEST_RUN_TMP_PREFIX, TEST_RUN_TMP_ROOT } from './vitest-tmpdir-global-setup.ts';
9+
import { TEST_RUN_TMP_PREFIX, TEST_RUN_TMP_ROOT } from './check-tmpdir-leaks-model.ts';
910

1011
const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
1112

@@ -66,3 +67,49 @@ test('worker inherits the run-owned temp directory', () => {
6667
fs.rmSync(evidenceRoot, { recursive: true, force: true });
6768
}
6869
});
70+
71+
// INT32_MAX exceeds every platform's pid range (Linux pid_max caps at 2^22,
72+
// macOS at 99999), so kill(pid, 0) is ESRCH by construction — an owner that
73+
// is dead and can never be reused mid-test, unlike a freshly exited child's pid.
74+
const NEVER_A_PID = 2_147_483_647;
75+
76+
test('global setup prunes a run directory abandoned by an earlier killed run and keeps a live one', async () => {
77+
const stamp = crypto.randomUUID();
78+
const abandoned = path.join(
79+
TEST_RUN_TMP_ROOT,
80+
`${TEST_RUN_TMP_PREFIX}${NEVER_A_PID}-planted-${stamp}`,
81+
);
82+
// Owned by this test process, which is alive for the whole nested run: the
83+
// same shape as a concurrent run in another worktree, and must survive.
84+
const live = path.join(
85+
TEST_RUN_TMP_ROOT,
86+
`${TEST_RUN_TMP_PREFIX}${process.pid}-planted-${stamp}`,
87+
);
88+
const probeName = `vitest-tmpdir-prune-probe-${process.pid}-${stamp}.test.ts`;
89+
const probePath = path.join(REPOSITORY_ROOT, 'src', '__tests__', probeName);
90+
fs.mkdirSync(path.join(abandoned, 'nested'), { recursive: true });
91+
fs.writeFileSync(path.join(abandoned, 'nested', 'leftover.txt'), 'from a killed run');
92+
fs.mkdirSync(live);
93+
fs.writeFileSync(
94+
probePath,
95+
`import { test } from 'vitest';
96+
97+
test('noop probe: the run itself is the subject', () => {});
98+
`,
99+
);
100+
101+
try {
102+
const result = await runCmd(
103+
path.join(REPOSITORY_ROOT, 'node_modules', '.bin', 'vitest'),
104+
['run', '--project', 'unit-core', probePath],
105+
{ cwd: REPOSITORY_ROOT, timeoutMs: 30_000 },
106+
);
107+
assert.equal(result.exitCode, 0, `probe run failed:\n${result.stdout}\n${result.stderr}`);
108+
assert.equal(fs.existsSync(abandoned), false, 'setup must prune the abandoned run directory');
109+
assert.equal(fs.existsSync(live), true, 'setup must never touch a live owner’s run directory');
110+
} finally {
111+
fs.rmSync(abandoned, { recursive: true, force: true });
112+
fs.rmSync(live, { recursive: true, force: true });
113+
fs.rmSync(probePath, { force: true });
114+
}
115+
});

scripts/vitest-tmpdir-global-setup.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import fs from 'node:fs';
22
import os from 'node:os';
33
import path from 'node:path';
4+
import {
5+
TEST_RUN_TMP_PREFIX,
6+
TEST_RUN_TMP_ROOT,
7+
pruneAbandonedRunDirectories,
8+
reportPrunedRunDirectories,
9+
} from './check-tmpdir-leaks-model.ts';
410

511
// os.tmpdir() reads TMPDIR on every call, so redirecting it here covers every
612
// mkdtemp call site — test and production — without touching any of them.
@@ -12,17 +18,8 @@ import path from 'node:path';
1218
// first; it proved unreliable (some workers were torn down before running
1319
// it), which is why this is a single run-level hook instead.
1420
//
15-
// Rooted at /tmp rather than nested inside the current os.tmpdir(): macOS's
16-
// per-user TMPDIR (/var/folders/.../T/) is already close to the 104-byte
17-
// sun_path limit AF_UNIX sockets need, and tests that bind real sockets
18-
// (e.g. runner-usbmux.test.ts) started hitting EINVAL once nested one level
19-
// deeper. /tmp is short enough to leave headroom for those.
20-
//
21-
// check-tmpdir-leaks.ts imports these two rather than recomputing them, so
22-
// the two can't drift onto different directories (os.tmpdir() != /tmp on
23-
// macOS, where TMPDIR is a deep per-user path).
24-
export const TEST_RUN_TMP_ROOT = '/tmp';
25-
export const TEST_RUN_TMP_PREFIX = 'agent-device-test-run-';
21+
// The root/prefix live in check-tmpdir-leaks-model.ts, next to the liveness
22+
// classification this setup and the post-run leak check both rely on.
2623

2724
let testRunTmpDir: string;
2825
let previousTmpDir: string | undefined;
@@ -45,6 +42,7 @@ export function setup(): void {
4542
'agent-device-swift-cache',
4643
);
4744
}
45+
reportPrunedRunDirectories(pruneAbandonedRunDirectories(TEST_RUN_TMP_ROOT));
4846
// The pid is embedded so check-tmpdir-leaks.ts can tell a directory that's
4947
// still in active use (its vitest process is alive — a concurrent run in
5048
// another worktree, say) apart from one actually abandoned by a killed

0 commit comments

Comments
 (0)