diff --git a/src/main/services/ptyManager.ts b/src/main/services/ptyManager.ts index ff4cf36b7..75bf49ae1 100644 --- a/src/main/services/ptyManager.ts +++ b/src/main/services/ptyManager.ts @@ -19,10 +19,10 @@ import { LOCALE_ENV_VARS, DEFAULT_UTF8_LOCALE, isUtf8Locale } from '../utils/loc * so EPIPE becomes an uncaught exception. Registering an additional error * listener bumps the listener count to >= 2, which causes node-pty to * swallow the error instead of throwing. + * + * The same pattern helps on Unix when the PTY is torn down during session close. */ function suppressPtyPipeErrors(proc: IPty): void { - if (process.platform !== 'win32') return; - // IPty doesn't expose .on() in its type, but the underlying Terminal // class extends EventEmitter and proxies to the socket. (proc as any).on?.('error', (err: NodeJS.ErrnoException) => { @@ -30,6 +30,14 @@ function suppressPtyPipeErrors(proc: IPty): void { log.warn('ptyManager: unexpected PTY error', { code: err.code, message: err.message }); }); } + +function isBenignStreamShutdownError(err: unknown): boolean { + const code = + err && typeof err === 'object' && 'code' in err + ? (err as NodeJS.ErrnoException).code + : undefined; + return code === 'EPIPE' || code === 'EIO' || code === 'ECONNRESET'; +} import { getProviderCustomConfig } from '../settings'; import { agentEventService } from './AgentEventService'; import { OpenCodeHookService } from './OpenCodeHookService'; @@ -1757,8 +1765,14 @@ export function createUtf8StreamForwarder(emitData: (data: string) => void): { } type LifecycleSpawnFallbackChild = { - stdout?: { on: (event: 'data', listener: (buf: Buffer) => void) => void } | null; - stderr?: { on: (event: 'data', listener: (buf: Buffer) => void) => void } | null; + stdout?: { + on(event: 'data', listener: (buf: Buffer) => void): void; + on(event: 'error', listener: (err: unknown) => void): void; + } | null; + stderr?: { + on(event: 'data', listener: (buf: Buffer) => void): void; + on(event: 'error', listener: (err: unknown) => void): void; + } | null; on: (event: 'error' | 'exit' | 'close', listener: (...args: any[]) => void) => void; }; @@ -1783,6 +1797,16 @@ export function attachLifecycleSpawnFallbackHandlers( forwarder.pushStderr(buf); }); + // Without these, closing the child can emit EPIPE on stdio streams (Node reads + // from a broken pipe) and surface as an uncaught exception in the main process. + const onStdioError = (err: unknown) => { + if (isBenignStreamShutdownError(err)) return; + forwarder.flush(); + onError(err instanceof Error ? err : new Error(String(err))); + }; + child.stdout?.on('error', onStdioError); + child.stderr?.on('error', onStdioError); + child.on('error', (error: Error) => { forwarder.flush(); onError(error); diff --git a/src/test/main/ptyManager.test.ts b/src/test/main/ptyManager.test.ts index 6be7784b4..013e3861f 100644 --- a/src/test/main/ptyManager.test.ts +++ b/src/test/main/ptyManager.test.ts @@ -401,7 +401,7 @@ describe('ptyManager provider command resolution', () => { ); }); - it('attaches PTY pipe error suppression on Windows only', async () => { + it('attaches PTY pipe error suppression on all platforms', async () => { const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); const onWin32 = vi.fn(); const onDarwin = vi.fn(); @@ -450,7 +450,7 @@ describe('ptyManager provider command resolution', () => { shell: '/bin/zsh', }); - expect(onDarwin).not.toHaveBeenCalledWith('error', expect.any(Function)); + expect(onDarwin).toHaveBeenCalledWith('error', expect.any(Function)); } finally { if (originalPlatformDescriptor) { Object.defineProperty(process, 'platform', originalPlatformDescriptor); @@ -606,6 +606,32 @@ describe('ptyManager provider command resolution', () => { expect(received.join('')).toBe('Marko Ranđ'); expect(exits).toEqual([[0, null]]); }); + + it('ignores EPIPE on stdio streams during lifecycle fallback', async () => { + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + }; + child.stdout = stdout; + child.stderr = stderr; + + const { attachLifecycleSpawnFallbackHandlers } = await import('../../main/services/ptyManager'); + const errors: Error[] = []; + attachLifecycleSpawnFallbackHandlers(child, { + onData: () => {}, + onExit: () => {}, + onError: (error) => { + errors.push(error); + }, + }); + + const epipe = Object.assign(new Error('read EPIPE'), { code: 'EPIPE' as const }); + stdout.emit('error', epipe); + stderr.emit('error', epipe); + expect(errors).toEqual([]); + }); }); describe('stale Claude session detection', () => {