@@ -2,6 +2,7 @@ import { getLogger } from "@intx/log";
22import { LOG_NAMESPACE_ROOT } from "./branding.js" ;
33import { primeCrashReporting , writeCrashReport , type CrashKind } from "./crash/report.js" ;
44import { getActiveRun , markCrashed } from "./session/active-run.js" ;
5+ import { getActiveDisposeHost } from "./session/active-host.js" ;
56import { saveCrashState } from "./session/state.js" ;
67import { loadConfig } from "./config/index.js" ;
78import { ensureTelemetrySettings , globalSettingsPath } from "./config/settings.js" ;
@@ -115,9 +116,30 @@ export async function main(argv: readonly string[]): Promise<number> {
115116 } ) ;
116117}
117118
119+ // Shared by handleFatal and the signal handlers below so a signal arriving
120+ // mid-crash-unwind (or a crash surfacing while a signal is already tearing
121+ // the process down) can't re-enter either path a second time.
122+ let terminating = false ;
123+
118124// Exported so an integration test can register these process-level handlers
119125// and inject a crash without spawning the full TUI stack.
120126export async function handleFatal ( kind : CrashKind , error : unknown ) : Promise < void > {
127+ if ( terminating ) return ;
128+ terminating = true ;
129+ // OpenTUI's own uncaughtException/unhandledRejection listener only logs
130+ // (see installCrashHandlers' comment below) — it never tears down the
131+ // terminal the way its signal listener does. Without this, a throw that
132+ // escapes runTUI's own try/catch (e.g. inside a fire-and-forget `void`
133+ // call) leaves the alternate screen and raw mode stuck. disposeHost is
134+ // idempotent, so this is safe even if runTUI's own catch block already
135+ // ran it moments earlier.
136+ try {
137+ getActiveDisposeHost ( ) ?.( ) ;
138+ } catch ( disposeErr : unknown ) {
139+ process . stderr . write (
140+ `host dispose failed during fatal handling: ${ disposeErr instanceof Error ? disposeErr . message : String ( disposeErr ) } \n` ,
141+ ) ;
142+ }
121143 // Flip this before any awaits below so any snapshot write still queued
122144 // behind another one in state.ts's per-session chain sees it and steps
123145 // aside the moment it's next in line, rather than racing saveCrashState's
@@ -184,8 +206,80 @@ export function installCrashHandlers(): void {
184206 } ) ;
185207}
186208
209+ // Mirrors finalizeActiveRunOnCrash but is not itself a crash — a signal is a
210+ // clean, externally-requested termination (operator, shell, orchestrator),
211+ // so the run is left "failed" (interrupted) rather than "crashed", and no
212+ // crash report is written for it.
213+ async function finalizeActiveRunOnSignal ( signal : NodeJS . Signals ) : Promise < void > {
214+ const run = getActiveRun ( ) ;
215+ if ( run === null || ! run . active ) return ;
216+ try {
217+ await saveCrashState ( run . cwd , run . sessionId , {
218+ status : "failed" ,
219+ turnsUsed : 0 ,
220+ task : run . task ,
221+ startedAt : run . startedAt ,
222+ finishedAt : Date . now ( ) ,
223+ error : `terminated by ${ signal } ` ,
224+ ...( run . model !== undefined ? { model : run . model } : { } ) ,
225+ } ) ;
226+ } catch ( saveErr : unknown ) {
227+ process . stderr . write (
228+ `failed to finalize run state after ${ signal } : ${ saveErr instanceof Error ? saveErr . message : String ( saveErr ) } \n` ,
229+ ) ;
230+ }
231+ }
232+
233+ const SIGNAL_EXIT_NUMBER : Record < "SIGINT" | "SIGTERM" | "SIGHUP" , number > = {
234+ SIGHUP : 1 ,
235+ SIGINT : 2 ,
236+ SIGTERM : 15 ,
237+ } ;
238+
239+ // Bun's tty raw mode (which the TUI runs under for its whole session) clears
240+ // ISIG, so a real terminal's Ctrl+C never reaches this handler while a
241+ // session is interactive — confirmed empirically (see the raw-mode SIGINT
242+ // regression test) rather than assumed. The in-session double-tap-to-quit
243+ // gesture (shell.ts, CTRL_C_EXIT_WINDOW_MS) is therefore untouched by this
244+ // handler; it owns Ctrl+C exclusively for the interactive case. This handler
245+ // exists for the signal actually reaching the process: external
246+ // orchestration (kill, systemd, docker stop), or a terminal that never
247+ // entered raw mode at all (exec mode has no TUI host and no raw stdin, so
248+ // its Ctrl+C is a real SIGINT today with no listener at all — Bun's default
249+ // disposition kills it immediately without a chance to close out run.json).
250+ //
251+ // Terminal restore is done directly here, the same way handleFatal does it,
252+ // rather than left to OpenTUI's own same-signal listener (registered later,
253+ // at host-mount time, once a TUI is actually running): relying on a
254+ // vendored listener's registration order and internal behavior to already
255+ // cover teardown would make correctness depend on undocumented @opentui
256+ // internals that could change on any version bump, with terminal-left-wedged
257+ // as the silent failure mode. disposeHost is idempotent, so calling it here
258+ // even when OpenTUI's own listener also runs is harmless.
259+ // Exported so an integration test can register these process-level handlers
260+ // and send a real signal without spawning the full TUI stack.
261+ export function installSignalHandlers ( ) : void {
262+ for ( const signal of [ "SIGINT" , "SIGTERM" , "SIGHUP" ] as const ) {
263+ process . on ( signal , ( ) => {
264+ if ( terminating ) return ;
265+ terminating = true ;
266+ try {
267+ getActiveDisposeHost ( ) ?.( ) ;
268+ } catch ( disposeErr : unknown ) {
269+ process . stderr . write (
270+ `host dispose failed handling ${ signal } : ${ disposeErr instanceof Error ? disposeErr . message : String ( disposeErr ) } \n` ,
271+ ) ;
272+ }
273+ void finalizeActiveRunOnSignal ( signal ) . finally ( ( ) => {
274+ process . exit ( 128 + SIGNAL_EXIT_NUMBER [ signal ] ) ;
275+ } ) ;
276+ } ) ;
277+ }
278+ }
279+
187280if ( import . meta. main ) {
188281 installCrashHandlers ( ) ;
282+ installSignalHandlers ( ) ;
189283
190284 let code : number ;
191285 try {
0 commit comments