fix(cli): catch sync exec throw in statusline to prevent crash (#3264) (#3310)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run

child_process.exec() throws synchronously for spawn errors libuv does not
report via the async 'error' event (EBADF, EINVAL, …). On macOS with Node
22, EBADF can surface during stdio pipe setup, and the uncaught throw
escapes the debounce setTimeout callback and crashes the CLI.

Wrap the exec call in try/catch so a failing statusline degrades to no
output while the rest of the CLI keeps running.
This commit is contained in:
Shaojin Wen 2026-04-16 11:02:38 +08:00 committed by GitHub
parent d439e7d738
commit 6f29d24fb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 80 additions and 15 deletions

View file

@ -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');
});
});
});

View file

@ -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;