diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 3bba16f9fa8..83c7d9f4354 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -580,6 +580,25 @@ describe('ShellExecutionService', () => { expect(mockHeadlessTerminal.scrollLines).toHaveBeenCalledWith(10); }); + it('should ignore EBADF when the master fd was already closed', async () => { + // node-pty's native resize throws "ioctl(2) failed, EBADF" when the + // master fd has been closed by destroyPtyProcess() ahead of the + // activePtys map cleanup. The race is wider under Bun. + const resizeError = new Error('ioctl(2) failed, EBADF'); + mockPtyProcess.resize.mockImplementation(() => { + throw resizeError; + }); + + await expect( + simulateExecution('ls -l', (pty) => { + ShellExecutionService.resizePty(pty.pid, 100, 40); + pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); + }), + ).resolves.not.toThrow(); + + expect(mockPtyProcess.resize).toHaveBeenCalledWith(100, 40); + }); + it('should not throw when resizing a pty that has already exited (Windows)', () => { const resizeError = new Error( 'Cannot resize a pty that has already exited', diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index 93c55f0636d..5d277ec2129 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -1473,11 +1473,16 @@ export class ShellExecutionService { const isWindowsPtyError = err.message?.includes( 'Cannot resize a pty that has already exited', ); - - if (isEsrch || isWindowsPtyError) { - // On Unix, we get an ESRCH error. - // On Windows, we get a message-based error. - // In both cases, it's safe to ignore. + // node-pty's native resize throws "ioctl(2) failed, EBADF" (or ENOTTY) + // when the master fd has already been closed. This is the same race as + // ESRCH: destroyPtyProcess() in onExit closes the fd before we remove + // the entry from activePtys, so a concurrent resizePty() can land in + // between. The race is wider under Bun, which is where this surfaces. + const isClosedFdError = + err.message?.includes('EBADF') || err.message?.includes('ENOTTY'); + + if (isEsrch || isWindowsPtyError || isClosedFdError) { + // pty is gone; nothing to resize. } else { throw e; } diff --git a/packages/core/src/utils/getPty.ts b/packages/core/src/utils/getPty.ts index 27638ec1faa..2945e60895a 100644 --- a/packages/core/src/utils/getPty.ts +++ b/packages/core/src/utils/getPty.ts @@ -21,6 +21,16 @@ export const getPty = async (): Promise => { if (process.env['GEMINI_PTY_INFO'] === 'child_process') { return null; } + // node-pty's master-fd tty.ReadStream wrapper doesn't deliver data to onData + // under Bun, and resize hits EBADF when the fd is closed early. Fall back to + // the child_process path (which already has Bun-specific handling for + // detached/SIGHUP). Set GEMINI_PTY_INFO=node-pty to override and test. + if ( + 'bun' in process.versions && + process.env['GEMINI_PTY_INFO'] !== 'node-pty' + ) { + return null; + } try { const lydell = '@lydell/node-pty'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment