|
1 | 1 | import { getLogger } from "@intx/log"; |
2 | 2 | import { LOG_NAMESPACE_ROOT } from "./branding.js"; |
3 | 3 | 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"; |
4 | 6 | import { loadConfig } from "./config/index.js"; |
5 | 7 | import { ensureTelemetrySettings, globalSettingsPath } from "./config/settings.js"; |
6 | 8 | import { installFileLogSink } from "./logging/sink.js"; |
@@ -113,32 +115,77 @@ export async function main(argv: readonly string[]): Promise<number> { |
113 | 115 | }); |
114 | 116 | } |
115 | 117 |
|
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(); |
117 | 127 | process.stderr.write(`${kind}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`); |
118 | 128 | const file = await writeCrashReport(kind, error); |
119 | 129 | if (file !== null) { |
120 | 130 | process.stderr.write(`crash report written to ${file}\n`); |
121 | 131 | } else { |
122 | 132 | process.stderr.write("failed to write crash report\n"); |
123 | 133 | } |
| 134 | + await finalizeActiveRunOnCrash(error); |
124 | 135 | process.exit(1); |
125 | 136 | } |
126 | 137 |
|
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 { |
136 | 179 | process.on("uncaughtException", (err) => { |
137 | 180 | void handleFatal("uncaughtException", err); |
138 | 181 | }); |
139 | 182 | process.on("unhandledRejection", (reason) => { |
140 | 183 | void handleFatal("unhandledRejection", reason); |
141 | 184 | }); |
| 185 | +} |
| 186 | + |
| 187 | +if (import.meta.main) { |
| 188 | + installCrashHandlers(); |
142 | 189 |
|
143 | 190 | let code: number; |
144 | 191 | try { |
|
0 commit comments