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
This commit is contained in:
roboomp
2026-07-17 11:53:11 +00:00
parent 0f9fceeea4
commit 09b43b7581
6 changed files with 94 additions and 9 deletions
+4
View File
@@ -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
+1 -1
View File
@@ -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;
+41 -7
View File
@@ -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);
}
}
+2
View File
@@ -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();
@@ -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<void>;
/** 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<void>;
/** Feed raw byte chunks through the real stdin pipeline (StdinBuffer reassembly included). */
feed(...chunks: string[]): Promise<void>;
/** End stdin as a terminal host does when its pane disappears. */
endInput(): Promise<void>;
/** Fail stdout as a revoked terminal descriptor does on write. */
failOutput(): Promise<void>;
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);
@@ -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" });
});
});