Skip to content

Commit f3ca332

Browse files
committed
Restore the terminal on a detached throw before exiting
A throw that escapes runTUI's own try/catch (e.g. inside a fire-and-forget void call) only reached the top-level uncaughtException/unhandledRejection handler, which had no way to call runTUI's terminal-restore routine since it lives as a closure bound only once the OpenTUI host mounts. The process exited with the terminal left in the alternate screen and raw mode still on. A module-level slot mirroring the existing active-run.ts pattern lets the handler reach it.
1 parent 3d1bc83 commit f3ca332

4 files changed

Lines changed: 78 additions & 0 deletions

File tree

src/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getLogger } from "@intx/log";
22
import { LOG_NAMESPACE_ROOT } from "./branding.js";
33
import { primeCrashReporting, writeCrashReport, type CrashKind } from "./crash/report.js";
44
import { getActiveRun, markCrashed } from "./session/active-run.js";
5+
import { getActiveDisposeHost } from "./session/active-host.js";
56
import { saveCrashState } from "./session/state.js";
67
import { loadConfig } from "./config/index.js";
78
import { 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.
120126
export 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

src/session/active-host.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
3+
import { clearActiveDisposeHost, getActiveDisposeHost, setActiveDisposeHost } from "./active-host.js";
4+
5+
describe("active-host", () => {
6+
afterEach(() => {
7+
clearActiveDisposeHost();
8+
});
9+
10+
test("starts with no active dispose handle", () => {
11+
expect(getActiveDisposeHost()).toBeNull();
12+
});
13+
14+
test("returns the handle set by setActiveDisposeHost", () => {
15+
const disposeHost = () => {};
16+
setActiveDisposeHost(disposeHost);
17+
expect(getActiveDisposeHost()).toBe(disposeHost);
18+
});
19+
20+
test("clearActiveDisposeHost removes the handle", () => {
21+
setActiveDisposeHost(() => {});
22+
clearActiveDisposeHost();
23+
expect(getActiveDisposeHost()).toBeNull();
24+
});
25+
26+
test("setActiveDisposeHost overwrites a previously set handle", () => {
27+
setActiveDisposeHost(() => {});
28+
const second = () => {};
29+
setActiveDisposeHost(second);
30+
expect(getActiveDisposeHost()).toBe(second);
31+
});
32+
});

src/session/active-host.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// A module-level slot mirroring active-run.ts's pattern: the top-level
2+
// process handlers in src/index.ts (a detached-throw handler today, a signal
3+
// handler alongside it) need to reach runTUI's terminal-restore routine even
4+
// though it is a closure local to runTUI, bound only once the OpenTUI host
5+
// has mounted. Cleared the moment runTUI itself finalizes (normally or via
6+
// its own crash path) so a signal arriving after teardown has nothing left
7+
// to call.
8+
let activeDisposeHost: (() => void) | null = null;
9+
10+
export function setActiveDisposeHost(disposeHost: () => void): void {
11+
activeDisposeHost = disposeHost;
12+
}
13+
14+
export function clearActiveDisposeHost(): void {
15+
activeDisposeHost = null;
16+
}
17+
18+
export function getActiveDisposeHost(): (() => void) | null {
19+
return activeDisposeHost;
20+
}

src/tui/runner.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ import { generateSessionId, initSessionDir, renameSession, sessionContextDir, se
167167
import { resolveSessionLabel, truncateSessionLabel } from "../session/session-label.js";
168168
import { loadState, saveState, type ConnectedMcpServer, type RunState } from "../session/state.js";
169169
import { setActiveRun, clearActiveRun, type RunStateHandle } from "../session/active-run.js";
170+
import { setActiveDisposeHost, clearActiveDisposeHost } from "../session/active-host.js";
170171
import { openInBrowser } from "../auth/oauth/browser.js";
171172
import { pickSession } from "./pick-session.js";
172173
import { RESUME_TRANSCRIPT_BLOCK_LIMIT, turnsToContentBlocks } from "./turns-to-blocks.js";
@@ -529,6 +530,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
529530
finalized = true;
530531
activeRunHandle.active = false;
531532
clearActiveRun();
533+
clearActiveDisposeHost();
532534
await flushPartialOnCrash().catch((flushErr: unknown) => {
533535
// Best-effort only — still attempt saveState below. Log so a flush
534536
// failure is not invisible when diagnosing a crash exit.
@@ -2192,6 +2194,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
21922194
});
21932195

21942196
disposeHost = host.dispose;
2197+
setActiveDisposeHost(disposeHost);
21952198

21962199
setMentionSuggestionSource(host.shell, (prefix) => listPathSuggestions(prefix, config.cwd));
21972200

@@ -2296,6 +2299,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
22962299
finalized = true;
22972300
activeRunHandle.active = false;
22982301
clearActiveRun();
2302+
clearActiveDisposeHost();
22992303
await writeRunSnapshot(persistedStatus, {
23002304
finishedAt,
23012305
...(sinkError !== undefined ? { error: sinkError } : {}),

0 commit comments

Comments
 (0)