Merge remote-tracking branch 'origin/farm/4ade1343/handle-tui-eio-write'

This commit is contained in:
can1357
2026-06-11 15:51:13 +02:00
5 changed files with 86 additions and 6 deletions
+4
View File
@@ -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
+38 -3
View File
@@ -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);
}
}
@@ -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();
});
});
});
+4
View File
@@ -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
+3 -3
View File
@@ -38,7 +38,6 @@ function runCleanup(reason: Reason): Promise<void> {
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) {