Skip to content

Commit bfd1595

Browse files
Merge crash-time run.json finalize
2 parents 6af12df + 13584dd commit bfd1595

6 files changed

Lines changed: 312 additions & 15 deletions

File tree

src/index.ts

Lines changed: 57 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { getLogger } from "@intx/log";
22
import { LOG_NAMESPACE_ROOT } from "./branding.js";
33
import { primeCrashReporting, writeCrashReport, type CrashKind } from "./crash/report.js";
4+
import { getActiveRun, markCrashed } from "./session/active-run.js";
5+
import { saveCrashState } from "./session/state.js";
46
import { loadConfig } from "./config/index.js";
57
import { ensureTelemetrySettings, globalSettingsPath } from "./config/settings.js";
68
import { installFileLogSink } from "./logging/sink.js";
@@ -113,32 +115,77 @@ export async function main(argv: readonly string[]): Promise<number> {
113115
});
114116
}
115117

116-
async function handleFatal(kind: CrashKind, error: unknown): Promise<void> {
118+
// Exported so an integration test can register these process-level handlers
119+
// and inject a crash without spawning the full TUI stack.
120+
export async function handleFatal(kind: CrashKind, error: unknown): Promise<void> {
121+
// Flip this before any awaits below so any snapshot write still queued
122+
// behind another one in state.ts's per-session chain sees it and steps
123+
// aside the moment it's next in line, rather than racing saveCrashState's
124+
// rename() below. See markCrashed's doc comment for the residual window
125+
// this cannot close.
126+
markCrashed();
117127
process.stderr.write(`${kind}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
118128
const file = await writeCrashReport(kind, error);
119129
if (file !== null) {
120130
process.stderr.write(`crash report written to ${file}\n`);
121131
} else {
122132
process.stderr.write("failed to write crash report\n");
123133
}
134+
await finalizeActiveRunOnCrash(error);
124135
process.exit(1);
125136
}
126137

127-
if (import.meta.main) {
128-
// OpenTUI installs a process-global uncaughtException/unhandledRejection
129-
// handler that only logs (opentui/core's Renderer.handleError), which
130-
// suppresses Bun's default print-and-exit. Combined with raw-mode stdin
131-
// holding the event loop open, an escaped throw would otherwise hang the
132-
// process forever with the terminal still in the alternate screen. Node
133-
// invokes every registered listener for the event regardless of order, so
134-
// these still run and terminate the process even though OpenTUI's own
135-
// listener never exits or rethrows.
138+
// A crash reaching here escaped without ever hitting runTUI's own try/catch
139+
// (e.g. a throw inside a fire-and-forget `void` call), so run.json was never
140+
// closed out. getActiveRun surfaces the in-flight session set by runTUI, with
141+
// enough (task, startedAt, model) carried on the handle itself that no read
142+
// of run.json is needed — a readFile here would be exactly the kind of
143+
// unbounded crash-path I/O primeCrashReporting (src/crash/report.ts) exists
144+
// to avoid for git: a stalled disk or network mount would block process.exit
145+
// forever. The write itself goes through saveCrashState, which bypasses the
146+
// per-session write chain in state.ts on purpose — chaining behind a write
147+
// that never settles (possibly the very write that triggered this crash)
148+
// would block process.exit indefinitely, defeating this handler's one job.
149+
async function finalizeActiveRunOnCrash(error: unknown): Promise<void> {
150+
const run = getActiveRun();
151+
if (run === null || !run.active) return;
152+
const message = error instanceof Error ? error.message : String(error);
153+
try {
154+
await saveCrashState(run.cwd, run.sessionId, {
155+
status: "crashed",
156+
turnsUsed: 0,
157+
task: run.task,
158+
startedAt: run.startedAt,
159+
finishedAt: Date.now(),
160+
error: message,
161+
...(run.model !== undefined ? { model: run.model } : {}),
162+
});
163+
} catch (saveErr: unknown) {
164+
process.stderr.write(
165+
`failed to finalize run state after crash: ${saveErr instanceof Error ? saveErr.message : String(saveErr)}\n`,
166+
);
167+
}
168+
}
169+
170+
// OpenTUI installs a process-global uncaughtException/unhandledRejection
171+
// handler that only logs (opentui/core's Renderer.handleError), which
172+
// suppresses Bun's default print-and-exit. Combined with raw-mode stdin
173+
// holding the event loop open, an escaped throw would otherwise hang the
174+
// process forever with the terminal still in the alternate screen. Node
175+
// invokes every registered listener for the event regardless of order, so
176+
// these still run and terminate the process even though OpenTUI's own
177+
// listener never exits or rethrows.
178+
export function installCrashHandlers(): void {
136179
process.on("uncaughtException", (err) => {
137180
void handleFatal("uncaughtException", err);
138181
});
139182
process.on("unhandledRejection", (reason) => {
140183
void handleFatal("unhandledRejection", reason);
141184
});
185+
}
186+
187+
if (import.meta.main) {
188+
installCrashHandlers();
142189

143190
let code: number;
144191
try {

src/session/active-run.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// A module-level slot the top-level uncaughtException/unhandledRejection
2+
// handler (src/index.ts) can reach even though persistRunSnapshot is a
3+
// closure local to runTUI. Only ever consulted from the crash path: a run
4+
// that never crashes never has this read.
5+
//
6+
// Carries enough of the live run state (task, startedAt, model) that the
7+
// crash handler can build a full RunState record itself. It must not read
8+
// run.json back off disk to fill these in — an unbounded readFile on the
9+
// crash path has the exact failure mode primeCrashReporting (src/crash/
10+
// report.ts) exists to avoid for git: a stalled disk or network mount would
11+
// block process.exit forever.
12+
export type RunStateHandle = {
13+
sessionId: string;
14+
cwd: string;
15+
active: boolean;
16+
task: string;
17+
startedAt: number;
18+
model?: string;
19+
};
20+
21+
let activeRun: RunStateHandle | null = null;
22+
23+
export function setActiveRun(handle: RunStateHandle): void {
24+
activeRun = handle;
25+
}
26+
27+
export function clearActiveRun(): void {
28+
activeRun = null;
29+
}
30+
31+
export function getActiveRun(): RunStateHandle | null {
32+
return activeRun;
33+
}
34+
35+
// Set once, by the crash handler, immediately before it writes the terminal
36+
// "crashed" record. saveState (src/session/state.ts) reads this synchronously
37+
// right before each queued write actually fires, so any snapshot write still
38+
// waiting behind another one in its per-session chain sees the flag and
39+
// no-ops instead of firing after (and clobbering) the crash write. It cannot
40+
// stop a write whose writeFile/rename has already been dispatched to the
41+
// kernel at the moment the flag flips — that window is one atomicWrite call
42+
// wide, not the full remaining lifetime of the process.
43+
let crashed = false;
44+
45+
export function markCrashed(): void {
46+
crashed = true;
47+
}
48+
49+
export function isCrashed(): boolean {
50+
return crashed;
51+
}
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: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
44
import { type } from "arktype";
55

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

910
const ConnectedMcpServerSchema = type({
@@ -14,7 +15,7 @@ const ConnectedMcpServerSchema = type({
1415
export type ConnectedMcpServer = typeof ConnectedMcpServerSchema.infer;
1516

1617
const RunStateSchema = type({
17-
status: "'running' | 'done' | 'failed' | 'cancelled'",
18+
status: "'running' | 'done' | 'failed' | 'cancelled' | 'crashed'",
1819
turnsUsed: "number",
1920
task: "string",
2021
startedAt: "number",
@@ -65,6 +66,22 @@ export function warnUnreadableState(path: string, reason: string): void {
6566
// only ever address one file per session.
6667
const writeChains = new Map<string, Promise<void>>();
6768

69+
// Checked right before a chained write actually fires (not at saveState()
70+
// call time) so a snapshot write still queued behind another one, at the
71+
// moment the crash handler flips this flag, sees it and no-ops instead of
72+
// landing after (and clobbering) the crash write issued via saveCrashState.
73+
// This cannot recall a write whose writeFile/rename has already been
74+
// dispatched to the kernel — that residual window is one atomicWrite call
75+
// wide (a small local JSON write), not the remaining lifetime of the process.
76+
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;
81+
if (isCrashed()) return;
82+
await atomicWrite(path, content);
83+
}
84+
6885
export async function saveState(
6986
cwd: string,
7087
sessionId: string,
@@ -75,8 +92,8 @@ export async function saveState(
7592
const content = JSON.stringify(state, null, 2);
7693
const previous = writeChains.get(sessionId) ?? Promise.resolve();
7794
const write = previous.then(
78-
() => atomicWrite(path, content),
79-
() => atomicWrite(path, content),
95+
() => atomicWriteUnlessCrashed(path, content),
96+
() => atomicWriteUnlessCrashed(path, content),
8097
);
8198
// Swallow the error in the chain tail (not in `write`, which still rejects
8299
// for this caller) so one failed save doesn't permanently wedge later
@@ -92,6 +109,23 @@ export async function saveState(
92109
}
93110

94111

112+
// Crash-time terminal write. Deliberately bypasses writeChains: a hung or
113+
// still-pending write for this session (possibly the very write mid-flight
114+
// when the process crashed) must never be awaited here, or a queued write
115+
// that never settles would block the crash handler's process.exit forever.
116+
// Callers must call markCrashed() (src/session/active-run.ts) before this, so
117+
// any snapshot write still queued behind another one in the chain steps
118+
// aside instead of racing this write's rename().
119+
export async function saveCrashState(
120+
cwd: string,
121+
sessionId: string,
122+
state: RunState,
123+
home?: string,
124+
): Promise<void> {
125+
const path = statePath(cwd, sessionId, home);
126+
await atomicWrite(path, JSON.stringify(state, null, 2));
127+
}
128+
95129
// Returns the parsed state, or the arktype error summary when the shape is
96130
// invalid, so callers can surface a specific reason rather than "invalid shape".
97131
function parseRunState(data: unknown): RunState | { error: string } {

src/tui/runner.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ import { createRunSink } from "../session/run-sink.js";
163163
import { generateSessionId, initSessionDir, renameSession, sessionContextDir, sessionDir } from "../session/index.js";
164164
import { resolveSessionLabel, truncateSessionLabel } from "../session/session-label.js";
165165
import { loadState, saveState, type ConnectedMcpServer, type RunState } from "../session/state.js";
166+
import { setActiveRun, clearActiveRun, type RunStateHandle } from "../session/active-run.js";
166167
import { openInBrowser } from "../auth/oauth/browser.js";
167168
import { pickSession } from "./pick-session.js";
168169
import { RESUME_TRANSCRIPT_BLOCK_LIMIT, turnsToContentBlocks } from "./turns-to-blocks.js";
@@ -470,6 +471,22 @@ export async function runTUI(initialConfig: Config): Promise<number> {
470471
mcpServers: resumeSeed.mcpServers,
471472
});
472473

474+
// Registered the moment a run starts so the top-level uncaughtException /
475+
// unhandledRejection handler in index.ts (which cannot see any local state
476+
// in this function) can finalize run.json for crashes that escape without
477+
// ever reaching this function's own try/catch — e.g. a throw inside a
478+
// fire-and-forget `void` call. Cleared wherever `finalized` below flips
479+
// true, since those paths already write a terminal run.json themselves.
480+
const activeRunHandle: RunStateHandle = {
481+
sessionId,
482+
cwd: config.cwd,
483+
active: true,
484+
task: runTaskTitle.trim().length > 0 ? runTaskTitle.trim() : "(conversation)",
485+
startedAt,
486+
model: `${config.providerName}:${config.model}`,
487+
};
488+
setActiveRun(activeRunHandle);
489+
473490
// Crash guard: if anything from setup onward throws all the way out of
474491
// runTUI instead of reaching the normal finalize block, this still closes
475492
// out run.json so status and finishedAt never disagree. Declared before the
@@ -496,6 +513,8 @@ export async function runTUI(initialConfig: Config): Promise<number> {
496513
const finalizeOnCrash = async (err: unknown): Promise<void> => {
497514
if (finalized) return;
498515
finalized = true;
516+
activeRunHandle.active = false;
517+
clearActiveRun();
499518
await flushPartialOnCrash().catch((flushErr: unknown) => {
500519
// Best-effort only — still attempt saveState below. Log so a flush
501520
// failure is not invisible when diagnosing a crash exit.
@@ -1375,12 +1394,19 @@ export async function runTUI(initialConfig: Config): Promise<number> {
13751394
status: RunState["status"],
13761395
extra?: Pick<RunState, "finishedAt" | "error">,
13771396
): Promise<void> => {
1397+
const task = runTaskTitle.trim().length > 0 ? runTaskTitle.trim() : "(conversation)";
1398+
const model = `${liveSource.id}:${liveSource.model}`;
1399+
// Kept in step with every persisted snapshot so the crash handler's copy
1400+
// (activeRunHandle, read by index.ts) never lags what's actually on disk.
1401+
activeRunHandle.task = task;
1402+
activeRunHandle.startedAt = startedAt;
1403+
activeRunHandle.model = model;
13781404
await saveState(config.cwd, sessionId, {
13791405
status,
13801406
turnsUsed: runSink.getTurnCount(),
1381-
task: runTaskTitle.trim().length > 0 ? runTaskTitle.trim() : "(conversation)",
1407+
task,
13821408
startedAt,
1383-
model: `${liveSource.id}:${liveSource.model}`,
1409+
model,
13841410
mcpServers: connectedMcpServers,
13851411
...extra,
13861412
});
@@ -1640,6 +1666,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
16401666
});
16411667
await persistRunSnapshot("done", { finishedAt: Date.now() });
16421668
sessionId = generateSessionId();
1669+
activeRunHandle.sessionId = sessionId;
16431670
startedAt = Date.now();
16441671
runTaskTitle = config.task;
16451672
emitter.emit("session.title", runTaskTitle.trim().length > 0 ? truncateSessionLabel(runTaskTitle) : "Untitled session");
@@ -2254,6 +2281,8 @@ export async function runTUI(initialConfig: Config): Promise<number> {
22542281
// finished run (finishedAt set) can be left reading as still in progress.
22552282
const persistedStatus: RunState["status"] = summaryStatus;
22562283
finalized = true;
2284+
activeRunHandle.active = false;
2285+
clearActiveRun();
22572286
await writeRunSnapshot(persistedStatus, {
22582287
finishedAt,
22592288
...(sinkError !== undefined ? { error: sinkError } : {}),
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Spawned as a subprocess by tests/integration/crash-finalize.test.ts. Mimics
2+
// what runTUI does at startup (register the active run, write the initial
3+
// "running" run.json) and what index.ts does at process entry (install the
4+
// crash handlers), then throws asynchronously so it surfaces as a genuine
5+
// uncaughtException rather than a synchronous throw the caller could catch.
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 { saveState } from "../../../src/session/state.js";
10+
11+
const cwd = process.cwd();
12+
const sessionId = process.env["CRASH_TEST_SESSION_ID"];
13+
if (sessionId === undefined) {
14+
throw new Error("CRASH_TEST_SESSION_ID must be set");
15+
}
16+
17+
const startedAt = Date.now();
18+
const task = "simulated crash 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, active: true, task, startedAt, model });
30+
installCrashHandlers();
31+
32+
process.stdout.write(`${sessionDir(cwd, sessionId)}\n`);
33+
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 });
51+
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.
60+
setImmediate(() => {
61+
throw new Error("simulated crash");
62+
});
63+
setImmediate(() => {
64+
releaseGate();
65+
});

0 commit comments

Comments
 (0)