fix(coding-agent): do not report dead terminal stdin errors as crashes

When the terminal goes away, stdin reads and setRawMode fail with EIO (pi left in an orphaned background process group) or ENOTTY (revoked tty on macOS). These were uncaught, recorded in crashes.json, and prompted users to run /bug. Handle them on stdin like stdout/stderr and skip crash recording for dead terminal errors.
This commit is contained in:
Armin Ronacher 2026-10-05 00:41:36 +02:00
parent 1965a80693
commit 4c6b724ea6
3 changed files with 150 additions and 6 deletions

View file

@ -11,6 +11,7 @@
- Fixed subscription logins such as Sign in with ChatGPT failing with `refresh_token_invalidated` after a request was cancelled during an OAuth token refresh
- Fixed codemode failing for the rest of a session after a pnpm global update removed the running install, and added a restart hint when errors occur after pi was updated or removed on disk ([#10439](https://github.com/earendil-works/pi/issues/10439))
- Fixed interactive sessions reporting a `read EIO` or `setRawMode EIO` crash (and asking to run /bug) when the terminal went away, e.g. after closing the window or resuming a suspended pi in a closed terminal
## [1.0.2] - 2026-10-04

View file

@ -288,7 +288,9 @@ function isUsageSessionEntry(item: RenderSessionItem): item is Extract<SessionEn
return "type" in item && item.type === "usage";
}
const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]);
// EIO: tty reads/ioctls from an orphaned background process group, or writes after hangup.
// ENOTTY: the tty was revoked (macOS) and stdin is no longer a terminal.
const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN", "ENOTTY"]);
function isDeadTerminalError(error: unknown): boolean {
if (!error || typeof error !== "object" || !("code" in error)) {
@ -4308,6 +4310,10 @@ export class InteractiveMode {
* paste / Kitty / modifyOtherKeys sequences.
*/
private uncaughtCrash(error: Error): never {
// A dead terminal is not a pi crash. Do not try to restore it or record it.
if (isDeadTerminalError(error)) {
this.emergencyTerminalExit();
}
if (this.isShuttingDown) {
process.exit(1);
}
@ -4366,10 +4372,13 @@ export class InteractiveMode {
}
throw error;
};
process.stdout.on("error", terminalErrorHandler);
process.stderr.on("error", terminalErrorHandler);
this.signalCleanupHandlers.push(() => process.stdout.off("error", terminalErrorHandler));
this.signalCleanupHandlers.push(() => process.stderr.off("error", terminalErrorHandler));
// stdin needs the handler too: once the terminal is gone, reads and setRawMode
// fail with EIO (orphaned background process group) or ENOTTY (revoked tty).
// Node emits these as stream errors, which are uncaught without a listener.
for (const stream of [process.stdin, process.stdout, process.stderr]) {
stream.on("error", terminalErrorHandler);
this.signalCleanupHandlers.push(() => stream.off("error", terminalErrorHandler));
}
// Restore the terminal before the process dies on any uncaught throw.
// Without this, an unhandled exception from extension code (or anywhere

View file

@ -34,7 +34,13 @@ const interactiveModePrototype = InteractiveMode.prototype as unknown;
const tempDirs: string[] = [];
const originalStdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
class ProcessExitError extends Error {}
class ProcessExitError extends Error {
readonly code: number | undefined;
constructor(code?: number) {
super(`process.exit(${code})`);
this.code = code;
}
}
function createSessionManager(options: { sessionFile?: string } = {}): SessionManager {
return {
@ -183,3 +189,131 @@ describe("InteractiveMode.shutdown ordering (#5080)", () => {
expect(context.runtimeHost.dispose).not.toHaveBeenCalled();
});
});
// Regression for the `read EIO` crash reports (crash_tty_read_eio).
//
// When the terminal goes away, stdin reads and setRawMode fail with EIO (pi is
// left in an orphaned background process group) or ENOTTY (macOS revoked the
// tty). Node emits these as `error` events on process.stdin. Without a stdin
// listener they became uncaught exceptions that were recorded as crashes and
// prompted users to run /bug on the next launch.
type HandlerContext = {
isShuttingDown: boolean;
signalCleanupHandlers: Array<() => void>;
shutdown: () => Promise<void>;
unregisterSignalHandlers: () => void;
emergencyTerminalExit: () => never;
uncaughtCrash: (error: Error) => never;
recordCrash: () => boolean;
getCrashExtensionHint: () => string | undefined;
crashReportInstructions: () => string;
ui: { stop: () => void };
};
type InteractiveModePrivates = {
registerSignalHandlers(this: HandlerContext): void;
unregisterSignalHandlers(this: HandlerContext): void;
emergencyTerminalExit(this: HandlerContext): never;
uncaughtCrash(this: HandlerContext, error: Error): never;
};
const proto = InteractiveMode.prototype as unknown as InteractiveModePrivates;
function createHandlerContext(): HandlerContext {
const context: HandlerContext = {
isShuttingDown: false,
signalCleanupHandlers: [],
shutdown: vi.fn(async () => {}),
unregisterSignalHandlers: () => proto.unregisterSignalHandlers.call(context),
emergencyTerminalExit: () => proto.emergencyTerminalExit.call(context),
uncaughtCrash: (error: Error) => proto.uncaughtCrash.call(context, error),
recordCrash: vi.fn(() => true),
getCrashExtensionHint: () => undefined,
crashReportInstructions: () => "",
ui: { stop: vi.fn() },
};
return context;
}
function errnoError(message: string, code: string): NodeJS.ErrnoException {
return Object.assign(new Error(message), { code });
}
function captureExit(fn: () => void): number | undefined {
try {
fn();
} catch (error) {
if (error instanceof ProcessExitError) return error.code;
throw error;
}
throw new Error("expected process.exit");
}
describe("dead terminal errors on stdin", () => {
let context: HandlerContext | undefined;
afterEach(() => {
context?.unregisterSignalHandlers();
context = undefined;
vi.restoreAllMocks();
});
function setup(): HandlerContext {
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new ProcessExitError(code);
}) as typeof process.exit);
vi.spyOn(console, "error").mockImplementation(() => {});
context = createHandlerContext();
proto.registerSignalHandlers.call(context);
return context;
}
test.each([
["read EIO", "EIO"],
["setRawMode EIO", "EIO"],
["setRawMode ENOTTY", "ENOTTY"],
])("stdin %s exits quietly without recording a crash", (message, code) => {
const ctx = setup();
const exitCode = captureExit(() => process.stdin.emit("error", errnoError(message, code)));
expect(exitCode).toBe(129);
expect(ctx.recordCrash).not.toHaveBeenCalled();
expect(ctx.ui.stop).not.toHaveBeenCalled();
});
test("other stdin errors still crash", () => {
setup();
const error = errnoError("read ECONNREFUSED", "ECONNREFUSED");
expect(() => process.stdin.emit("error", error)).toThrow(error);
});
test("uncaught dead terminal errors are not recorded as crashes", () => {
const ctx = setup();
const exitCode = captureExit(() => ctx.uncaughtCrash(errnoError("read EIO", "EIO")));
expect(exitCode).toBe(129);
expect(ctx.recordCrash).not.toHaveBeenCalled();
});
test("other uncaught errors are still recorded as crashes", () => {
const ctx = setup();
const exitCode = captureExit(() => ctx.uncaughtCrash(new Error("boom")));
expect(exitCode).toBe(1);
expect(ctx.recordCrash).toHaveBeenCalledOnce();
});
test("handlers are removed on unregister", () => {
const ctx = setup();
const before = process.stdin.listenerCount("error");
ctx.unregisterSignalHandlers();
expect(process.stdin.listenerCount("error")).toBe(before - 1);
});
});