diff --git a/packages/cli/src/utils/readStdin.test.ts b/packages/cli/src/utils/readStdin.test.ts index 5cc187fcbe..1a5202173c 100644 --- a/packages/cli/src/utils/readStdin.test.ts +++ b/packages/cli/src/utils/readStdin.test.ts @@ -21,6 +21,8 @@ const mockStdin = { on: vi.fn(), removeListener: vi.fn(), destroy: vi.fn(), + listeners: vi.fn().mockReturnValue([]), + listenerCount: vi.fn().mockReturnValue(0), }; describe('readStdin', () => { @@ -48,6 +50,8 @@ describe('readStdin', () => { if (event === 'error') onErrorHandler = handler as (err: Error) => void; }, ); + mockStdin.listeners.mockReturnValue([]); + mockStdin.listenerCount.mockReturnValue(0); }); afterEach(() => { diff --git a/packages/cli/src/utils/readStdin.ts b/packages/cli/src/utils/readStdin.ts index cd62d1f961..30834e0d14 100644 --- a/packages/cli/src/utils/readStdin.ts +++ b/packages/cli/src/utils/readStdin.ts @@ -62,6 +62,13 @@ export async function readStdin(): Promise { process.stdin.removeListener('readable', onReadable); process.stdin.removeListener('end', onEnd); process.stdin.removeListener('error', onError); + + // Add a no-op error listener if no other error listeners are present to prevent + // unhandled 'error' events (like EIO) from crashing the process after we stop reading. + // This is especially important for background execution where TTY might cause EIO. + if (process.stdin.listenerCount('error') === 0) { + process.stdin.on('error', noopErrorHandler); + } }; process.stdin.on('readable', onReadable); @@ -69,3 +76,5 @@ export async function readStdin(): Promise { process.stdin.on('error', onError); }); } + +function noopErrorHandler() {} diff --git a/packages/cli/src/utils/readStdin_safety.test.ts b/packages/cli/src/utils/readStdin_safety.test.ts new file mode 100644 index 0000000000..4e4ba08f6d --- /dev/null +++ b/packages/cli/src/utils/readStdin_safety.test.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { readStdin } from './readStdin.js'; +import { EventEmitter } from 'node:events'; + +// Mock debugLogger to avoid clutter +vi.mock('@google/gemini-cli-core', () => ({ + debugLogger: { + warn: vi.fn(), + }, +})); + +describe('readStdin EIO Reproduction', () => { + let originalStdin: typeof process.stdin; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let fakeStdin: EventEmitter & { setEncoding: any; read: any; destroy: any }; + + beforeEach(() => { + originalStdin = process.stdin; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fakeStdin = new EventEmitter() as any; + fakeStdin.setEncoding = vi.fn(); + fakeStdin.read = vi.fn().mockReturnValue(null); // Return null to simulate end of reading or no data + fakeStdin.destroy = vi.fn(); + + Object.defineProperty(process, 'stdin', { + value: fakeStdin, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'stdin', { + value: originalStdin, + writable: true, + configurable: true, + }); + vi.restoreAllMocks(); + }); + + it('crashes (throws unhandled error) if EIO happens after readStdin completes', async () => { + const promise = readStdin(); + fakeStdin.emit('end'); + await promise; + + // Verify listeners are removed (implementation detail check) + + // We expect 1 listener now (our no-op handler) because we started with 0. + + expect(fakeStdin.listenerCount('error')).toBe(1); + + // This mimics the crash. + + // We expect this NOT to throw now that we've added a no-op handler. + + expect(() => { + fakeStdin.emit('error', new Error('EIO')); + }).not.toThrow(); + }); + + it('does NOT add a no-op handler if another error listener is present', async () => { + const customErrorHandler = vi.fn(); + + fakeStdin.on('error', customErrorHandler); + + const promise = readStdin(); + + fakeStdin.emit('end'); + + await promise; + + // It should have exactly 1 listener (our custom one), not 2. + + expect(fakeStdin.listenerCount('error')).toBe(1); + + expect(fakeStdin.listeners('error')).toContain(customErrorHandler); + + // Triggering error should call our handler and NOT crash (because there is a listener) + + const error = new Error('EIO'); + + fakeStdin.emit('error', error); + + expect(customErrorHandler).toHaveBeenCalledWith(error); + }); +});