fix(test-utils): cancel subprocess tracking timer for every proc in afterEach

The vitest subprocess guard's 60 s tracking timer could outlive the test
that spawned it and fire during a later test's afterEach, producing
spurious "Timed out after 60000ms" failures attributed to a different
test name under concurrent recursive test load.

Scope "Left running" reporting + SIGKILL to the current test's procs but
always clear each tracked subprocess's timer so it cannot fire later.
Bump the post-test grace from 200 ms to 1 s to absorb event-loop
contention from slow git shells.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-22 18:38:51 -07:00
parent d37bebf8bf
commit ec6643e4d9
2 changed files with 40 additions and 8 deletions

View File

@@ -0,0 +1,19 @@
---
"@fusion/core": patch
---
fix(test-utils): cancel subprocess tracking timer for every proc in afterEach
The vitest subprocess guard registered a 60 s "command timed out" timer for
each tracked child process and relied on `afterEach` to cancel it. Under
concurrent load (`pnpm` recursive test runs) the timer could outlive the
originating test and fire during a later test's `afterEach`, surfacing as
spurious "Test subprocess guard detected unsafe child-process usage:
Timed out after 60000ms" failures attributed to a different test name.
The cleanup loop now scopes "Left running" failure reporting + SIGKILL to
processes spawned by the current test, but unconditionally clears each
tracked subprocess's timer so the 60 s timeout cannot fire after the
afterEach completes. The grace period before declaring a process leaked
is also raised from 200 ms to 1 s to absorb event-loop contention from
slow git shells under recursive test load.

View File

@@ -645,7 +645,10 @@ afterEach(async () => {
// them "left running" — tests like dev-server-process.cleanup() send SIGTERM // them "left running" — tests like dev-server-process.cleanup() send SIGTERM
// and immediately drop their reference, so the OS exit lags the test by a // and immediately drop their reference, so the OS exit lags the test by a
// few ms even when the production code did the right thing. // few ms even when the production code did the right thing.
const SUBPROCESS_GRACE_MS = 200; // Under concurrent load (pnpm recursive test) the event loop can be busy
// enough that git shell processes take longer to emit 'close'; 1 s prevents
// false-positive guard failures without weakening the safety net.
const SUBPROCESS_GRACE_MS = 1000;
if (trackedSubprocesses.size > 0) { if (trackedSubprocesses.size > 0) {
const stillRunningProcs: ChildProcess[] = []; const stillRunningProcs: ChildProcess[] = [];
for (const [proc] of trackedSubprocesses) { for (const [proc] of trackedSubprocesses) {
@@ -679,18 +682,28 @@ afterEach(async () => {
} }
} }
const currentTest = currentTestName();
for (const [proc, tracked] of trackedSubprocesses) { for (const [proc, tracked] of trackedSubprocesses) {
const stillRunning = proc.exitCode === null && proc.signalCode === null; const stillRunning = proc.exitCode === null && proc.signalCode === null;
if (stillRunning) { if (stillRunning) {
failures.push( // Under concurrent load (pool:threads + isolate:true), tests in the
`Left running at end of test: ${tracked.commandLine}${tracked.testName ? ` (${tracked.testName})` : ""}`, // same worker can interleave. Only flag processes started by the
); // current test to avoid false-positive "left running" errors from
try { // sibling tests that are still wrapping up their subprocesses.
proc.kill("SIGKILL"); if (tracked.testName === currentTest) {
} catch { failures.push(
// Ignore — the process may have already exited. `Left running at end of test: ${tracked.commandLine}${tracked.testName ? ` (${tracked.testName})` : ""}`,
);
try {
proc.kill("SIGKILL");
} catch {
// Ignore — the process may have already exited.
}
} }
} }
// Always clean up (cancel the tracking timer and remove from map) so the
// 60s timeout timer cannot fire after this afterEach, regardless of which
// test originally spawned the process.
cleanupTrackedSubprocess(proc); cleanupTrackedSubprocess(proc);
} }