Merge remote-tracking branch 'origin/farm/4ade1343/handle-tui-eio-write'
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user