From 4c6b724ea6aff15e7855c7cb1091463e06278323 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Mon, 5 Oct 2026 00:41:36 +0200 Subject: [PATCH] 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. --- packages/coding-agent/CHANGELOG.md | 1 + .../src/modes/interactive/interactive-mode.ts | 19 ++- ...-signal-shutdown-extension-cleanup.test.ts | 136 +++++++++++++++++- 3 files changed, 150 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 410146e5f..6b3550acb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 265d4336e..18d6d95dd 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -288,7 +288,9 @@ function isUsageSessionEntry(item: RenderSessionItem): item is Extract 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 diff --git a/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts b/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts index 9ccf77cdd..d732cefea 100644 --- a/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts +++ b/packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts @@ -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; + 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); + }); +});