mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-11 02:13:47 +02:00
bb4de3926f
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>
74 lines
2.9 KiB
TypeScript
74 lines
2.9 KiB
TypeScript
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { runSkillTest } from './helpers/session-runner';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
|
|
// Regression test for the runSkillTest timeout path (free tier — no API call,
|
|
// the spawned "claude" is a local fake).
|
|
//
|
|
// proc.kill() only signals the `sh -c` wrapper; the claude child it spawned
|
|
// survives as an orphan that inherited our stdout/stderr pipes. Before the
|
|
// fix, the runner then blocked on the pipe drain until the orphan exited —
|
|
// observed as a 600s spawn timeout stretching past 1400s and tripping bun's
|
|
// per-test timeout in skill-e2e-autoplan-dual-voice.test.ts. The fix cancels
|
|
// the stdout reader on timeout and races the stderr drain against child exit
|
|
// plus a short grace window.
|
|
|
|
const ORPHAN_LINGER_SECS = 45; // without the fix the runner blocks this long
|
|
|
|
describe.skipIf(process.platform === 'win32')('session-runner timeout path', () => {
|
|
let fixtureBin: string;
|
|
let workDir: string;
|
|
|
|
beforeAll(() => {
|
|
fixtureBin = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-claude-bin-'));
|
|
workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-runner-timeout-'));
|
|
|
|
// Fake claude: emits minimal stream-json, spawns a pipe-holding orphan,
|
|
// then lingers in the foreground. Killing the sh wrapper leaves both this
|
|
// process and its background child alive, holding the pipes open.
|
|
const fakeClaude = path.join(fixtureBin, 'claude');
|
|
fs.writeFileSync(
|
|
fakeClaude,
|
|
`#!/bin/sh
|
|
cat > /dev/null &
|
|
echo '{"type":"system","subtype":"init"}'
|
|
echo '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"echo hi"}}]}}'
|
|
sleep ${ORPHAN_LINGER_SECS} &
|
|
exec sleep ${ORPHAN_LINGER_SECS}
|
|
`,
|
|
{ mode: 0o755 },
|
|
);
|
|
});
|
|
|
|
afterAll(() => {
|
|
for (const dir of [fixtureBin, workDir]) {
|
|
if (dir && fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test(
|
|
'returns promptly when a killed child leaves a pipe-holding orphan',
|
|
async () => {
|
|
const started = Date.now();
|
|
const result = await runSkillTest({
|
|
testName: 'session-runner-timeout-orphan',
|
|
workingDirectory: workDir,
|
|
prompt: 'irrelevant — the fake claude ignores stdin',
|
|
timeout: 3_000,
|
|
env: { PATH: `${fixtureBin}:${process.env.PATH ?? ''}` },
|
|
});
|
|
const wall = Date.now() - started;
|
|
|
|
expect(result.exitReason).toBe('timeout');
|
|
// Streamed lines collected before the kill must survive the cancel.
|
|
expect(result.transcript.some((e: any) => e?.type === 'assistant')).toBe(true);
|
|
// Without the fix the runner blocks until the orphan exits (~${ORPHAN_LINGER_SECS}s).
|
|
// Timeout (3s) + stderr grace (5s) + slack must stay well under that.
|
|
expect(wall).toBeLessThan(20_000);
|
|
},
|
|
30_000,
|
|
);
|
|
});
|