From 93af84f248df756ec65ea874b699dac1385ad00a Mon Sep 17 00:00:00 2001 From: ChiGao Date: Thu, 23 Jul 2026 16:38:47 +0800 Subject: [PATCH] fix(cli): surface unhandled rejections and render errors instead of swallowing them (#7406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): surface unhandled rejections and render errors instead of swallowing them Unhandled promise rejections were emitted to AppEvent.LogError which has no production listener, making crashes completely invisible in debug logs and stderr. Additionally, the main App tree had no top-level ErrorBoundary, so React render errors were caught only by Ink's internal boundary which silently exits via exitPromise.catch(noop). - Write unhandled rejection details to the debug logger so they appear in ~/.qwen/debug/.txt - Wrap the interactive UI tree in an ErrorBoundary that logs fatal render errors and shows a fallback message instead of silent exit * fix(cli): address review feedback — normalize non-Error throws and add exit path - Normalize non-Error thrown values (strings, null, etc.) to Error instances in ErrorBoundary before logging/rendering, preventing secondary TypeErrors from sanitizeTerminalText(undefined) - Schedule a graceful exit (5s delay) from the fatal render error fallback so the session does not hang under the Kitty keyboard protocol where Ctrl+C is a keypress, not SIGINT * test(cli): add test for non-Error thrown value normalization in ErrorBoundary --------- Co-authored-by: 秦奇 --- packages/cli/src/gemini.tsx | 1 + .../components/shared/ErrorBoundary.test.tsx | 21 +++++++++++++++ .../ui/components/shared/ErrorBoundary.tsx | 15 ++++++++--- packages/cli/src/ui/startInteractiveUI.tsx | 27 +++++++++++++++---- 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 2ae7d72d32..06c69bf784 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -181,6 +181,7 @@ Stack trace: ${reason.stack}` : '' }`; + debugLogger.error(errorMessage); appEvents.emit(AppEvent.LogError, errorMessage); if (!unhandledRejectionOccurred) { unhandledRejectionOccurred = true; diff --git a/packages/cli/src/ui/components/shared/ErrorBoundary.test.tsx b/packages/cli/src/ui/components/shared/ErrorBoundary.test.tsx index 77371206bd..8576943e75 100644 --- a/packages/cli/src/ui/components/shared/ErrorBoundary.test.tsx +++ b/packages/cli/src/ui/components/shared/ErrorBoundary.test.tsx @@ -15,6 +15,11 @@ const Thrower = ({ message }: { message: string }) => { throw new Error(message); }; +// A child that throws a non-Error value (string). +const StringThrower = ({ message }: { message: string }) => { + throw message; +}; + describe('ErrorBoundary', () => { // React logs caught render errors to console.error; silence it so the test // output stays clean (the boundary catching the error is the point). @@ -103,4 +108,20 @@ describe('ErrorBoundary', () => { rerender(tree); expect(lastFrame()).toContain('recovered'); }); + + it('normalizes a non-Error thrown value to an Error instance', () => { + const onError = vi.fn(); + const { lastFrame } = render( + + + , + ); + // The fallback renders the stringified value. + expect(lastFrame()).toContain('string error'); + // onError receives a proper Error instance, not the raw string. + expect(onError).toHaveBeenCalledTimes(1); + const [error] = onError.mock.calls[0]; + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('string error'); + }); }); diff --git a/packages/cli/src/ui/components/shared/ErrorBoundary.tsx b/packages/cli/src/ui/components/shared/ErrorBoundary.tsx index 37e508ba9b..dec8a68157 100644 --- a/packages/cli/src/ui/components/shared/ErrorBoundary.tsx +++ b/packages/cli/src/ui/components/shared/ErrorBoundary.tsx @@ -9,6 +9,13 @@ import { Box, Text } from 'ink'; import { theme } from '../../semantic-colors.js'; import { sanitizeTerminalText } from '../../utils/textUtils.js'; +function normalizeError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + return new Error(String(error)); +} + interface ErrorBoundaryProps { children: ReactNode; /** @@ -38,12 +45,12 @@ export class ErrorBoundary extends Component< > { override state: ErrorBoundaryState = { error: null }; - static getDerivedStateFromError(error: Error): ErrorBoundaryState { - return { error }; + static getDerivedStateFromError(error: unknown): ErrorBoundaryState { + return { error: normalizeError(error) }; } - override componentDidCatch(error: Error, info: ErrorInfo): void { - this.props.onError?.(error, info); + override componentDidCatch(error: unknown, info: ErrorInfo): void { + this.props.onError?.(normalizeError(error), info); } private readonly reset = () => { diff --git a/packages/cli/src/ui/startInteractiveUI.tsx b/packages/cli/src/ui/startInteractiveUI.tsx index ffd1a4f143..1c06c586a1 100644 --- a/packages/cli/src/ui/startInteractiveUI.tsx +++ b/packages/cli/src/ui/startInteractiveUI.tsx @@ -33,7 +33,8 @@ import { } from './utils/kittyProtocolDetector.js'; import { installTerminalRedrawOptimizer } from './utils/terminalRedrawOptimizer.js'; import { installSynchronizedOutput } from './utils/synchronizedOutput.js'; -import { registerCleanup } from '../utils/cleanup.js'; +import { ErrorBoundary } from './components/shared/ErrorBoundary.js'; +import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; import { stopAndGetCapturedInput } from '../utils/earlyInputCapture.js'; import { profileCheckpoint } from '../utils/startupProfiler.js'; import { writeStderrLine } from '../utils/stdioHelpers.js'; @@ -195,13 +196,29 @@ export async function startInteractiveUI( // coordinates even though these listeners are owned and cleaned up. process.stdout.setMaxListeners(0); } + const appTree = ( + { + debugLogger.error( + `[FATAL_RENDER_ERROR] ${error.message}\n${info.componentStack ?? ''}\n${error.stack ?? ''}`, + ); + // The fallback replaces AppWrapper, unmounting KeypressProvider and + // Ctrl+C handling. Schedule a graceful exit so the session does not + // hang (e.g. under the Kitty keyboard protocol where Ctrl+C is a + // keypress, not SIGINT). + setTimeout(() => { + void runExitCleanup().then(() => process.exit(1)); + }, 5000); + }} + > + + + ); const instance = render( process.env['DEBUG'] ? ( - - - + {appTree} ) : ( - + appTree ), { exitOnCtrlC: false,