Skip to content

Commit dfc7cd4

Browse files
committed
Add a regression test for a crash during the run-end write
The existing crash-after-rotation test only covers a crash arriving before any terminal write starts, so it stayed green even with the run-end handle-clearing regression present. This test parks the run-end write mid-flight and fires an unrelated uncaughtException during that window, asserting the outcome is never "crashed" — pinning the fix that moved finalizeRunState's clear ahead of its own write.
1 parent fe23e57 commit dfc7cd4

2 files changed

Lines changed: 102 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Spawned as a subprocess by tests/integration/crash-finalize.test.ts. Mimics
2+
// the run-end write (writeRunSnapshot's "done" call through finalizeRunState
3+
// in state.ts) landing mid-flight when an unrelated uncaughtException fires,
4+
// rather than simulate-crash.ts's scenario of a crash escaping before any
5+
// terminal write is issued at all.
6+
import { installCrashHandlers } from "../../../src/index.js";
7+
import { setActiveRun, setTestWriteGate } from "../../../src/session/active-run.js";
8+
import { sessionDir } from "../../../src/session/index.js";
9+
import { finalizeRunState, saveState } from "../../../src/session/state.js";
10+
11+
const cwd = process.cwd();
12+
const sessionId = process.env["RUN_END_TEST_SESSION_ID"];
13+
if (sessionId === undefined) {
14+
throw new Error("RUN_END_TEST_SESSION_ID must be set");
15+
}
16+
17+
const startedAt = Date.now();
18+
const task = "simulated run-end task";
19+
const model = "test-provider:test-model";
20+
21+
await saveState(cwd, sessionId, {
22+
status: "running",
23+
turnsUsed: 3,
24+
task,
25+
startedAt,
26+
model,
27+
});
28+
29+
setActiveRun({ sessionId, cwd, task, startedAt, model });
30+
installCrashHandlers();
31+
32+
process.stdout.write(`${sessionDir(cwd, sessionId)}\n`);
33+
34+
// Held open for the rest of the process's life — saveCrashState (the crash
35+
// path) bypasses this gate entirely via a raw atomicWrite, so parking the
36+
// run-end write here forever is enough to simulate "the run-end snapshot
37+
// write is still in flight" without needing to release it: whether the
38+
// process observes "done" or "crashed" is decided before this write would
39+
// ever land.
40+
setTestWriteGate(new Promise(() => {}));
41+
42+
// Fire the run-end write the same way writeRunSnapshot does for a terminal
43+
// status, but don't await it — runner.ts doesn't either from the crash
44+
// handler's point of view, since the crash below arrives asynchronously.
45+
void finalizeRunState(cwd, sessionId, {
46+
status: "done",
47+
turnsUsed: 3,
48+
task,
49+
startedAt,
50+
finishedAt: Date.now(),
51+
model,
52+
});
53+
54+
// Runs after the synchronous portion of finalizeRunState above (its
55+
// clearActiveRun call, if placed before the saveState await) has already
56+
// executed, since setImmediate always waits for the current synchronous
57+
// script to finish. This is the window the bug reopened: an unrelated
58+
// exception landing while the run-end write is still in flight.
59+
setImmediate(() => {
60+
throw new Error("simulated crash during run-end write");
61+
});

tests/integration/crash-finalize.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { RunState } from "../../src/session/state.js";
99
import { isResumableByDefault } from "../../src/tui/pick-session.js";
1010

1111
const FIXTURE = join(import.meta.dirname, "../fixtures/crash-run/simulate-crash.ts");
12+
const RUN_END_FIXTURE = join(import.meta.dirname, "../fixtures/crash-run/simulate-run-end-crash.ts");
1213

1314
describe("integration — crash finalizes run.json", () => {
1415
test("uncaughtException writes status: crashed with finishedAt, racing in-flight snapshot writes", async () => {
@@ -99,4 +100,44 @@ describe("integration — crash finalizes run.json", () => {
99100
rmSync(home, { recursive: true, force: true });
100101
}
101102
}, 15_000);
103+
104+
test("an unrelated crash while the run-end write is in flight does not report crashed", async () => {
105+
const cwd = mkdtempSync(join(tmpdir(), "corbits-crash-cwd-"));
106+
const home = mkdtempSync(join(tmpdir(), "corbits-crash-home-"));
107+
const sessionId = generateSessionId();
108+
109+
try {
110+
const proc = Bun.spawn(["bun", "run", RUN_END_FIXTURE], {
111+
cwd,
112+
env: { ...process.env, HOME: home, RUN_END_TEST_SESSION_ID: sessionId },
113+
stdout: "pipe",
114+
stderr: "pipe",
115+
});
116+
117+
const exitCode = await proc.exited;
118+
const stdout = await new Response(proc.stdout).text();
119+
const stderr = await new Response(proc.stderr).text();
120+
121+
expect(exitCode).toBe(1);
122+
expect(stderr).toContain("uncaughtException: Error: simulated crash during run-end write");
123+
124+
// The bug this pins: finalizeRunState used to clear the active-run
125+
// handle only after its own saveState write resolved. With the
126+
// run-end write parked mid-flight (this fixture's gate never
127+
// releases), the handle stayed live for the entire window, so the
128+
// crash handler saw a live run and wrote a "crashed" record via
129+
// saveCrashState — which bypasses the gate — clobbering what should
130+
// have been a clean finish. Clearing the handle before the await
131+
// closes that window: the crash handler finds no active run and
132+
// writes nothing, so the last write to land is the one from the
133+
// initial saveState above ("running"), never "crashed".
134+
const runJsonPath = join(stdout.trim(), "run.json");
135+
const raw = readFileSync(runJsonPath, "utf8");
136+
const state = JSON.parse(raw) as RunState;
137+
expect(state.status).not.toBe("crashed");
138+
} finally {
139+
rmSync(cwd, { recursive: true, force: true });
140+
rmSync(home, { recursive: true, force: true });
141+
}
142+
}, 15_000);
102143
});

0 commit comments

Comments
 (0)