mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
0fb38f1da2
* 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.
116 lines
4.6 KiB
TypeScript
116 lines
4.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
||
import crypto from 'node:crypto';
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { test } from 'node:test';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { runCmd } from '../src/utils/exec.ts';
|
||
import { TEST_RUN_TMP_PREFIX, TEST_RUN_TMP_ROOT } from './check-tmpdir-leaks-model.ts';
|
||
|
||
const REPOSITORY_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||
|
||
test('the configured Vitest lifecycle redirects worker TMPDIR and removes it after the run', async () => {
|
||
const probeName = `vitest-tmpdir-probe-${process.pid}-${Date.now()}.test.ts`;
|
||
const probePath = path.join(REPOSITORY_ROOT, 'src', '__tests__', probeName);
|
||
const evidenceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vitest-tmpdir-lifecycle-test-'));
|
||
const evidencePath = path.join(evidenceRoot, 'worker-tmpdir.txt');
|
||
const expectedSwiftCacheDir = path.join(os.tmpdir(), 'agent-device-swift-cache');
|
||
let workerTmpDir: string | undefined;
|
||
|
||
fs.writeFileSync(
|
||
probePath,
|
||
`import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { expect, test } from 'vitest';
|
||
|
||
test('worker inherits the run-owned temp directory', () => {
|
||
const tmpdir = os.tmpdir();
|
||
expect(path.dirname(tmpdir)).toBe(${JSON.stringify(TEST_RUN_TMP_ROOT)});
|
||
expect(path.basename(tmpdir)).toMatch(new RegExp(${JSON.stringify(
|
||
`^${TEST_RUN_TMP_PREFIX}\\d+-`,
|
||
)}));
|
||
expect(process.env.AGENT_DEVICE_SWIFT_CACHE_DIR).toBe(${JSON.stringify(expectedSwiftCacheDir)});
|
||
expect(process.env.AGENT_DEVICE_SWIFT_CACHE_DIR?.startsWith(tmpdir)).toBe(false);
|
||
fs.writeFileSync(process.env.AGENT_DEVICE_TEST_TMPDIR_EVIDENCE_PATH!, tmpdir);
|
||
});
|
||
`,
|
||
);
|
||
|
||
try {
|
||
await runCmd(
|
||
path.join(REPOSITORY_ROOT, 'node_modules', '.bin', 'vitest'),
|
||
['run', '--project', 'unit-core', probePath],
|
||
{
|
||
cwd: REPOSITORY_ROOT,
|
||
env: {
|
||
...process.env,
|
||
AGENT_DEVICE_SWIFT_CACHE_DIR: '',
|
||
AGENT_DEVICE_TEST_TMPDIR_EVIDENCE_PATH: evidencePath,
|
||
},
|
||
timeoutMs: 30_000,
|
||
},
|
||
);
|
||
|
||
workerTmpDir = fs.readFileSync(evidencePath, 'utf8');
|
||
assert.equal(path.dirname(workerTmpDir), TEST_RUN_TMP_ROOT);
|
||
assert.match(path.basename(workerTmpDir), new RegExp(`^${TEST_RUN_TMP_PREFIX}\\d+-`));
|
||
assert.equal(
|
||
fs.existsSync(workerTmpDir),
|
||
false,
|
||
'global teardown must remove the run directory',
|
||
);
|
||
} finally {
|
||
if (workerTmpDir) fs.rmSync(workerTmpDir, { recursive: true, force: true });
|
||
fs.rmSync(probePath, { force: true });
|
||
fs.rmSync(evidenceRoot, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
// INT32_MAX exceeds every platform's pid range (Linux pid_max caps at 2^22,
|
||
// macOS at 99999), so kill(pid, 0) is ESRCH by construction — an owner that
|
||
// is dead and can never be reused mid-test, unlike a freshly exited child's pid.
|
||
const NEVER_A_PID = 2_147_483_647;
|
||
|
||
test('global setup prunes a run directory abandoned by an earlier killed run and keeps a live one', async () => {
|
||
const stamp = crypto.randomUUID();
|
||
const abandoned = path.join(
|
||
TEST_RUN_TMP_ROOT,
|
||
`${TEST_RUN_TMP_PREFIX}${NEVER_A_PID}-planted-${stamp}`,
|
||
);
|
||
// Owned by this test process, which is alive for the whole nested run: the
|
||
// same shape as a concurrent run in another worktree, and must survive.
|
||
const live = path.join(
|
||
TEST_RUN_TMP_ROOT,
|
||
`${TEST_RUN_TMP_PREFIX}${process.pid}-planted-${stamp}`,
|
||
);
|
||
const probeName = `vitest-tmpdir-prune-probe-${process.pid}-${stamp}.test.ts`;
|
||
const probePath = path.join(REPOSITORY_ROOT, 'src', '__tests__', probeName);
|
||
fs.mkdirSync(path.join(abandoned, 'nested'), { recursive: true });
|
||
fs.writeFileSync(path.join(abandoned, 'nested', 'leftover.txt'), 'from a killed run');
|
||
fs.mkdirSync(live);
|
||
fs.writeFileSync(
|
||
probePath,
|
||
`import { test } from 'vitest';
|
||
|
||
test('noop probe: the run itself is the subject', () => {});
|
||
`,
|
||
);
|
||
|
||
try {
|
||
const result = await runCmd(
|
||
path.join(REPOSITORY_ROOT, 'node_modules', '.bin', 'vitest'),
|
||
['run', '--project', 'unit-core', probePath],
|
||
{ cwd: REPOSITORY_ROOT, timeoutMs: 30_000 },
|
||
);
|
||
assert.equal(result.exitCode, 0, `probe run failed:\n${result.stdout}\n${result.stderr}`);
|
||
assert.equal(fs.existsSync(abandoned), false, 'setup must prune the abandoned run directory');
|
||
assert.equal(fs.existsSync(live), true, 'setup must never touch a live owner’s run directory');
|
||
} finally {
|
||
fs.rmSync(abandoned, { recursive: true, force: true });
|
||
fs.rmSync(live, { recursive: true, force: true });
|
||
fs.rmSync(probePath, { force: true });
|
||
}
|
||
});
|