diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index a80141946..4c702550f 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `ProcessTerminal` treating asynchronous stdout `EIO` errors as uncaught exceptions: stdout `error` events now mark the terminal dead, disable future renders, and keep the active session process alive ([#2284](https://github.com/can1357/oh-my-pi/issues/2284)). + ## [15.11.0] - 2026-06-10 ### Added diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 80c42db4d..fefc54219 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -123,6 +123,24 @@ let activeTerminal: ProcessTerminal | null = null; // Track if a terminal was ever started (for emergency restore logic) let terminalEverStarted = false; +const stdoutErrorHandlers = new Set<(err: Error) => void>(); +let stdoutErrorListenerInstalled = false; + +function onStdoutError(err: Error): void { + for (const handler of stdoutErrorHandlers) handler(err); +} + +function registerStdoutErrorHandler(handler: (err: Error) => void): () => void { + stdoutErrorHandlers.add(handler); + if (!stdoutErrorListenerInstalled) { + process.stdout.on("error", onStdoutError); + stdoutErrorListenerInstalled = true; + } + return () => { + stdoutErrorHandlers.delete(handler); + }; +} + const STD_INPUT_HANDLE = -10; const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200; /** UTF-8 codepage id for SetConsoleCP/SetConsoleOutputCP. */ @@ -344,6 +362,11 @@ export class ProcessTerminal implements Terminal { #stdinDataHandler?: (data: string) => void; #dead = false; #writeLogPath = $env.PI_TUI_WRITE_LOG || ""; + #stdoutErrorCleanup?: () => void; + #stdoutErrorHandler = (err: Error) => { + this.#markTerminalWriteFailed(err); + }; + #windowsVTInputRestore?: () => void; #appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = []; #appearance: TerminalAppearance | undefined; @@ -1182,6 +1205,18 @@ export class ProcessTerminal implements Terminal { if (process.stdin.setRawMode) { process.stdin.setRawMode(this.#wasRaw); } + this.#stdoutErrorCleanup?.(); + this.#stdoutErrorCleanup = undefined; + } + + #ensureStdoutErrorHandler(): void { + this.#stdoutErrorCleanup ??= registerStdoutErrorHandler(this.#stdoutErrorHandler); + } + + #markTerminalWriteFailed(err: unknown): void { + if (this.#dead) return; + this.#dead = true; + logger.warn("terminal write failed; disabling terminal rendering", { err }); } write(data: string): void { @@ -1200,6 +1235,7 @@ export class ProcessTerminal implements Terminal { // Skip control sequences when stdout isn't a TTY (piped output, tests, log // files). They serve no purpose there and would surface as visible noise. if (!process.stdout.isTTY) return; + this.#ensureStdoutErrorHandler(); // A console-sharing child process may have flipped the console codepage // away from UTF-8; repair it before any bytes hit WriteFile so no frame // is ever translated through an OEM codepage. See ensureWindowsConsoleUtf8. @@ -1219,15 +1255,14 @@ export class ProcessTerminal implements Terminal { // threshold. See #2034 and #2095. if (isConPTYHosted() && Buffer.byteLength(data, "utf8") > MAX_CONPTY_WRITE_CHUNK_BYTES) { for (const chunk of chunkForConPTY(data, MAX_CONPTY_WRITE_CHUNK_BYTES)) { + if (this.#dead) break; process.stdout.write(chunk); } } else { process.stdout.write(data); } } catch (err) { - // Any write failure means terminal is dead - no recovery possible - this.#dead = true; - logger.warn("terminal is dead - no recovery possible", { error: err, data }); + this.#markTerminalWriteFailed(err); } } diff --git a/packages/tui/test/issue-2034-repro.test.ts b/packages/tui/test/issue-2034-repro.test.ts index 4e35e1bd2..d82f3ef5f 100644 --- a/packages/tui/test/issue-2034-repro.test.ts +++ b/packages/tui/test/issue-2034-repro.test.ts @@ -273,5 +273,42 @@ describe("issue #2034: chunk large terminal writes on Windows ConPTY", () => { } expect(conptyChunks.join("")).toBe(payload); }); + + it("marks the terminal dead when stdout emits EIO after a write (#2284)", () => { + const writes = captureStdoutWrites(); + const terminal = new ProcessTerminal(); + const err = Object.assign(new Error("EIO: i/o error, write"), { + code: "EIO", + fd: 5, + syscall: "write", + errno: -5, + }); + + try { + terminal.write("first frame"); + process.stdout.emit("error", err); + terminal.write("second frame"); + + expect(writes).toEqual(["first frame"]); + } finally { + terminal.stop(); + } + }); + + it("keeps stdout error events handled after stop for delayed write failures (#2284)", () => { + captureStdoutWrites(); + const terminal = new ProcessTerminal(); + const err = Object.assign(new Error("EIO: i/o error, write"), { + code: "EIO", + fd: 5, + syscall: "write", + errno: -5, + }); + + terminal.write("restore frame"); + terminal.stop(); + + expect(() => process.stdout.emit("error", err)).not.toThrow(); + }); }); }); diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 927996369..0ac8d8f24 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed cleanup reentry noise during fatal shutdown: recursive cleanup requests now no-op idempotently instead of logging repeated `Cleanup invoked recursively` errors ([#2284](https://github.com/can1357/oh-my-pi/issues/2284)). + ## [15.11.0] - 2026-06-10 ### Added diff --git a/packages/utils/src/postmortem.ts b/packages/utils/src/postmortem.ts index 1e66b8875..501e67071 100644 --- a/packages/utils/src/postmortem.ts +++ b/packages/utils/src/postmortem.ts @@ -38,7 +38,6 @@ function runCleanup(reason: Reason): Promise { cleanupStage = "running"; break; case "running": - logger.error("Cleanup invoked recursively", { stack: new Error().stack }); return Promise.resolve(); case "complete": return Promise.resolve(); @@ -150,8 +149,9 @@ export function register(id: string, callback: (reason: Reason) => void | Promis }; if (cleanupStage !== "idle") { - // If cleanup is already running/completed, warn and run on microtask. - logger.warn("Cleanup invoked recursively", { id }); + // Cleanup is already in progress or complete; run late registrations once + // without re-entering the global cleanup pass. + logger.debug("Cleanup already started; running late callback once", { id }); try { callback(Reason.MANUAL); } catch (e) {