fix(test-harness): return promptly when a timed-out child leaves pipe-holding orphans

proc.kill() only signals the sh -c wrapper; the claude child survives as an
orphan that inherited our stdout/stderr pipes, blocking the stream drain until
it exits (observed: a 600s spawn timeout stretching to 1431s and tripping bun's
per-test timeout with no result). On timeout, cancel the stdout reader; race
the stderr drain against child exit + 5s grace. Streamed transcript lines
survive the cancel, so callers still get their evidence.

Regression test: test/session-runner-timeout.test.ts (fake claude spawns a
pipe-holding orphan; fails in 30s without the fix, passes in 8s with it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-07-09 23:49:09 -07:00
parent 11de390be1
commit bb4de3926f
2 changed files with 91 additions and 1 deletions
+18 -1
View File
@@ -201,6 +201,12 @@ export async function runSkillTest(options: {
const timeoutId = setTimeout(() => {
timedOut = true;
proc.kill();
// proc.kill() only signals the `sh -c` wrapper. The claude child it
// spawned can survive as an orphan that inherited our stdout/stderr
// pipes, so without cancel() the read loop below blocks until the
// orphan finally exits (observed: a 600s timeout stretching past 1400s
// and tripping bun's per-test timeout instead of returning a result).
reader.cancel().catch(() => { /* stream already closed */ });
}, timeout);
// Stream NDJSON from stdout for real-time progress
@@ -289,7 +295,18 @@ export async function runSkillTest(options: {
collectedLines.push(buf);
}
stderr = await stderrPromise;
// Same orphan hazard as stdout: an orphaned grandchild holding stderr open
// would block the drain forever. Race it against child exit + a short grace
// window; the normal path (pipes close with the child) still wins the race
// and keeps full stderr.
stderr = await Promise.race([
stderrPromise,
(async () => {
await proc.exited;
await new Promise((r) => setTimeout(r, 5_000));
return '';
})(),
]);
const exitCode = await proc.exited;
clearTimeout(timeoutId);