mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(cli): prevent silent VP-mode crash by adding uncaughtException handler and error visibility (#8088)
* fix(cli): prevent silent VP-mode crash by adding uncaughtException handler and error visibility VP (alternate-screen) mode swallows all error output: uncaught exceptions write their stack trace to stderr which lands on the alternate screen buffer, then gets discarded when teardown switches back to the primary buffer. The user sees a silent exit with no error message and nothing in the debug log. Root cause: no `uncaughtException` handler existed anywhere in the CLI. PR #7406's ErrorBoundary and unhandledRejection handler only cover React render errors and promise rejections — synchronous exceptions bypass both. Changes: - Add `setupUncaughtExceptionHandler()` with sync debug-log write, alternate-screen exit before stderr output, and clean process.exit(1) - Add `consumeLastRenderError()` to ErrorBoundary for VP main-screen echo after unmount leaves the alternate screen - Remove SIGTERM/SIGINT handlers from kittyProtocolDetector.ts that raced with the main signal handlers (#7779) - Add SIGHUP handler alongside SIGTERM/SIGINT (#7781) - Guard ThinkBody's MarkdownDisplay with per-item ErrorBoundary so partial markdown during thought streaming degrades to plain text Related: #7971 #7972 #7779 #7781 * fix(cli): consolidate uncaughtException handler into a single listener (#8088) The VP-crash handler added a second uncaughtException listener that conflicted with the pre-existing one in runCliEntryPoint: the first listener's process.exit(1) ran before the second, leaving the visibility feature inert for real errors, and the new listener lacked the PTY-race guard, crashing the session on benign teardown errors. Replace the startup handler with one session-aware listener (PTY guard, isTTY-guarded alternate-screen leave, writeStderrLineSafe), sanitize the inline render-error fallback, gate the exit-time render-error echo on onError, and cover SIGHUP exit code. * fix(cli): harden uncaughtException handler and scope render-error echo (#8088) * fix(cli): address review feedback on VP crash handler (#8088) - Close over Config instead of sessionId string so the uncaughtException handler reads the current session ID at crash time - Create debug log directory before appending so the write succeeds on fresh installs where ~/.qwen/debug/ does not yet exist - Sanitize error stack with sanitizeTerminalText before writing to stderr to prevent ANSI injection on the persistent main buffer - Add onError debug logging to the ThinkBody ErrorBoundary - Gate the '(logged to debug file)' hint on isDebugLogFileEnabled() so the message is accurate when debug file logging is disabled * fix(cli): move uncaught-exception helpers to a leaf module (#8088) gemini.tsx statically imported handleUncaughtException and isExpectedPtyRaceError from cli.ts, the esbuild entry point. gemini.tsx is only ever loaded lazily, so that import formed an entry<->lazy cycle; with splitting enabled esbuild hoisted the entry body into a shared chunk and left dist/cli.js as a re-export stub. The bootstrap guard at the bottom of cli.ts then never ran and the bundled CLI exited 0 silently. Move the helpers (and the private getErrnoCode) into a new leaf module, utils/uncaught-exception-handler.ts, imported by both cli.ts and gemini.tsx. cli.ts re-exports them so existing importers (cli.test.ts) are unaffected, and the handler reuses the shared writeStderrLine from stdioHelpers.ts. The uncaughtException behavior itself is unchanged. --------- Co-authored-by: 秦奇 <[email protected]> Co-authored-by: qwen-code-dev-bot <[email protected]> Co-authored-by: Qwen Code Autofix <[email protected]> Co-authored-by: Shaojin Wen <[email protected]> Co-authored-by: qwen-code-ci-bot <[email protected]>
This commit is contained in:
9 files changed
+402
-65
No files matched your search
+13
-48
@@ -16,6 +16,10 @@ import type { ArgumentsCamelCase, Argv, Options } from 'yargs';
|
||||
import { normalizeServeFastPathArgv } from './serve/fast-path-argv.js';
|
||||
import { initStartupProfiler } from './utils/startupProfiler.js';
|
||||
import { initCpuProfiler } from './utils/cpuProfiler.js';
|
||||
import {
|
||||
handleUncaughtException,
|
||||
isExpectedPtyRaceError,
|
||||
} from './utils/uncaught-exception-handler.js';
|
||||
|
||||
// Preserve the old entrypoint's profiling baseline before route-specific
|
||||
// dynamic imports or command handling shift startup measurements.
|
||||
@@ -390,42 +394,6 @@ export async function runCliEntry(
|
||||
await main();
|
||||
}
|
||||
|
||||
function getErrnoCode(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
export function isExpectedPtyRaceError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = error.message;
|
||||
const code = getErrnoCode(error);
|
||||
|
||||
if (
|
||||
(code === 'EIO' && message.includes('read')) ||
|
||||
message.includes('read EIO')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
(code === 'EAGAIN' && message.includes('read')) ||
|
||||
message.includes('read EAGAIN')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
message.includes('ioctl(2) failed, EBADF') ||
|
||||
message.includes('Cannot resize a pty that has already exited')
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleCriticalError(error: unknown): Promise<void> {
|
||||
const [{ FatalError }, { AlreadyReportedError }] = await Promise.all([
|
||||
import('./utils/deferred-core-runtime.js'),
|
||||
@@ -533,24 +501,21 @@ export function stampCliEntryEnv(entryPath?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// handleUncaughtException and isExpectedPtyRaceError live in
|
||||
// ./utils/uncaught-exception-handler.js and are re-exported here for existing
|
||||
// importers (cli.test.ts). gemini.tsx must import them from that leaf module
|
||||
// directly: a static import of this entry file from a module the bundle loads
|
||||
// lazily makes esbuild hoist this entry into a shared chunk, which silently
|
||||
// disables the bootstrap guard at the bottom.
|
||||
export { handleUncaughtException, isExpectedPtyRaceError };
|
||||
|
||||
export async function runCliEntryPoint(
|
||||
run: () => Promise<void> = runCliEntry,
|
||||
handleError: (error: unknown) => Promise<void> = handleCriticalError,
|
||||
): Promise<void> {
|
||||
stampCliEntryEnv();
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
if (isExpectedPtyRaceError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
writeStderrLine(error.stack ?? error.message);
|
||||
} else {
|
||||
writeStderrLine(String(error));
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('uncaughtException', handleUncaughtException);
|
||||
|
||||
try {
|
||||
await run();
|
||||
|
||||
@@ -30,6 +30,7 @@ import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { ApprovalMode, OutputFormat } from '@qwen-code/qwen-code-core';
|
||||
|
||||
const mockWriteStderrLine = vi.hoisted(() => vi.fn());
|
||||
const mockConsumeLastRenderError = vi.hoisted(() => vi.fn());
|
||||
const mockHandleListExtensions = vi.hoisted(() => vi.fn());
|
||||
const mockStartEarlyStartupPrefetches = vi.hoisted(() => vi.fn());
|
||||
const mockStartPostRenderPrefetches = vi.hoisted(() => vi.fn());
|
||||
@@ -2056,6 +2057,55 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('still exits on SIGHUP with code 129', async () => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
const { loadSettings } = await import('./config/settings.js');
|
||||
const cleanupModule = await import('./utils/cleanup.js');
|
||||
const signalHandlers = new Map<string, (...args: unknown[]) => void>();
|
||||
const realProcessOn = process.on.bind(process);
|
||||
const processOnSpy = vi.spyOn(process, 'on').mockImplementation(((
|
||||
eventName: string | symbol,
|
||||
listener: (...args: unknown[]) => void,
|
||||
) => {
|
||||
if (
|
||||
eventName === 'SIGTERM' ||
|
||||
eventName === 'SIGINT' ||
|
||||
eventName === 'SIGHUP'
|
||||
) {
|
||||
if (!signalHandlers.has(eventName as string)) {
|
||||
signalHandlers.set(eventName as string, listener);
|
||||
}
|
||||
return process;
|
||||
}
|
||||
return realProcessOn(
|
||||
eventName as string,
|
||||
listener as (...args: unknown[]) => void,
|
||||
);
|
||||
}) as typeof process.on);
|
||||
const processExitSpy = vi
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined) as unknown as typeof process.exit);
|
||||
const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup);
|
||||
runExitCleanupMock.mockResolvedValue(undefined);
|
||||
applyInteractiveSigintConfigMocks(loadCliConfig, loadSettings);
|
||||
vi.mocked(parseArguments).mockResolvedValue({
|
||||
extensions: undefined,
|
||||
} as never);
|
||||
|
||||
await main();
|
||||
signalHandlers.get('SIGHUP')?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(runExitCleanupMock).toHaveBeenCalledTimes(1);
|
||||
expect(processExitSpy).toHaveBeenCalledWith(129);
|
||||
|
||||
processOnSpy.mockRestore();
|
||||
processExitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects --json-schema when running in interactive (TUI) mode', async () => {
|
||||
// The synthetic structured_output tool only terminates the run inside
|
||||
// runNonInteractive. In TUI mode it's an inert tool that prints
|
||||
@@ -2222,6 +2272,17 @@ describe('startInteractiveUI', () => {
|
||||
render: vi.fn().mockReturnValue({ unmount: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('./ui/components/shared/ErrorBoundary.js', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<
|
||||
typeof import('./ui/components/shared/ErrorBoundary.js')
|
||||
>();
|
||||
return {
|
||||
...original,
|
||||
consumeLastRenderError: mockConsumeLastRenderError,
|
||||
};
|
||||
});
|
||||
|
||||
let initialExitListeners: NodeJS.ExitListener[] = [];
|
||||
let originalStdoutIsTTY: boolean | undefined;
|
||||
let restoreCiEnv = () => {};
|
||||
@@ -2607,6 +2668,70 @@ describe('startInteractiveUI', () => {
|
||||
).toBeGreaterThan(unmount.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('echoes a stored render error to stderr on cleanup (VP exit-time echo)', async () => {
|
||||
const unmount = vi.fn();
|
||||
const { render } = await import('ink');
|
||||
vi.mocked(render).mockReturnValue({ unmount } as never);
|
||||
mockConsumeLastRenderError.mockReturnValue(new Error('render boom'));
|
||||
mockWriteStderrLine.mockClear();
|
||||
|
||||
await startInteractiveUI(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
{
|
||||
authError: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const { registerCleanup } = await import('./utils/cleanup.js');
|
||||
const cleanupFn = vi.mocked(registerCleanup).mock.calls.at(-1)?.[0] as
|
||||
| (() => Promise<void> | void)
|
||||
| undefined;
|
||||
expect(cleanupFn).toBeTypeOf('function');
|
||||
await cleanupFn?.();
|
||||
|
||||
expect(unmount).toHaveBeenCalledTimes(1);
|
||||
expect(mockWriteStderrLine).toHaveBeenCalledWith(
|
||||
'\nRendering error: render boom',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not echo when no render error was stored', async () => {
|
||||
const unmount = vi.fn();
|
||||
const { render } = await import('ink');
|
||||
vi.mocked(render).mockReturnValue({ unmount } as never);
|
||||
mockConsumeLastRenderError.mockReturnValue(undefined);
|
||||
mockWriteStderrLine.mockClear();
|
||||
|
||||
await startInteractiveUI(
|
||||
mockConfig,
|
||||
mockSettings,
|
||||
mockStartupWarnings,
|
||||
mockWorkspaceRoot,
|
||||
{
|
||||
authError: null,
|
||||
themeError: null,
|
||||
shouldOpenAuthDialog: false,
|
||||
geminiMdFileCount: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const { registerCleanup } = await import('./utils/cleanup.js');
|
||||
const cleanupFn = vi.mocked(registerCleanup).mock.calls.at(-1)?.[0] as
|
||||
| (() => Promise<void> | void)
|
||||
| undefined;
|
||||
await cleanupFn?.();
|
||||
|
||||
expect(mockWriteStderrLine).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Rendering error'),
|
||||
);
|
||||
});
|
||||
|
||||
describe('periodic memory-pressure check', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
uiTelemetryService,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import dns from 'node:dns';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import v8 from 'node:v8';
|
||||
@@ -81,7 +82,8 @@ import { start_sandbox } from './utils/sandbox.js';
|
||||
import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||
import { initializeWarningHandler } from './utils/warningHandler.js';
|
||||
import { writeStderrLine } from './utils/stdioHelpers.js';
|
||||
import { writeStderrLine, writeStderrLineSafe } from './utils/stdioHelpers.js';
|
||||
import { sanitizeTerminalText } from './ui/utils/textUtils.js';
|
||||
import { getHeadlessYoloSafetyWarning } from './utils/headlessSafetyWarnings.js';
|
||||
import { initializeLlmOutputLanguage } from './utils/languageUtils.js';
|
||||
import {
|
||||
@@ -166,6 +168,62 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] {
|
||||
}
|
||||
|
||||
import { loadSandboxConfig } from './config/sandboxConfig.js';
|
||||
import {
|
||||
handleUncaughtException,
|
||||
isExpectedPtyRaceError,
|
||||
} from './utils/uncaught-exception-handler.js';
|
||||
|
||||
let uncaughtExceptionHandler: ((error: unknown) => void) | undefined;
|
||||
|
||||
export function setupUncaughtExceptionHandler(config: Config) {
|
||||
// runCliEntryPoint() registered the basic handleUncaughtException at startup,
|
||||
// before the session ID existed. Replace it now: two listeners conflict — the
|
||||
// first calls process.exit(1) so the second never runs — and the basic one
|
||||
// lacks the debug-log write and the alternate-screen handling below. Also drop
|
||||
// any handler a previous call installed so exactly one listener is ever active.
|
||||
process.removeListener('uncaughtException', handleUncaughtException);
|
||||
if (uncaughtExceptionHandler) {
|
||||
process.removeListener('uncaughtException', uncaughtExceptionHandler);
|
||||
}
|
||||
uncaughtExceptionHandler = (rawError) => {
|
||||
if (isExpectedPtyRaceError(rawError)) {
|
||||
return;
|
||||
}
|
||||
const error =
|
||||
rawError instanceof Error ? rawError : new Error(String(rawError));
|
||||
const timestamp = new Date().toISOString();
|
||||
const line = `${timestamp} [ERROR] [STARTUP] [UNCAUGHT_EXCEPTION] ${error.message}\n${error.stack ?? ''}\n`;
|
||||
// debugLogger.error() uses async fs.appendFile — the write would be
|
||||
// abandoned by the process.exit() below. Write synchronously instead.
|
||||
let logged = false;
|
||||
try {
|
||||
const logPath = Storage.getDebugLogPath(config.getSessionId());
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
fs.appendFileSync(logPath, line, 'utf8');
|
||||
logged = true;
|
||||
} catch {
|
||||
// Best-effort: if the debug dir doesn't exist yet or the disk is
|
||||
// full, the stderr output below is the fallback record.
|
||||
}
|
||||
// In VP / alternate-screen mode, stderr is written to the alternate
|
||||
// buffer which is discarded on teardown. Leave the alternate screen
|
||||
// *before* writing the error so the user actually sees it. Guard on
|
||||
// isTTY: with stdout redirected to a file the escapes would corrupt it.
|
||||
if (process.stdout.isTTY) {
|
||||
try {
|
||||
process.stdout.write('\x1b[?1049l'); // leave alternate screen
|
||||
process.stdout.write('\x1b[?25h'); // show cursor
|
||||
} catch {
|
||||
// stdout may be broken; the debug log above is the primary record.
|
||||
}
|
||||
}
|
||||
writeStderrLineSafe(
|
||||
`\nFatal: uncaught exception${logged ? ' (logged to debug file)' : ''}\n${sanitizeTerminalText(error.stack ?? error.message)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
};
|
||||
process.on('uncaughtException', uncaughtExceptionHandler);
|
||||
}
|
||||
|
||||
export function setupUnhandledRejectionHandler() {
|
||||
let unhandledRejectionOccurred = false;
|
||||
@@ -191,7 +249,9 @@ ${reason.stack}`
|
||||
}
|
||||
|
||||
function getSignalExitCode(signal: NodeJS.Signals): number {
|
||||
return signal === 'SIGINT' ? 130 : 143;
|
||||
if (signal === 'SIGINT') return 130;
|
||||
if (signal === 'SIGHUP') return 129;
|
||||
return 143;
|
||||
}
|
||||
|
||||
// A real SIGINT only reaches the process-level handler while raw mode is
|
||||
@@ -241,6 +301,9 @@ function installInteractiveSignalHandlers(wasRaw: boolean): () => void {
|
||||
const handleSigterm = () => {
|
||||
beginExit('SIGTERM');
|
||||
};
|
||||
const handleSighup = () => {
|
||||
beginExit('SIGHUP');
|
||||
};
|
||||
const handleSigint = () => {
|
||||
if (cleanupStarted) {
|
||||
return;
|
||||
@@ -260,10 +323,12 @@ function installInteractiveSignalHandlers(wasRaw: boolean): () => void {
|
||||
|
||||
process.on('SIGTERM', handleSigterm);
|
||||
process.on('SIGINT', handleSigint);
|
||||
process.on('SIGHUP', handleSighup);
|
||||
|
||||
return () => {
|
||||
process.removeListener('SIGTERM', handleSigterm);
|
||||
process.removeListener('SIGINT', handleSigint);
|
||||
process.removeListener('SIGHUP', handleSighup);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -859,6 +924,11 @@ export async function main() {
|
||||
// This ensures MCP server subprocesses are properly terminated on exit
|
||||
registerCleanup(() => config.shutdown());
|
||||
|
||||
// Install the uncaughtException handler once the session ID is known.
|
||||
// Before this point VP mode is not active, so Node's default stderr
|
||||
// output is visible and sufficient.
|
||||
setupUncaughtExceptionHandler(config);
|
||||
|
||||
startEarlyStartupPrefetches(config);
|
||||
|
||||
const wasRaw = process.stdin.isRaw;
|
||||
|
||||
@@ -18,10 +18,17 @@ import {
|
||||
SCREEN_READER_USER_PREFIX,
|
||||
} from '../../textConstants.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import { ErrorBoundary } from '../shared/ErrorBoundary.js';
|
||||
import { ICON } from '../../constants.js';
|
||||
import { wrapToVisualLines } from '../../utils/textUtils.js';
|
||||
import {
|
||||
wrapToVisualLines,
|
||||
sanitizeTerminalText,
|
||||
} from '../../utils/textUtils.js';
|
||||
import { formatDuration } from '../../utils/displayUtils.js';
|
||||
|
||||
const debugLogger = createDebugLogger('THINK_RENDER');
|
||||
|
||||
export const THINKING_ICON = `${ICON.THEREFORE} `;
|
||||
export const THINKING_ICON_PENDING = `${ICON.BECAUSE} `;
|
||||
|
||||
@@ -341,13 +348,26 @@ const ThinkBody: React.FC<{
|
||||
|
||||
return (
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
contentWidth={contentWidth - 2}
|
||||
textColor={theme.text.secondary}
|
||||
/>
|
||||
<ErrorBoundary
|
||||
fallback={(err) => (
|
||||
<Text color={theme.text.secondary} dimColor>
|
||||
{sanitizeTerminalText(err.message)}
|
||||
</Text>
|
||||
)}
|
||||
onError={(error, info) => {
|
||||
debugLogger.error(
|
||||
`[THINK_RENDER_ERROR] ${error.message}\n${info.componentStack ?? ''}\n${error.stack ?? ''}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
<MarkdownDisplay
|
||||
text={text}
|
||||
isPending={isPending}
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
contentWidth={contentWidth - 2}
|
||||
textColor={theme.text.secondary}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { act } from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { ErrorBoundary } from './ErrorBoundary.js';
|
||||
import { ErrorBoundary, consumeLastRenderError } from './ErrorBoundary.js';
|
||||
|
||||
// A child that throws during render to trip the boundary.
|
||||
const Thrower = ({ message }: { message: string }) => {
|
||||
@@ -124,4 +124,40 @@ describe('ErrorBoundary', () => {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe('string error');
|
||||
});
|
||||
|
||||
it('stores the error for consumeLastRenderError (VP main-screen echo)', () => {
|
||||
// Drain any leftover state from prior tests.
|
||||
consumeLastRenderError();
|
||||
|
||||
const onError = vi.fn();
|
||||
render(
|
||||
<ErrorBoundary recordForExitEcho onError={onError}>
|
||||
<Thrower message="vp crash" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
const err = consumeLastRenderError();
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err?.message).toBe('vp crash');
|
||||
// Second call returns undefined (consumed).
|
||||
expect(consumeLastRenderError()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not store the error without recordForExitEcho (non-fatal boundary)', () => {
|
||||
// A boundary that handles the error itself (e.g. the transcript view)
|
||||
// must not feed the exit-time echo: the app continues normally, so a
|
||||
// later /quit should not print a spurious "Rendering error".
|
||||
consumeLastRenderError();
|
||||
|
||||
const onError = vi.fn();
|
||||
render(
|
||||
<ErrorBoundary onError={onError}>
|
||||
<Thrower message="handled inline" />
|
||||
</ErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(onError).toHaveBeenCalledTimes(1);
|
||||
expect(consumeLastRenderError()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,21 @@ function normalizeError(error: unknown): Error {
|
||||
return new Error(String(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-level store for the last rendering error. The cleanup chain in
|
||||
* startInteractiveUI.tsx reads this after `instance.unmount()` leaves the
|
||||
* alternate screen, so the message can be echoed to the *main* screen buffer
|
||||
* where it survives after the process exits. Without this, VP / alternate-
|
||||
* screen mode discards the fallback UI on teardown and the user sees nothing.
|
||||
*/
|
||||
let lastRenderError: Error | undefined;
|
||||
|
||||
export function consumeLastRenderError(): Error | undefined {
|
||||
const err = lastRenderError;
|
||||
lastRenderError = undefined;
|
||||
return err;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
@@ -26,6 +41,14 @@ interface ErrorBoundaryProps {
|
||||
fallback?: (error: Error, reset: () => void) => ReactNode;
|
||||
/** Optional side-effecting hook for logging the error. */
|
||||
onError?: (error: Error, info: ErrorInfo) => void;
|
||||
/**
|
||||
* When true, the caught error is stored in the module-level
|
||||
* `lastRenderError` so the cleanup chain in startInteractiveUI.tsx can
|
||||
* echo it to stderr after leaving the alternate screen. Only the fatal
|
||||
* top-level boundary should set this; non-fatal boundaries (e.g. the
|
||||
* transcript view) recover and the app continues.
|
||||
*/
|
||||
recordForExitEcho?: boolean;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
@@ -50,7 +73,11 @@ export class ErrorBoundary extends Component<
|
||||
}
|
||||
|
||||
override componentDidCatch(error: unknown, info: ErrorInfo): void {
|
||||
this.props.onError?.(normalizeError(error), info);
|
||||
const normalized = normalizeError(error);
|
||||
if (this.props.recordForExitEcho) {
|
||||
lastRenderError = normalized;
|
||||
}
|
||||
this.props.onError?.(normalized, info);
|
||||
}
|
||||
|
||||
private readonly reset = () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { render } from 'ink';
|
||||
import React from 'react';
|
||||
import {
|
||||
createDebugLogger,
|
||||
isDebugLogFileEnabled,
|
||||
type Config,
|
||||
writeRuntimeStatus,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
@@ -37,11 +38,15 @@ import {
|
||||
isInteractiveTerminal,
|
||||
shouldUseVirtualViewport,
|
||||
} from './utils/terminal-buffer.js';
|
||||
import { ErrorBoundary } from './components/shared/ErrorBoundary.js';
|
||||
import {
|
||||
ErrorBoundary,
|
||||
consumeLastRenderError,
|
||||
} 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';
|
||||
import { sanitizeTerminalText } from './utils/textUtils.js';
|
||||
import { startPostRenderPrefetches } from '../startup/startup-prefetch.js';
|
||||
import {
|
||||
computeWindowTitle,
|
||||
@@ -208,6 +213,7 @@ export async function startInteractiveUI(
|
||||
}
|
||||
const appTree = (
|
||||
<ErrorBoundary
|
||||
recordForExitEcho
|
||||
onError={(error, info) => {
|
||||
debugLogger.error(
|
||||
`[FATAL_RENDER_ERROR] ${error.message}\n${info.componentStack ?? ''}\n${error.stack ?? ''}`,
|
||||
@@ -306,6 +312,18 @@ export async function startInteractiveUI(
|
||||
}
|
||||
restoreSynchronizedOutput();
|
||||
restoreTerminalRedrawOptimizer();
|
||||
// If the ErrorBoundary caught a rendering error, echo it to stderr
|
||||
// now that we are back on the main screen buffer. In VP mode the
|
||||
// fallback UI was drawn on the alternate screen and is gone.
|
||||
const renderError = consumeLastRenderError();
|
||||
if (renderError) {
|
||||
const loggedHint = isDebugLogFileEnabled()
|
||||
? ' (logged to debug file)'
|
||||
: '';
|
||||
writeStderrLine(
|
||||
`\nRendering error${loggedHint}: ${sanitizeTerminalText(renderError.message)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -96,11 +96,14 @@ export async function detectAndEnableKittyProtocol(): Promise<boolean> {
|
||||
protocolSupported = true;
|
||||
enableProtocol();
|
||||
|
||||
// Set up cleanup on exit (exit covers process.exit() calls,
|
||||
// SIGTERM/SIGINT cover signal-based terminations).
|
||||
// Last-resort fallback: if the process exits without running
|
||||
// the async cleanup chain (e.g. direct process.exit() call),
|
||||
// the 'exit' event still fires synchronously and restores the
|
||||
// terminal. Signal-based teardown is handled by the main
|
||||
// installInteractiveSignalHandlers() → runExitCleanup() →
|
||||
// disableKittyProtocol() path, which runs *after* Ink leaves
|
||||
// the alternate screen so the pop lands on the correct buffer.
|
||||
process.on('exit', disableProtocol);
|
||||
process.on('SIGTERM', disableProtocol);
|
||||
process.on('SIGINT', disableProtocol);
|
||||
}
|
||||
|
||||
detectionComplete = true;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { writeStderrLine } from './stdioHelpers.js';
|
||||
|
||||
// These helpers live in a leaf module (no import of cli.ts or gemini.tsx) so
|
||||
// both the entry point and the lazily-loaded gemini.tsx can share them. A
|
||||
// static import of cli.ts from gemini.tsx makes esbuild hoist the entry into a
|
||||
// shared chunk under `splitting: true`, which silently disables the bootstrap
|
||||
// guard at the bottom of cli.ts and leaves the bundled CLI dead.
|
||||
|
||||
function getErrnoCode(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
export function isExpectedPtyRaceError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = error.message;
|
||||
const code = getErrnoCode(error);
|
||||
|
||||
if (
|
||||
(code === 'EIO' && message.includes('read')) ||
|
||||
message.includes('read EIO')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
(code === 'EAGAIN' && message.includes('read')) ||
|
||||
message.includes('read EAGAIN')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
message.includes('ioctl(2) failed, EBADF') ||
|
||||
message.includes('Cannot resize a pty that has already exited')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The process-level `uncaughtException` handler registered at the entry point,
|
||||
* before the session ID (and thus the debug-log path) is known. Benign PTY
|
||||
* teardown races are suppressed; anything else is reported to stderr and fatal.
|
||||
*
|
||||
* `setupUncaughtExceptionHandler` in gemini.tsx removes this handler and
|
||||
* installs a session-aware replacement once interactive startup is far enough
|
||||
* along to leave the alternate screen and write the debug file. Exactly one
|
||||
* listener must be active: two would conflict (the first calls `process.exit`
|
||||
* before the second runs) and this basic one lacks the visibility behavior.
|
||||
*/
|
||||
export function handleUncaughtException(error: unknown): void {
|
||||
if (isExpectedPtyRaceError(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
writeStderrLine(error.stack ?? error.message);
|
||||
} else {
|
||||
writeStderrLine(String(error));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in new issue
Block a user