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:
authored and GitHub committed 2026-07-31 08:39:55 +00:00
1 parent 31a26ea99c
commit 90052f25ae
9 files changed
+402 -65

No files matched your search

+13 -48
View File
@@ -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();
+125
View File
@@ -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();
+72 -2
View File
@@ -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 = () => {
+19 -1
View File
@@ -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);
}