Skip to content

Commit 7eec056

Browse files
Add parallel test runner with stall watchdog
bun test --parallel on Bun 1.4.x intermittently livelocks: a worker spins at 100% CPU holding a zombie git child while the main process idles with no output, and bun test has no run-level timeout, so a stalled run hangs forever (upstream oven-sh/bun bug, still open on 1.4.2, reproduced locally at ~50% of runs with 4 workers). A healthy run never exceeds 0.64s of output silence, so the wrapper runs the seeded suite in its own process group, kills the whole group after 90s of silence, and retries up to 3 times. A child that exits on its own, pass or fail, is never retried, so real failures still fail the gate. CI keeps sharded sequential runs and is unaffected.
1 parent 51db9b6 commit 7eec056

4 files changed

Lines changed: 326 additions & 0 deletions

File tree

docs/IMPLEMENTATION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,7 @@ Run all three before declaring work complete.
521521
- **`tests/integration/`** holds the reactor permission / multi-turn harness (scripted models via `@intx/inference-testing`). **`tests/e2e/`** (fixture-repo runs) is still planned. Until e2e exists, broader harness coverage also lives in co-located `*.test.ts` files and `tests/unit/`.
522522
- **Capability evals** (`evals/capability/`) are **not** the integration harness: they drive the product path (`corbits exec` / `runExec`) with real models against fixture copies and objective `verify.sh` graders. Case format + loader tests live under `evals/capability/`; run with `bun run eval:capability` (see `evals/capability/README.md`). Use `--baseline` to detect improve/regress across models or commits.
523523
- **TUI tests** are co-located `*.test.ts` files under `src/tui/` (e.g. `shell.test.ts`, `runner-host.test.ts`, `stream.test.ts`), run as part of `bun test` along with everything else; there is no separate `test:tui` script or test-setup preload.
524+
- **Parallel local runs**`bun run test:parallel [N]` (default 4 workers) runs the same seeded suite with `--parallel=N`, wrapped in an output-stall watchdog (`scripts/test-parallel.ts`). Bun 1.4.x intermittently livelocks under `--parallel` (one worker spins at 100 % CPU holding a zombie git child while the main process idles; no output, no summary — upstream oven-sh/bun#36235), and `bun test` has no run-level timeout, so a stalled run hangs forever. The watchdog kills the suite's own process group after 90 s of silence and retries up to 3 times; a child that exits on its own (pass or fail) is never retried. CI keeps sharded sequential runs (`test:paths`) and does not use `--parallel`.
524525

525526
## Deployment
526527

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"typecheck": "tsc --noEmit",
3535
"test": "bun test ./src ./tests ./evals ./scripts --randomize --seed 424242",
3636
"test:paths": "bun scripts/test-paths.ts",
37+
"test:parallel": "bun scripts/test-parallel.ts",
3738
"lint": "oxfmt --check . && oxlint",
3839
"check:projects-dir-guard": "bun scripts/guard-real-projects-dir.ts",
3940
"check": "bun run lint && bun run typecheck && bun run build && bun run check:projects-dir-guard",

scripts/test-parallel.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { mkdtempSync, readFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
6+
import { runWithWatchdog } from "./test-parallel.js";
7+
8+
function processAlive(pid: number): boolean {
9+
try {
10+
process.kill(pid, 0);
11+
return true;
12+
} catch {
13+
return false;
14+
}
15+
}
16+
17+
// These probes are `bun -e` one-liners, so they never import project modules
18+
// and finish in milliseconds. Stall windows are tiny (300-500ms) to keep the
19+
// file fast while still exercising the watchdog's timing logic.
20+
21+
describe("runWithWatchdog", () => {
22+
test("passes through a successful run without retrying", async () => {
23+
const chunks: string[] = [];
24+
const result = await runWithWatchdog({
25+
command: process.execPath,
26+
args: ["-e", 'console.log("hello-parallel")'],
27+
stallMs: 5_000,
28+
onOutput: (chunk) => chunks.push(Buffer.from(chunk).toString("utf8")),
29+
});
30+
expect(result.exitCode).toBe(0);
31+
expect(result.stalled).toBe(false);
32+
expect(result.attempts).toBe(1);
33+
expect(chunks.join("")).toContain("hello-parallel");
34+
});
35+
36+
test("passes through a failing run without retrying", async () => {
37+
const result = await runWithWatchdog({
38+
command: process.execPath,
39+
args: ["-e", "process.exit(3)"],
40+
stallMs: 5_000,
41+
onStall: () => {
42+
throw new Error("must not stall on a clean failure");
43+
},
44+
});
45+
expect(result.exitCode).toBe(3);
46+
expect(result.stalled).toBe(false);
47+
expect(result.attempts).toBe(1);
48+
});
49+
50+
test("output resets the stall timer", async () => {
51+
// Prints every 100ms for ~700ms against a 300ms stall window: a broken
52+
// timer that never reset would fire on the second tick.
53+
const result = await runWithWatchdog({
54+
command: process.execPath,
55+
args: [
56+
"-e",
57+
"for (let i = 0; i < 7; i++) { console.log('tick', i); await new Promise(r => setTimeout(r, 100)); }",
58+
],
59+
stallMs: 300,
60+
});
61+
expect(result.exitCode).toBe(0);
62+
expect(result.stalled).toBe(false);
63+
expect(result.attempts).toBe(1);
64+
});
65+
66+
test("retries after a stall and returns the second attempt's result", async () => {
67+
const dir = mkdtempSync(join(tmpdir(), "test-parallel-retry-"));
68+
const flag = join(dir, "attempted");
69+
const code = `
70+
const fs = require("node:fs");
71+
if (fs.existsSync(${JSON.stringify(flag)})) {
72+
console.log("second attempt");
73+
} else {
74+
fs.writeFileSync(${JSON.stringify(flag)}, "1");
75+
setTimeout(() => {}, 30_000);
76+
}
77+
`;
78+
const stalls: [number, number][] = [];
79+
const result = await runWithWatchdog({
80+
command: process.execPath,
81+
args: ["-e", code],
82+
stallMs: 300,
83+
onStall: (attempt, max) => stalls.push([attempt, max]),
84+
});
85+
expect(result.exitCode).toBe(0);
86+
expect(result.stalled).toBe(false);
87+
expect(result.attempts).toBe(2);
88+
expect(stalls).toEqual([[1, 3]]);
89+
});
90+
91+
test("gives up after the final attempt with exit code 1", async () => {
92+
const stalls: [number, number][] = [];
93+
const result = await runWithWatchdog({
94+
command: process.execPath,
95+
args: ["-e", "setTimeout(() => {}, 30_000)"],
96+
stallMs: 300,
97+
attempts: 2,
98+
onStall: (attempt, max) => stalls.push([attempt, max]),
99+
});
100+
expect(result.exitCode).toBe(1);
101+
expect(result.stalled).toBe(true);
102+
expect(result.attempts).toBe(2);
103+
// The final stall is reported by the exit code, not a retry notice.
104+
expect(stalls).toEqual([[1, 2]]);
105+
});
106+
107+
test("kills the whole process group, including the child's own children", async () => {
108+
const dir = mkdtempSync(join(tmpdir(), "test-parallel-group-"));
109+
const pidFile = join(dir, "grandchild.pid");
110+
const code = `
111+
const { spawn } = require("node:child_process");
112+
const fs = require("node:fs");
113+
const grandchild = spawn("sleep", ["30"], { stdio: "ignore" });
114+
fs.writeFileSync(${JSON.stringify(pidFile)}, String(grandchild.pid));
115+
setTimeout(() => {}, 30_000);
116+
`;
117+
const result = await runWithWatchdog({
118+
command: process.execPath,
119+
args: ["-e", code],
120+
stallMs: 300,
121+
attempts: 1,
122+
});
123+
expect(result.exitCode).toBe(1);
124+
expect(result.stalled).toBe(true);
125+
126+
const grandchildPid = Number(readFileSync(pidFile, "utf8"));
127+
// Reaped processes disappear from the pid namespace, but allow a brief
128+
// window for the kernel to reap before concluding the kill failed.
129+
const deadline = Date.now() + 2_000;
130+
while (processAlive(grandchildPid) && Date.now() < deadline) {
131+
await new Promise((r) => setTimeout(r, 50));
132+
}
133+
expect(processAlive(grandchildPid)).toBe(false);
134+
});
135+
});

scripts/test-parallel.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
#!/usr/bin/env bun
2+
// Parallel test runner with a stall watchdog.
3+
//
4+
// `bun test --parallel` intermittently livelocks on Bun 1.4.x: a worker
5+
// spins at 100% CPU while a git child it spawned is left as a zombie, and
6+
// the suite produces no further output (upstream Bun issue #36235, still
7+
// open as of 1.4.2). bun test has no run-level timeout, so this wrapper
8+
// runs the suite in its own process group, watches for an output stall
9+
// well beyond any healthy run's silence, kills the group, and retries.
10+
// A child that exits on its own (pass or fail) is never retried.
11+
//
12+
// Usage: bun scripts/test-parallel.ts [workers] (default 4)
13+
14+
export const DEFAULT_WORKERS = 4;
15+
export const DEFAULT_STALL_MS = 90_000;
16+
export const DEFAULT_ATTEMPTS = 3;
17+
// SIGKILL a group that ignores SIGTERM this long.
18+
const KILL_GRACE_MS = 5_000;
19+
20+
export interface WatchdogOptions {
21+
// Arguments after the bun binary, e.g. ["test", "./src", "--parallel", "4"].
22+
args: string[];
23+
// Bun binary to spawn. Defaults to the running binary.
24+
command?: string;
25+
stallMs?: number;
26+
attempts?: number;
27+
onOutput?: (chunk: Uint8Array, stream: "stdout" | "stderr") => void;
28+
// Report a stall so the caller can log the retry. Defaults to stderr.
29+
onStall?: (attempt: number, maxAttempts: number) => void;
30+
}
31+
32+
export interface WatchdogResult {
33+
exitCode: number;
34+
stalled: boolean;
35+
attempts: number;
36+
}
37+
38+
const STALL_CODE = -1;
39+
40+
function groupAlive(pid: number): boolean {
41+
try {
42+
process.kill(-pid, 0);
43+
return true;
44+
} catch {
45+
return false;
46+
}
47+
}
48+
49+
async function terminateGroup(pid: number): Promise<void> {
50+
try {
51+
process.kill(-pid, "SIGTERM");
52+
} catch {
53+
return;
54+
}
55+
const deadline = Date.now() + KILL_GRACE_MS;
56+
while (Date.now() < deadline) {
57+
if (!groupAlive(pid)) return;
58+
await new Promise((r) => setTimeout(r, 200));
59+
}
60+
try {
61+
process.kill(-pid, "SIGKILL");
62+
} catch {
63+
// group already gone
64+
}
65+
}
66+
67+
interface AttemptOutcome {
68+
exitCode: number;
69+
stalled: boolean;
70+
}
71+
72+
async function runAttempt(
73+
command: string,
74+
args: string[],
75+
stallMs: number,
76+
onOutput: WatchdogOptions["onOutput"],
77+
): Promise<AttemptOutcome> {
78+
const child = Bun.spawn([command, ...args], {
79+
stdout: "pipe",
80+
stderr: "pipe",
81+
// Own process group so a stall kills bun test, its workers, and any
82+
// grandchild (e.g. git) together.
83+
detached: true,
84+
});
85+
86+
let lastOutput = Date.now();
87+
let stalled = false;
88+
let exited = false;
89+
90+
const touch = (chunk: Uint8Array, stream: "stdout" | "stderr") => {
91+
lastOutput = Date.now();
92+
onOutput?.(chunk, stream);
93+
};
94+
const pumpStream = async (
95+
stream: typeof child.stdout,
96+
kind: "stdout" | "stderr",
97+
): Promise<void> => {
98+
const reader = stream.getReader();
99+
for (;;) {
100+
const { done, value } = await reader.read();
101+
if (done) break;
102+
touch(value, kind);
103+
}
104+
};
105+
const pumps = [
106+
pumpStream(child.stdout, "stdout"),
107+
pumpStream(child.stderr, "stderr"),
108+
];
109+
110+
const tickMs = Math.max(100, Math.min(5_000, stallMs));
111+
const watchdog = setInterval(() => {
112+
if (exited) return;
113+
// Re-check liveness: the child may have exited between output and now.
114+
if (!groupAlive(child.pid)) {
115+
exited = true;
116+
return;
117+
}
118+
if (Date.now() - lastOutput > stallMs) {
119+
stalled = true;
120+
clearInterval(watchdog);
121+
void terminateGroup(child.pid);
122+
}
123+
}, tickMs);
124+
125+
const raw = await child.exited;
126+
exited = true;
127+
clearInterval(watchdog);
128+
await Promise.allSettled(pumps);
129+
// null means killed by a signal; the stalled flag says who did it.
130+
const exitCode = raw === null ? (stalled ? STALL_CODE : 1) : raw;
131+
return { exitCode, stalled };
132+
}
133+
134+
export async function runWithWatchdog(
135+
opts: WatchdogOptions,
136+
): Promise<WatchdogResult> {
137+
const stallMs = opts.stallMs ?? DEFAULT_STALL_MS;
138+
const maxAttempts = opts.attempts ?? DEFAULT_ATTEMPTS;
139+
const command = opts.command ?? process.execPath;
140+
const reportStall =
141+
opts.onStall ??
142+
((attempt, max) =>
143+
console.error(
144+
`[test:parallel] no output for ${Math.round(stallMs / 1000)}s — ` +
145+
`killing stalled run (attempt ${attempt}/${max}); ` +
146+
`Bun --parallel livelock, see Bun issue #36235`,
147+
));
148+
149+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
150+
const { exitCode, stalled } = await runAttempt(
151+
command,
152+
opts.args,
153+
stallMs,
154+
opts.onOutput,
155+
);
156+
if (!stalled) {
157+
return { exitCode, stalled: false, attempts: attempt };
158+
}
159+
if (attempt < maxAttempts) reportStall(attempt, maxAttempts);
160+
}
161+
return { exitCode: 1, stalled: true, attempts: maxAttempts };
162+
}
163+
164+
if (import.meta.main) {
165+
const arg = process.argv[2];
166+
const workers = arg === undefined ? DEFAULT_WORKERS : Number(arg);
167+
if (!Number.isInteger(workers) || workers < 1 || workers > 16) {
168+
console.error("usage: bun scripts/test-parallel.ts [workers 1-16]");
169+
process.exit(2);
170+
}
171+
const result = await runWithWatchdog({
172+
args: [
173+
"test",
174+
"./src",
175+
"./tests",
176+
"./evals",
177+
"./scripts",
178+
"--randomize",
179+
"--seed",
180+
"424242",
181+
"--parallel",
182+
String(workers),
183+
],
184+
onOutput: (chunk, stream) => {
185+
(stream === "stdout" ? process.stdout : process.stderr).write(chunk);
186+
},
187+
});
188+
process.exit(result.exitCode);
189+
}

0 commit comments

Comments
 (0)