From 09b43b75813aa6d067512bb6abbcbecc8b1dd9c4 Mon Sep 17 00:00:00 2001 From: roboomp Date: Fri, 17 Jul 2026 11:53:11 +0000 Subject: [PATCH] fix(tui): exited when terminal disconnected Stopped the renderer when stdin closes or stdout fails, then raised SIGHUP so registered session cleanup completes before exit. Fixes #5835 --- packages/tui/CHANGELOG.md | 4 ++ packages/tui/README.md | 2 +- packages/tui/src/terminal.ts | 48 ++++++++++++++++--- packages/tui/src/tui.ts | 2 + .../test/process-terminal-render-harness.ts | 21 +++++++- .../tui/test/process-terminal-render.test.ts | 26 ++++++++++ 6 files changed, 94 insertions(+), 9 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 2f1ae373c..87e8521ba 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed interactive sessions surviving terminal closure and entering a runaway render loop by stopping the TUI and raising SIGHUP when terminal input closes or output fails ([#5835](https://github.com/can1357/oh-my-pi/issues/5835)). + ## [17.0.2] - 2026-07-17 ### Added diff --git a/packages/tui/README.md b/packages/tui/README.md index 6b0ae57f9..e32729d48 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -522,7 +522,7 @@ The TUI works with any object implementing the `Terminal` interface: ```typescript interface Terminal { - start(onInput: (data: string) => void, onResize: () => void): void; + start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void; stop(): void; write(data: string): void; get columns(): number; diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 795163531..aa640676a 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -337,8 +337,8 @@ export function emergencyTerminalRestore(): void { /** Terminal-reported appearance (dark/light mode). */ export type TerminalAppearance = "dark" | "light"; export interface Terminal { - // Start the terminal with input and resize handlers - start(onInput: (data: string) => void, onResize: () => void): void; + // Start the terminal with input, resize, and host-disconnect handlers. + start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void; // Stop the terminal and restore state stop(): void; @@ -483,6 +483,16 @@ export class ProcessTerminal implements Terminal { #modifyOtherKeysTimeout?: Timer; #stdinBuffer?: StdinBuffer; #stdinDataHandler?: (data: string) => void; + #disconnectHandler?: () => void; + #stdinEndHandler = () => { + this.#markTerminalDisconnected("stdin ended"); + }; + #stdinCloseHandler = () => { + this.#markTerminalDisconnected("stdin closed"); + }; + #stdinErrorHandler = (err: Error) => { + this.#markTerminalDisconnected("stdin failed", err); + }; #dead = false; // Captured at construction and re-read at start(): when true, every real // terminal side effect (writes, probes, raw mode, SIGWINCH, timers) is @@ -491,7 +501,7 @@ export class ProcessTerminal implements Terminal { #writeLogPath = $env.PI_TUI_WRITE_LOG || ""; #stdoutErrorCleanup?: () => void; #stdoutErrorHandler = (err: Error) => { - this.#markTerminalWriteFailed(err); + this.#markTerminalDisconnected("stdout failed", err); }; #windowsVTInputRestore?: () => void; @@ -577,9 +587,10 @@ export class ProcessTerminal implements Terminal { this.#privateModeCallbacks.push(callback); } - start(onInput: (data: string) => void, onResize: () => void): void { + start(onInput: (data: string) => void, onResize: () => void, onDisconnect?: () => void): void { this.#inputHandler = onInput; this.#resizeHandler = onResize; + this.#disconnectHandler = onDisconnect; // Headless (tests): suppress every real-terminal side effect. Skip raw // mode, stdin listeners, capability probes, SIGWINCH, and emergency-restore @@ -604,6 +615,9 @@ export class ProcessTerminal implements Terminal { process.stdin.setRawMode(true); } process.stdin.setEncoding("utf8"); + process.stdin.on("end", this.#stdinEndHandler); + process.stdin.on("close", this.#stdinCloseHandler); + process.stdin.on("error", this.#stdinErrorHandler); process.stdin.resume(); // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~ @@ -1425,6 +1439,10 @@ export class ProcessTerminal implements Terminal { process.stdin.removeListener("data", this.#stdinDataHandler); this.#stdinDataHandler = undefined; } + process.stdin.removeListener("end", this.#stdinEndHandler); + process.stdin.removeListener("close", this.#stdinCloseHandler); + process.stdin.removeListener("error", this.#stdinErrorHandler); + this.#disconnectHandler = undefined; this.#inputHandler = undefined; this.#appearance = undefined; if (this.#stdoutResizeListener) { @@ -1450,10 +1468,26 @@ export class ProcessTerminal implements Terminal { this.#stdoutErrorCleanup ??= registerStdoutErrorHandler(this.#stdoutErrorHandler); } - #markTerminalWriteFailed(err: unknown): void { + #markTerminalDisconnected(reason: string, err?: unknown): void { if (this.#dead) return; this.#dead = true; - logger.warn("terminal write failed; disabling terminal rendering", { err }); + logger.warn("terminal disconnected; stopping interactive rendering", { reason, err }); + + const disconnectHandler = this.#disconnectHandler; + this.#disconnectHandler = undefined; + if (!disconnectHandler) return; + disconnectHandler(); + + if (process.platform === "win32") { + void postmortem.quit(129); + return; + } + try { + process.kill(process.pid, "SIGHUP"); + } catch (signalErr) { + logger.error("Failed to deliver terminal disconnect signal; exiting directly", { err: signalErr }); + void postmortem.quit(129); + } } write(data: string): void { @@ -1500,7 +1534,7 @@ export class ProcessTerminal implements Terminal { process.stdout.write(data); } } catch (err) { - this.#markTerminalWriteFailed(err); + this.#markTerminalDisconnected("stdout failed", err); } } diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index d4c6bd30e..94e6b07ee 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -1571,7 +1571,9 @@ export class TUI extends Container { } this.#armMultiplexerResizeTimer(false); }, + () => this.stop(), ); + if (this.#stopped) return; for (const listener of this.#startListeners) { try { listener(); diff --git a/packages/tui/test/process-terminal-render-harness.ts b/packages/tui/test/process-terminal-render-harness.ts index af8d6e760..9f4082e91 100644 --- a/packages/tui/test/process-terminal-render-harness.ts +++ b/packages/tui/test/process-terminal-render-harness.ts @@ -43,6 +43,8 @@ export interface ProcessTerminalRenderHarness { readonly probe: WidthProbe; /** Raw bytes the TUI wrote to stdout, in order. */ readonly writes: string[]; + /** Signals the terminal requested from the host process, in order. */ + readonly signals: Array<{ pid: number; signal: string | number | undefined }>; /** Wait for the render scheduler to flush any pending paint. */ settle(): Promise; /** Simulate an OS resize (SIGWINCH / ConPTY): refresh stdout dims, fire `resize`. */ @@ -51,6 +53,10 @@ export interface ProcessTerminalRenderHarness { inBand(rows: number, columns: number, yPixels?: number, xPixels?: number): Promise; /** Feed raw byte chunks through the real stdin pipeline (StdinBuffer reassembly included). */ feed(...chunks: string[]): Promise; + /** End stdin as a terminal host does when its pane disappears. */ + endInput(): Promise; + /** Fail stdout as a revoked terminal descriptor does on write. */ + failOutput(): Promise; dispose(): void; } @@ -82,8 +88,12 @@ export function createProcessTerminalRenderHarness( Object.defineProperty(process.stdout, "rows", { value: initialRows, configurable: true }); const writes: string[] = []; + const signals: Array<{ pid: number; signal: string | number | undefined }> = []; const spies = [ - vi.spyOn(process, "kill").mockReturnValue(true), + vi.spyOn(process, "kill").mockImplementation((pid, signal) => { + signals.push({ pid, signal }); + return true; + }), vi.spyOn(process.stdin, "resume").mockImplementation(() => process.stdin), vi.spyOn(process.stdin, "pause").mockImplementation(() => process.stdin), vi.spyOn(process.stdin, "setEncoding").mockImplementation(() => process.stdin), @@ -112,6 +122,7 @@ export function createProcessTerminalRenderHarness( tui, probe, writes, + signals, settle, async osResize(columns, rows) { Object.defineProperty(process.stdout, "columns", { value: columns, configurable: true }); @@ -127,6 +138,14 @@ export function createProcessTerminalRenderHarness( for (const chunk of chunks) process.stdin.emit("data", chunk); await settle(); }, + async endInput() { + process.stdin.emit("end"); + await settle(); + }, + async failOutput() { + process.stdout.emit("error", new Error("terminal revoked")); + await settle(); + }, dispose() { tui.stop(); setTerminalHeadless(previousHeadless); diff --git a/packages/tui/test/process-terminal-render.test.ts b/packages/tui/test/process-terminal-render.test.ts index d9af2d757..87ad0e48e 100644 --- a/packages/tui/test/process-terminal-render.test.ts +++ b/packages/tui/test/process-terminal-render.test.ts @@ -88,4 +88,30 @@ describe("ProcessTerminal geometry reflow through the renderer", () => { expect(harness.terminal.rows).toBe(30); expect(harness.terminal.columns).toBe(100); }); + + it("stops rendering and raises SIGHUP when terminal input ends", async () => { + harness = createProcessTerminalRenderHarness(100, 30); + await harness.settle(); + const rendersBeforeDisconnect = harness.probe.widths.length; + + await harness.endInput(); + harness.tui.requestRender(true); + await harness.settle(); + + expect(harness.probe.widths).toHaveLength(rendersBeforeDisconnect); + expect(harness.signals.at(-1)).toEqual({ pid: process.pid, signal: "SIGHUP" }); + }); + + it("stops rendering and raises SIGHUP when terminal output fails", async () => { + harness = createProcessTerminalRenderHarness(100, 30); + await harness.settle(); + const rendersBeforeDisconnect = harness.probe.widths.length; + + await harness.failOutput(); + harness.tui.requestRender(true); + await harness.settle(); + + expect(harness.probe.widths).toHaveLength(rendersBeforeDisconnect); + expect(harness.signals.at(-1)).toEqual({ pid: process.pid, signal: "SIGHUP" }); + }); });