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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user