Files
callstack__agent-device/scripts/check-tmpdir-leaks-model.test.ts
Michał Pierzchała 0fb38f1da2 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.
2026-08-18 17:48:12 +02:00

162 lines
6.3 KiB
TypeScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import {
findLeakedRunDirectories,
pruneAbandonedRunDirectories,
runDirectoryNameOf,
} from './check-tmpdir-leaks-model.ts';
const NONE: ReadonlySet<string> = new Set();
function withScratchRoot(fn: (root: string) => void): void {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'check-tmpdir-leaks-model-test-'));
try {
fn(root);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
}
test('a directory owned by a still-running process is not reported as a leak', () => {
withScratchRoot((root) => {
fs.mkdirSync(path.join(root, 'agent-device-test-run-4242-abcdef'));
const leaks = findLeakedRunDirectories(root, {
isAlive: (pid) => pid === 4242,
consumers: NONE,
});
assert.deepEqual(leaks, []);
});
});
test('a directory whose owning process has exited is reported as a leak', () => {
withScratchRoot((root) => {
fs.mkdirSync(path.join(root, 'agent-device-test-run-4242-abcdef'));
const leaks = findLeakedRunDirectories(root, { isAlive: () => false, consumers: NONE });
assert.deepEqual(leaks, ['agent-device-test-run-4242-abcdef']);
});
});
test('a concurrent run from another worktree does not fail the check for this one', () => {
withScratchRoot((root) => {
// Simulates two worktrees running vitest at once: pid 1111 (this
// invocation, still alive) and pid 2222 (a different worktree's
// in-progress run, also alive).
fs.mkdirSync(path.join(root, 'agent-device-test-run-1111-aaaaaa'));
fs.mkdirSync(path.join(root, 'agent-device-test-run-2222-bbbbbb'));
const alivePids = new Set([1111, 2222]);
const leaks = findLeakedRunDirectories(root, {
isAlive: (pid) => alivePids.has(pid),
consumers: NONE,
});
assert.deepEqual(leaks, []);
});
});
test('a mix of active and abandoned directories reports only the abandoned one', () => {
withScratchRoot((root) => {
fs.mkdirSync(path.join(root, 'agent-device-test-run-1111-aaaaaa')); // alive
fs.mkdirSync(path.join(root, 'agent-device-test-run-3333-cccccc')); // exited
const leaks = findLeakedRunDirectories(root, {
isAlive: (pid) => pid === 1111,
consumers: NONE,
});
assert.deepEqual(leaks, ['agent-device-test-run-3333-cccccc']);
});
});
test('a directory with no parseable pid is conservatively reported as a leak', () => {
withScratchRoot((root) => {
fs.mkdirSync(path.join(root, 'agent-device-test-run-not-a-pid'));
const leaks = findLeakedRunDirectories(root, { isAlive: () => true, consumers: NONE });
assert.deepEqual(leaks, ['agent-device-test-run-not-a-pid']);
});
});
test('non-matching directories and files are ignored', () => {
withScratchRoot((root) => {
fs.mkdirSync(path.join(root, 'unrelated-directory'));
fs.writeFileSync(path.join(root, 'agent-device-test-run-4242-loose-file'), '');
const leaks = findLeakedRunDirectories(root, { isAlive: () => false, consumers: NONE });
assert.deepEqual(leaks, []);
});
});
test('pruning removes only abandoned directories and returns their names', () => {
withScratchRoot((root) => {
fs.mkdirSync(path.join(root, 'agent-device-test-run-1111-aaaaaa')); // alive
fs.mkdirSync(path.join(root, 'agent-device-test-run-3333-cccccc', 'nested'), {
recursive: true,
}); // exited, non-empty
fs.mkdirSync(path.join(root, 'agent-device-test-run-not-a-pid')); // no owner
fs.mkdirSync(path.join(root, 'unrelated-directory'));
const pruned = pruneAbandonedRunDirectories(root, {
isAlive: (pid) => pid === 1111,
consumers: NONE,
});
assert.deepEqual(pruned.sort(), [
'agent-device-test-run-3333-cccccc',
'agent-device-test-run-not-a-pid',
]);
assert.deepEqual(fs.readdirSync(root).sort(), [
'agent-device-test-run-1111-aaaaaa',
'unrelated-directory',
]);
// Once pruned, the post-run check has nothing historical left to report.
assert.deepEqual(
findLeakedRunDirectories(root, { isAlive: (pid) => pid === 1111, consumers: NONE }),
[],
);
});
});
test('pruning nothing is a no-op that reports nothing', () => {
withScratchRoot((root) => {
fs.mkdirSync(path.join(root, 'agent-device-test-run-1111-aaaaaa'));
assert.deepEqual(
pruneAbandonedRunDirectories(root, { isAlive: () => true, consumers: NONE }),
[],
);
assert.deepEqual(fs.readdirSync(root), ['agent-device-test-run-1111-aaaaaa']);
});
});
test('a directory whose owner is dead but which some live process still holds as TMPDIR is not a leak', () => {
withScratchRoot((root) => {
// The motivating case: the wrapper/vitest owner was SIGKILLed, its node --test chain or
// forked workers (or a daemon a test spawned) are still running with TMPDIR inside the
// directory. Nobody may prune it until the last of them exits.
fs.mkdirSync(path.join(root, 'agent-device-test-run-4242-orphaned'));
fs.mkdirSync(path.join(root, 'agent-device-test-run-5555-finished'));
const consumers = new Set(['agent-device-test-run-4242-orphaned']);
assert.deepEqual(findLeakedRunDirectories(root, { isAlive: () => false, consumers }), [
'agent-device-test-run-5555-finished',
]);
assert.deepEqual(pruneAbandonedRunDirectories(root, { isAlive: () => false, consumers }), [
'agent-device-test-run-5555-finished',
]);
assert.deepEqual(fs.readdirSync(root), ['agent-device-test-run-4242-orphaned']);
// Once the consumers are gone it is an ordinary abandoned directory.
assert.deepEqual(
pruneAbandonedRunDirectories(root, { isAlive: () => false, consumers: NONE }),
['agent-device-test-run-4242-orphaned'],
);
assert.deepEqual(fs.readdirSync(root), []);
});
});
test('a consumer is identified by the run directory its TMPDIR sits inside, at any depth', () => {
assert.equal(
runDirectoryNameOf('/tmp/agent-device-test-run-123-abc'),
'agent-device-test-run-123-abc',
);
assert.equal(
runDirectoryNameOf('/tmp/agent-device-test-run-123-abc/nested/deeper'),
'agent-device-test-run-123-abc',
);
assert.equal(runDirectoryNameOf('/tmp/other-123-abc'), undefined);
assert.equal(runDirectoryNameOf('/var/folders/x/T/agent-device-test-run-1-a'), undefined);
});