diff --git a/packages/cli/src/ui/hooks/useStatusLine.test.ts b/packages/cli/src/ui/hooks/useStatusLine.test.ts index 369fb2df6..af8f06c5d 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.test.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.test.ts @@ -581,4 +581,57 @@ describe('useStatusLine', () => { expect(firstKill).toHaveBeenCalled(); }); }); + + // --- Spawn failure handling (issue #3264) --- + // + // On macOS with Node 22, exec() can throw synchronously with EBADF when + // stdio pipe setup fails. The throw must not escape doUpdate() — or the + // setTimeout callback — or the whole CLI crashes. + + describe('spawn failure handling', () => { + it('does not crash when exec throws synchronously (EBADF)', () => { + vi.mocked(child_process.exec).mockImplementationOnce((() => { + const err = new Error('spawn EBADF') as NodeJS.ErrnoException; + err.code = 'EBADF'; + throw err; + }) as unknown as typeof child_process.exec); + + setStatusLineConfig({ type: 'command', command: 'echo test' }); + + let result: { current: { text: string | null } } | undefined; + expect(() => { + result = renderHook(() => useStatusLine()).result; + }).not.toThrow(); + expect(result!.current.text).toBeNull(); + }); + + it('recovers on subsequent state changes after a sync exec failure', async () => { + // First call throws, subsequent calls succeed with the default mock. + // Verifies activeChildRef and generationRef don't get wedged. + vi.mocked(child_process.exec).mockImplementationOnce((() => { + const err = new Error('spawn EBADF') as NodeJS.ErrnoException; + err.code = 'EBADF'; + throw err; + }) as unknown as typeof child_process.exec); + + setStatusLineConfig({ type: 'command', command: 'echo test' }); + const { result, rerender } = renderHook(() => useStatusLine()); + + expect(result.current.text).toBeNull(); + expect(child_process.exec).toHaveBeenCalledTimes(1); + + // Trigger a re-execution via state change — should use the default mock. + mockUIState.currentModel = 'new-model'; + rerender(); + await act(async () => { + vi.advanceTimersByTime(300); + }); + + expect(child_process.exec).toHaveBeenCalledTimes(2); + await act(async () => { + execCallback(null, 'recovered\n', ''); + }); + expect(result.current.text).toBe('recovered'); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useStatusLine.ts b/packages/cli/src/ui/hooks/useStatusLine.ts index 30173761e..1fd0daca8 100644 --- a/packages/cli/src/ui/hooks/useStatusLine.ts +++ b/packages/cli/src/ui/hooks/useStatusLine.ts @@ -269,21 +269,33 @@ export function useStatusLine(): { // Bump generation so earlier in-flight callbacks are ignored. const gen = ++generationRef.current; - const child = exec( - cmd, - { cwd: cfg.getTargetDir(), timeout: 5000, maxBuffer: 1024 * 10 }, - (error, stdout) => { - if (gen !== generationRef.current) return; // stale - activeChildRef.current = undefined; - if (!error && stdout) { - // Strip only the trailing newline to preserve intentional whitespace. - const line = stdout.replace(/\r?\n$/, '').split(/\r?\n/, 1)[0]; - setOutput(line || null); - } else { - setOutput(null); - } - }, - ); + // exec() can throw synchronously: libuv reports a handful of spawn + // errors (EACCES, ENOENT, …) via the async 'error' event, but anything + // else — including EBADF, reported on macOS Node 22 in issue #3264 — is + // thrown from ChildProcess.spawn. Without this guard the throw escapes + // the setTimeout callback and crashes the CLI as uncaughtException. + let child: ChildProcess; + try { + child = exec( + cmd, + { cwd: cfg.getTargetDir(), timeout: 5000, maxBuffer: 1024 * 10 }, + (error, stdout) => { + if (gen !== generationRef.current) return; // stale + activeChildRef.current = undefined; + if (!error && stdout) { + // Strip only the trailing newline to preserve intentional whitespace. + const line = stdout.replace(/\r?\n$/, '').split(/\r?\n/, 1)[0]; + setOutput(line || null); + } else { + setOutput(null); + } + }, + ); + } catch (err) { + debugLog.error('statusline exec error:', (err as Error).message); + setOutput(null); + return; + } activeChildRef.current = child;