Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 28 additions & 4 deletions src/main/services/ptyManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,25 @@ 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) => {
if (err.code === 'EPIPE' || err.code === 'EIO') return;
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';
Expand Down Expand Up @@ -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;
};

Expand All @@ -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);
Expand Down
30 changes: 28 additions & 2 deletions src/test/main/ptyManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading