Skip to content

Commit 64e85dc

Browse files
Merge pull request #406 from corbitsdev/cl-5552-own-process-termination
Own process termination on detached throws and OS signals
2 parents 662a3db + 0680866 commit 64e85dc

10 files changed

Lines changed: 353 additions & 0 deletions

File tree

src/index.ts

Lines changed: 94 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
@@ -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+
187280
if (import.meta.main) {
188281
installCrashHandlers();
282+
installSignalHandlers();
189283

190284
let code: number;
191285
try {

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-opentui/product-host.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,10 @@ export async function mountProductHost(
296296
const renderer = config.createRenderer
297297
? await config.createRenderer()
298298
: await createCliRenderer({
299+
// Leaves Ctrl+C entirely to shell.ts's own double-tap-to-quit
300+
// gesture (CTRL_C_EXIT_WINDOW_MS). index.ts's SIGINT handler also
301+
// depends on this staying false: Ctrl+C only reaches it as a real
302+
// OS signal when nothing already consumed it as a keypress.
299303
exitOnCtrlC: false,
300304
targetFps: 30,
301305
// Mouse reporting on by default: without it, wheel/trackpad scroll

src/tui/runner.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,7 @@ import { generateSessionId, initSessionDir, renameSession, sessionContextDir, se
176176
import { resolveSessionLabel, truncateSessionLabel } from "../session/session-label.js";
177177
import { loadState, saveState, type ConnectedMcpServer, type RunState } from "../session/state.js";
178178
import { setActiveRun, clearActiveRun, type RunStateHandle } from "../session/active-run.js";
179+
import { setActiveDisposeHost, clearActiveDisposeHost } from "../session/active-host.js";
179180
import { openInBrowser } from "../auth/oauth/browser.js";
180181
import { pickSession } from "./pick-session.js";
181182
import { RESUME_TRANSCRIPT_BLOCK_LIMIT, turnsToContentBlocks } from "./turns-to-blocks.js";
@@ -538,6 +539,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
538539
finalized = true;
539540
activeRunHandle.active = false;
540541
clearActiveRun();
542+
clearActiveDisposeHost();
541543
await flushPartialOnCrash().catch((flushErr: unknown) => {
542544
// Best-effort only — still attempt saveState below. Log so a flush
543545
// failure is not invisible when diagnosing a crash exit.
@@ -2263,6 +2265,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
22632265
});
22642266

22652267
disposeHost = host.dispose;
2268+
setActiveDisposeHost(disposeHost);
22662269

22672270
setMentionSuggestionSource(host.shell, (prefix) => listPathSuggestions(prefix, config.cwd));
22682271

@@ -2381,6 +2384,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
23812384
finalized = true;
23822385
activeRunHandle.active = false;
23832386
clearActiveRun();
2387+
clearActiveDisposeHost();
23842388
await writeRunSnapshot(persistedStatus, {
23852389
finishedAt,
23862390
...(sinkError !== undefined ? { error: sinkError } : {}),
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Spawned as a subprocess by tests/integration/signal-finalize.test.ts.
2+
// Mimics what runTUI does at startup (register the active run, write the
3+
// initial "running" run.json) and what index.ts does at process entry
4+
// (install the signal handlers), then waits to receive a real signal sent by
5+
// the test from outside the process.
6+
import { installSignalHandlers } from "../../../src/index.js";
7+
import { setActiveRun } 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["SIGNAL_TEST_SESSION_ID"];
13+
if (sessionId === undefined) {
14+
throw new Error("SIGNAL_TEST_SESSION_ID must be set");
15+
}
16+
17+
const startedAt = Date.now();
18+
const task = "simulated signal 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+
installSignalHandlers();
31+
32+
process.stdout.write(`${sessionDir(cwd, sessionId)}\n`);
33+
process.stdout.write("ready\n");
34+
35+
// Keep the event loop alive until the test sends a signal.
36+
setInterval(() => {}, 60_000);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Fixture for tests/integration/rawmode-sigint.test.ts. Proves, on the real
2+
// Bun runtime rather than by assumption, whether a Ctrl+C keypress (0x03)
3+
// generates a SIGINT deliverable to process.on("SIGINT") while stdin is in
4+
// raw mode — the empirical claim src/index.ts's installSignalHandlers
5+
// depends on to leave the in-session double-tap-to-quit gesture untouched.
6+
process.stdin.setRawMode(true);
7+
process.on("SIGINT", () => {
8+
process.stdout.write("GOT_SIGINT\n");
9+
process.exit(0);
10+
});
11+
process.stdin.resume();
12+
process.stdin.on("data", (chunk: Buffer) => {
13+
if (chunk.includes(0x03)) process.stdout.write("GOT_CTRL_C_BYTE\n");
14+
});
15+
setTimeout(() => {
16+
process.stdout.write("NO_SIGINT_ON_CTRL_C\n");
17+
process.exit(0);
18+
}, 3000);
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env python3
2+
# Drives tests/fixtures/rawmode-sigint/probe.ts inside a real forked pty
3+
# (stdlib `pty`/`os`/`select`, no third-party deps) so the fixture's stdin
4+
# is a genuine tty rather than a pipe -- `setRawMode` only has the raw-mode
5+
# vs. cooked-mode distinction this test cares about on a real tty.
6+
import os
7+
import pty
8+
import select
9+
import sys
10+
import time
11+
12+
def main() -> int:
13+
probe_path = sys.argv[1]
14+
pid, fd = pty.fork()
15+
if pid == 0:
16+
os.execvp("bun", ["bun", "run", probe_path])
17+
os._exit(127)
18+
19+
time.sleep(1)
20+
os.write(fd, b"\x03")
21+
22+
out = b""
23+
deadline = time.time() + 4
24+
while time.time() < deadline:
25+
ready, _, _ = select.select([fd], [], [], 0.5)
26+
if fd not in ready:
27+
continue
28+
try:
29+
chunk = os.read(fd, 4096)
30+
except OSError:
31+
break
32+
if not chunk:
33+
break
34+
out += chunk
35+
if b"NO_SIGINT_ON_CTRL_C" in out or b"GOT_SIGINT" in out:
36+
break
37+
38+
try:
39+
os.kill(pid, 9)
40+
except OSError:
41+
pass
42+
os.waitpid(pid, 0)
43+
44+
sys.stdout.write(out.decode(errors="replace"))
45+
return 0
46+
47+
if __name__ == "__main__":
48+
raise SystemExit(main())
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
// src/index.ts's installSignalHandlers relies on an empirical claim: Bun's
4+
// stdin.setRawMode(true) clears ISIG on this platform, so a real Ctrl+C
5+
// keypress never reaches process.on("SIGINT") during an interactive TUI
6+
// session -- only out-of-band kill(2) signals do. If a future Bun upgrade
7+
// changes that, the in-session double-tap-to-quit gesture (shell.ts,
8+
// CTRL_C_EXIT_WINDOW_MS) would silently start racing a process-level exit
9+
// on the very first Ctrl+C. This test pins the assumption against a real
10+
// forked pty rather than trusting it to hold forever.
11+
describe("integration — raw-mode stdin and SIGINT", () => {
12+
test("Ctrl+C is delivered as a stdin byte, not as SIGINT, while raw mode is active", async () => {
13+
const probe = new URL("../fixtures/rawmode-sigint/probe.ts", import.meta.url).pathname;
14+
const driver = new URL("../fixtures/rawmode-sigint/pty_probe.py", import.meta.url).pathname;
15+
16+
const proc = Bun.spawn(["python3", driver, probe], {
17+
stdout: "pipe",
18+
stderr: "pipe",
19+
});
20+
const [stdout, exitCode] = await Promise.all([
21+
new Response(proc.stdout).text(),
22+
proc.exited,
23+
]);
24+
25+
expect(exitCode).toBe(0);
26+
expect(stdout).toContain("GOT_CTRL_C_BYTE");
27+
expect(stdout).toContain("NO_SIGINT_ON_CTRL_C");
28+
expect(stdout).not.toContain("GOT_SIGINT");
29+
}, 15000);
30+
});

0 commit comments

Comments
 (0)