Files
gstack/test/session-runner-groupkill.test.ts
T
Garry TanandClaude Fable 5 59acf5759e feat: two-phase session timeout — silent APIs die at the startup grace, named
The single spawn-armed timer charged API queue latency to the work budget:
the recurring '0 turns / $0.00 / x3 attempts' failure with four budget-bump
receipts (180->300s, 240->360s, 300->420s, 90->300s). Split: startup phase
(no NDJSON byte yet) kills EARLY at min(grace, timeout) with the distinct
exitReason 'timeout_startup' — an availability verdict, not transcript
archaeology — and the work phase arms on the first byte for the REMAINING
budget, so total wall never exceeds the timeout (tier envelopes are
margin-free: tests pass timeout: CAPTURE_MS and bun-budget the same tier).
Local grace 90s (observed queue latency 60-90s), CI floor 300s (TODOS-filed;
shared runners queue harder), both pinned by the new grace tests with fake
-claude shims covering the late-first-byte and silent-API paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 04:50:43 +00:00

107 lines
5.0 KiB
TypeScript

/**
* Group-kill regression pin for the provider session runners (F7 in the
* test-infra audit): a timed-out `claude -p` used to get a bare proc.kill()
* — the direct child died but tool subprocesses it had spawned survived as
* orphans holding our pipes open and burning shared API rate (observed: a
* 600s timeout stretching past 1400s; a stalled legacy run once burned a
* core for 15 hours). The fix: node:child_process spawn with `detached`
* (child leads its own process group) + killProcessGroup(SIGKILL) in the
* timeout handler, mirroring runShardChild's proven pattern.
*
* The behavioral test drives the REAL runSkillTest against a fake `claude`
* shim (PATH override — hermeticChildEnv allowlists PATH through) that
* spawns a grandchild and never exits: the run must classify as timeout
* within its budget AND leave neither shim nor grandchild alive.
*
* Windows note: the shim is a '/bin/bash' shebang script, which the free
* runner's Windows curation auto-excludes (CreateProcess cannot exec
* shebangs) — this literal mention is what trips the content scan.
*/
import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
import { runSkillTest } from './helpers/session-runner';
const ROOT = path.resolve(import.meta.dir, '..');
function aliveWithArg(marker: string): boolean {
const result = spawnSync('pgrep', ['-f', marker], { stdio: 'pipe', timeout: 5_000 });
return result.status === 0;
}
describe('session-runner timeout kills the whole process group', () => {
test('fake claude + its grandchild are both dead after a timeout', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'groupkill-'));
const shimDir = path.join(dir, 'bin');
fs.mkdirSync(shimDir);
// Unique-ish sleep durations double as pgrep markers: they appear only
// in the shim's children's argv, never in this test process's cmdline.
const shim = [
'#!/bin/bash',
'sleep 6041 &', // the orphan-candidate grandchild
'exec sleep 6042', // the shim itself, wedged forever, no NDJSON
].join('\n');
fs.writeFileSync(path.join(shimDir, 'claude'), `${shim}\n`, { mode: 0o755 });
const realPath = process.env.PATH;
process.env.PATH = `${shimDir}:${realPath}`;
try {
const started = Date.now();
const result = await runSkillTest({
prompt: 'irrelevant — the shim never reads it',
workingDirectory: dir,
maxTurns: 1,
allowedTools: ['Bash'],
timeout: 3_000,
testName: 'groupkill-probe',
});
const wall = Date.now() - started;
// The shim never prints NDJSON, so the two-phase timer kills it in the
// STARTUP phase (grace = min(default, timeout) = 3s here) — the
// distinct reason is the point: no byte ever arrived.
expect(result.exitReason).toBe('timeout_startup');
// The old bug's signature was the drain blocking long past the budget
// (600s -> 1400s). Generous 10x bound: timeout 3s + the 5s stderr
// grace race must return promptly once the group is dead.
expect(wall).toBeLessThan(30_000);
// The kill is SIGKILL on the GROUP: give the OS a beat to reap, then
// require both the wedged shim and its grandchild gone.
await new Promise((r) => setTimeout(r, 1_000));
expect(aliveWithArg('sleep 6042'), 'the fake claude itself survived the timeout kill').toBe(false);
expect(aliveWithArg('sleep 6041'), 'the grandchild ORPHANED — group kill regressed to a direct-child kill').toBe(false);
} finally {
process.env.PATH = realPath;
// Belt and braces: never leak the markers into later tests even on
// assertion failure.
spawnSync('pkill', ['-f', 'sleep 604[12]'], { stdio: 'ignore', timeout: 5_000 });
fs.rmSync(dir, { recursive: true, force: true });
}
}, 60_000);
});
describe('all three provider runners carry the group-kill wiring', () => {
// Source pin, not behavior: codex/gemini need their real binaries for a
// behavioral run, but the kill wiring is identical code — a runner that
// drops `detached` or reverts to a bare kill() re-opens the orphan class.
const runners = [
'test/helpers/session-runner.ts',
'test/helpers/codex-session-runner.ts',
'test/helpers/gemini-session-runner.ts',
];
for (const rel of runners) {
test(`${path.basename(rel)}: detached spawn + killProcessGroup, no bare timeout kill`, () => {
const source = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
expect(source).toContain("detached: process.platform !== 'win32'");
expect(source).toContain('killProcessGroup(proc');
expect(source, `${rel} reverted to Bun.spawn for the provider child — detached group-kill is impossible there`)
.not.toMatch(/Bun\.spawn\(\[['"](?:claude|codex|gemini)['"]/);
expect(source, `${rel} has a bare proc.kill() in a timeout handler`)
.not.toMatch(/timedOut = true;\s*\n\s*proc\.kill\(\)/);
});
}
});