Skip to content

Commit 13584dd

Browse files
committed
Make the crash write-chain race test deterministic
Firing 50 unawaited writes and hoping enough were still queued when isCrashed() flipped was a coin flip in practice (5/13 passed with the guard removed, when it should fail every time). A test-only write gate in active-run.ts now lets the fixture park writes before they reach the isCrashed() check and release them only after the crash handler has flipped the flag, so the ordering is controlled instead of hoped for. 25/25 passes with the guard in place; removing the guard reliably lets the parked writes win the race again.
1 parent f901295 commit 13584dd

4 files changed

Lines changed: 56 additions & 22 deletions

File tree

src/session/active-run.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,18 @@ export function markCrashed(): void {
4949
export function isCrashed(): boolean {
5050
return crashed;
5151
}
52+
53+
// Test-only seam: lets an integration test hold a chained write open past the
54+
// moment markCrashed() fires, so it can deterministically prove a write still
55+
// queued in the chain sees isCrashed() before it fires — rather than hoping
56+
// real filesystem timing happens to interleave that way. No effect on
57+
// production callers, which never install a gate.
58+
let testWriteGate: Promise<void> | null = null;
59+
60+
export function setTestWriteGate(gate: Promise<void> | null): void {
61+
testWriteGate = gate;
62+
}
63+
64+
export function getTestWriteGate(): Promise<void> | null {
65+
return testWriteGate;
66+
}

src/session/state.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
44
import { type } from "arktype";
55

66
import { sessionDir } from "./index.js";
7-
import { isCrashed } from "./active-run.js";
7+
import { getTestWriteGate, isCrashed } from "./active-run.js";
88
import { COMMAND_NAME } from "../branding.js";
99

1010
const ConnectedMcpServerSchema = type({
@@ -74,6 +74,10 @@ const writeChains = new Map<string, Promise<void>>();
7474
// dispatched to the kernel — that residual window is one atomicWrite call
7575
// wide (a small local JSON write), not the remaining lifetime of the process.
7676
async function atomicWriteUnlessCrashed(path: string, content: string): Promise<void> {
77+
// No-op in production; lets a test hold this write open past the moment
78+
// isCrashed() flips, so the check below is proven rather than assumed.
79+
const gate = getTestWriteGate();
80+
if (gate !== null) await gate;
7781
if (isCrashed()) return;
7882
await atomicWrite(path, content);
7983
}

tests/fixtures/crash-run/simulate-crash.ts

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// crash handlers), then throws asynchronously so it surfaces as a genuine
55
// uncaughtException rather than a synchronous throw the caller could catch.
66
import { installCrashHandlers } from "../../../src/index.js";
7-
import { setActiveRun } from "../../../src/session/active-run.js";
7+
import { setActiveRun, setTestWriteGate } from "../../../src/session/active-run.js";
88
import { sessionDir } from "../../../src/session/index.js";
99
import { saveState } from "../../../src/session/state.js";
1010

@@ -31,23 +31,35 @@ installCrashHandlers();
3131

3232
process.stdout.write(`${sessionDir(cwd, sessionId)}\n`);
3333

34-
// Queue a burst of unawaited straggler snapshot writes (what
35-
// persistRunSnapshot does on every turn/model-switch/MCP-connect event) right
36-
// before crashing. Each is chained onto the previous one in state.ts's
37-
// per-session write queue, so most of these are still waiting their turn —
38-
// not yet dispatched to the kernel — at the moment the crash handler flips
39-
// the isCrashed() flag. Without that guard, one of these landing after
40-
// saveCrashState's rename() would resurrect status: "running".
41-
for (let i = 0; i < 50; i++) {
42-
void saveState(cwd, sessionId, {
43-
status: "running",
44-
turnsUsed: i,
45-
task,
46-
startedAt,
47-
model,
48-
});
49-
}
34+
// Hold every write issued from here on at the gate, before it reaches
35+
// isCrashed(). This makes the race deterministic instead of hoping real
36+
// filesystem timing interleaves the right way: the two straggler writes
37+
// below are guaranteed to still be queued, not dispatched to the kernel,
38+
// when the crash handler flips isCrashed() — the exact scenario the guard
39+
// exists for.
40+
let releaseGate: () => void;
41+
const gate = new Promise<void>((resolve) => {
42+
releaseGate = resolve;
43+
});
44+
setTestWriteGate(gate);
45+
46+
// Two unawaited straggler snapshot writes, chained behind each other in
47+
// state.ts's per-session queue — what persistRunSnapshot fires on every
48+
// turn/model-switch/MCP-connect event. Both are parked at the gate.
49+
void saveState(cwd, sessionId, { status: "running", turnsUsed: 1, task, startedAt, model });
50+
void saveState(cwd, sessionId, { status: "running", turnsUsed: 2, task, startedAt, model });
5051

52+
// Throws inside setImmediate so it surfaces as a real uncaughtException.
53+
// Node/Bun run the exception's own uncaughtException dispatch — including
54+
// handleFatal's synchronous markCrashed() call, which precedes its first
55+
// await — to completion before the event loop reaches the next queued
56+
// setImmediate callback. The second setImmediate below is therefore
57+
// guaranteed to run after isCrashed() has flipped to true, so releasing the
58+
// gate there always lets the two parked writes observe the flag rather than
59+
// racing it.
5160
setImmediate(() => {
5261
throw new Error("simulated crash");
5362
});
63+
setImmediate(() => {
64+
releaseGate();
65+
});

tests/integration/crash-finalize.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,13 @@ describe("integration — crash finalizes run.json", () => {
3535
const raw = readFileSync(runJsonPath, "utf8");
3636
const state = JSON.parse(raw) as RunState;
3737

38-
// The fixture also fires 50 unawaited straggler "running" snapshot
39-
// writes for the same session immediately before crashing. Without the
40-
// isCrashed() guard in saveState (src/session/state.ts), one of those
41-
// could win the rename() race and this would read back "running".
38+
// The fixture also parks two unawaited straggler "running" snapshot
39+
// writes behind a test-only gate (setTestWriteGate) that it releases
40+
// only after the crash handler has flipped isCrashed(), guaranteeing
41+
// both are still queued — not dispatched to the kernel — at that
42+
// moment. Without the isCrashed() guard in saveState
43+
// (src/session/state.ts), one of those would win the rename() race
44+
// once released and this would read back "running".
4245
expect(state.status).toBe("crashed");
4346
expect(state.finishedAt).toBeGreaterThan(0);
4447
expect(state.error).toContain("simulated crash");

0 commit comments

Comments
 (0)