|
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 } from "./session/active-run.js"; |
| 5 | +import { loadState, 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 { flushPerfToOtel } from "./perf/index.js"; |
@@ -106,32 +108,69 @@ export async function main(argv: readonly string[]): Promise<number> { |
106 | 108 | }); |
107 | 109 | } |
108 | 110 |
|
109 | | -async function handleFatal(kind: CrashKind, error: unknown): Promise<void> { |
| 111 | +// Exported so an integration test can register these process-level handlers |
| 112 | +// and inject a crash without spawning the full TUI stack. |
| 113 | +export async function handleFatal(kind: CrashKind, error: unknown): Promise<void> { |
110 | 114 | process.stderr.write(`${kind}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`); |
111 | 115 | const file = await writeCrashReport(kind, error); |
112 | 116 | if (file !== null) { |
113 | 117 | process.stderr.write(`crash report written to ${file}\n`); |
114 | 118 | } else { |
115 | 119 | process.stderr.write("failed to write crash report\n"); |
116 | 120 | } |
| 121 | + await finalizeActiveRunOnCrash(error); |
117 | 122 | process.exit(1); |
118 | 123 | } |
119 | 124 |
|
120 | | -if (import.meta.main) { |
121 | | - // OpenTUI installs a process-global uncaughtException/unhandledRejection |
122 | | - // handler that only logs (opentui/core's Renderer.handleError), which |
123 | | - // suppresses Bun's default print-and-exit. Combined with raw-mode stdin |
124 | | - // holding the event loop open, an escaped throw would otherwise hang the |
125 | | - // process forever with the terminal still in the alternate screen. Node |
126 | | - // invokes every registered listener for the event regardless of order, so |
127 | | - // these still run and terminate the process even though OpenTUI's own |
128 | | - // listener never exits or rethrows. |
| 125 | +// A crash reaching here escaped without ever hitting runTUI's own try/catch |
| 126 | +// (e.g. a throw inside a fire-and-forget `void` call), so run.json was never |
| 127 | +// closed out. getActiveRun surfaces the in-flight session set by runTUI; the |
| 128 | +// write itself goes through saveCrashState, which bypasses the per-session |
| 129 | +// write chain in state.ts on purpose — chaining behind a write that never |
| 130 | +// settles (possibly the very write that triggered this crash) would block |
| 131 | +// process.exit indefinitely, defeating this handler's one job. |
| 132 | +async function finalizeActiveRunOnCrash(error: unknown): Promise<void> { |
| 133 | + const run = getActiveRun(); |
| 134 | + if (run === null || !run.active) return; |
| 135 | + const message = error instanceof Error ? error.message : String(error); |
| 136 | + try { |
| 137 | + const prior = await loadState(run.cwd, run.sessionId); |
| 138 | + await saveCrashState(run.cwd, run.sessionId, { |
| 139 | + status: "crashed", |
| 140 | + turnsUsed: prior?.turnsUsed ?? 0, |
| 141 | + task: prior?.task ?? "(conversation)", |
| 142 | + startedAt: prior?.startedAt ?? Date.now(), |
| 143 | + finishedAt: Date.now(), |
| 144 | + error: message, |
| 145 | + ...(prior?.model !== undefined ? { model: prior.model } : {}), |
| 146 | + ...(prior?.mcpServers !== undefined ? { mcpServers: prior.mcpServers } : {}), |
| 147 | + }); |
| 148 | + } catch (saveErr: unknown) { |
| 149 | + process.stderr.write( |
| 150 | + `failed to finalize run state after crash: ${saveErr instanceof Error ? saveErr.message : String(saveErr)}\n`, |
| 151 | + ); |
| 152 | + } |
| 153 | +} |
| 154 | + |
| 155 | +// OpenTUI installs a process-global uncaughtException/unhandledRejection |
| 156 | +// handler that only logs (opentui/core's Renderer.handleError), which |
| 157 | +// suppresses Bun's default print-and-exit. Combined with raw-mode stdin |
| 158 | +// holding the event loop open, an escaped throw would otherwise hang the |
| 159 | +// process forever with the terminal still in the alternate screen. Node |
| 160 | +// invokes every registered listener for the event regardless of order, so |
| 161 | +// these still run and terminate the process even though OpenTUI's own |
| 162 | +// listener never exits or rethrows. |
| 163 | +export function installCrashHandlers(): void { |
129 | 164 | process.on("uncaughtException", (err) => { |
130 | 165 | void handleFatal("uncaughtException", err); |
131 | 166 | }); |
132 | 167 | process.on("unhandledRejection", (reason) => { |
133 | 168 | void handleFatal("unhandledRejection", reason); |
134 | 169 | }); |
| 170 | +} |
| 171 | + |
| 172 | +if (import.meta.main) { |
| 173 | + installCrashHandlers(); |
135 | 174 |
|
136 | 175 | let code: number; |
137 | 176 | try { |
|
0 commit comments