diff --git a/.changeset/quiet-terminal-exits.md b/.changeset/quiet-terminal-exits.md new file mode 100644 index 0000000000..d113a99c18 --- /dev/null +++ b/.changeset/quiet-terminal-exits.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Persist terminal stream failure diagnostics before emergency exits. diff --git a/.gitignore b/.gitignore index e62f42128f..b517518fbe 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ coverage/ .vitest-results/ .vite/ .DS_Store +.idea/ .playwright-mcp/ .claude .conductor diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 7e0a8ce711..9c7f88e97a 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -22,7 +22,7 @@ import { detectPendingMigration } from '#/migration/index'; import type { TuiConfig } from '#/tui/config'; import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; import { CHROME_GUTTER } from '#/tui/constant/rendering'; -import { KimiTUI } from '#/tui/index'; +import { KimiTUI, type DeadTerminalErrorContext } from '#/tui/index'; import { currentTheme, getColorPalette } from '#/tui/theme'; import { combineStartupNotice } from '#/tui/utils/startup'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; @@ -121,6 +121,37 @@ export async function runShell( }); setCrashPhase('runtime'); + tui.onDeadTerminalError = (context: DeadTerminalErrorContext): void => { + try { + log.error('terminal output stream failed, restoring terminal and exiting', { + error: context.error, + stream: context.stream, + errorCode: context.error.code, + errno: context.error.errno, + syscall: context.error.syscall, + sessionId: context.sessionId, + turnId: context.turnId, + step: context.step, + streamingPhase: context.streamingPhase, + exitCode: 129, + stdoutIsTTY: process.stdout.isTTY, + stderrIsTTY: process.stderr.isTTY, + stdoutDestroyed: process.stdout.destroyed, + stderrDestroyed: process.stderr.destroyed, + pid: process.pid, + ppid: process.ppid, + }); + } catch { + /* ignore */ + } + try { + // The TUI calls process.exit() immediately after this callback returns. + flushDiagnosticLogsSync(); + } catch { + /* ignore */ + } + }; + const trackLifecycleForSession = ( sessionId: string, event: string, diff --git a/apps/kimi-code/src/tui/index.ts b/apps/kimi-code/src/tui/index.ts index 06af194eca..5ae02866db 100644 --- a/apps/kimi-code/src/tui/index.ts +++ b/apps/kimi-code/src/tui/index.ts @@ -1,3 +1,3 @@ export { KimiTUI } from './kimi-tui'; -export type { KimiTUIStartupInput } from './kimi-tui'; +export type { DeadTerminalErrorContext, KimiTUIStartupInput } from './kimi-tui'; export type { KimiTUIOptions } from './types'; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 14951c6ed8..8e0be5bdca 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -184,6 +184,15 @@ export interface KimiTUIStartupInput { readonly migrateOnly?: boolean; } +export interface DeadTerminalErrorContext { + readonly stream: 'stdout' | 'stderr'; + readonly error: NodeJS.ErrnoException; + readonly sessionId?: string; + readonly turnId?: string; + readonly step: number; + readonly streamingPhase: AppState['streamingPhase']; +} + type EffectiveActivityPaneMode = ActivityPaneMode | 'idle' | 'session'; type LoadingTipKind = 'moon' | 'composing'; @@ -359,6 +368,9 @@ export class KimiTUI { public onExit?: (exitCode?: number) => Promise; + /** Called synchronously before a dead output stream triggers the emergency exit. */ + public onDeadTerminalError?: (context: DeadTerminalErrorContext) => void; + /** URL opened in the browser just before exit (e.g. by `/web`); printed by onExit. */ public exitOpenUrl: string | undefined; @@ -906,18 +918,35 @@ export class KimiTUI { }); } - const terminalErrorHandler = (error: Error): void => { - if (isDeadTerminalError(error)) { + const createTerminalErrorHandler = + (stream: DeadTerminalErrorContext['stream']) => + (error: Error): void => { + if (!isDeadTerminalError(error)) return; + try { + const sessionId = this.getCurrentSessionId(); + const { turnId, step } = this.streamingUI.getTurnContext(); + this.onDeadTerminalError?.({ + stream, + error: error as NodeJS.ErrnoException, + sessionId: sessionId === '' ? undefined : sessionId, + turnId, + step, + streamingPhase: this.state.appState.streamingPhase, + }); + } catch { + // Diagnostic recording is best-effort and must not block emergency exit. + } this.emergencyTerminalExit(); - } - }; - process.stdout.on('error', terminalErrorHandler); - process.stderr.on('error', terminalErrorHandler); + }; + const stdoutErrorHandler = createTerminalErrorHandler('stdout'); + const stderrErrorHandler = createTerminalErrorHandler('stderr'); + process.stdout.on('error', stdoutErrorHandler); + process.stderr.on('error', stderrErrorHandler); this.signalCleanupHandlers.push(() => { - process.stdout.off('error', terminalErrorHandler); + process.stdout.off('error', stdoutErrorHandler); }); this.signalCleanupHandlers.push(() => { - process.stderr.off('error', terminalErrorHandler); + process.stderr.off('error', stderrErrorHandler); }); } diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 2275d3a267..ea1ca11fc0 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -1,3 +1,9 @@ +/** + * Scenario: CLI shell startup, shutdown, and fatal-diagnostic wiring. + * Contract: fatal records are persisted synchronously before immediate process exits. + * Boundaries: harness, TUI, telemetry, logger, child_process, and process I/O are mocked. + * Run: pnpm exec vitest run apps/kimi-code/test/cli/run-shell.test.ts + */ import { execSync } from 'node:child_process'; import type { createKimiDeviceId as createKimiDeviceIdFn } from '@moonshot-ai/kimi-code-oauth'; @@ -9,6 +15,22 @@ import { captureProcessWrite, ExitCalled, mockProcessExit } from '../helpers/pro type CreateKimiDeviceId = typeof createKimiDeviceIdFn; +const initialUncaughtExceptionListeners = new Set(process.listeners('uncaughtException')); +const initialUnhandledRejectionListeners = new Set(process.listeners('unhandledRejection')); + +function restoreProcessCrashListeners(): void { + for (const listener of process.listeners('uncaughtException')) { + if (!initialUncaughtExceptionListeners.has(listener)) { + process.off('uncaughtException', listener); + } + } + for (const listener of process.listeners('unhandledRejection')) { + if (!initialUnhandledRejectionListeners.has(listener)) { + process.off('unhandledRejection', listener); + } + } +} + const mocks = vi.hoisted(() => { type TuiConfigFallback = { theme: 'dark' | 'light' | 'auto'; @@ -59,6 +81,8 @@ const mocks = vi.hoisted(() => { })), resolveKimiHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/kimi-code-test-home'), flushDiagnosticLogsSync: vi.fn(), + logError: vi.fn(), + logInfo: vi.fn(), harnessCreatesDeviceIdOnConstruction: false, execSync: vi.fn(), TuiConfigParseError, @@ -71,6 +95,13 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async (importOriginal) => { ...actual, resolveKimiHome: mocks.resolveKimiHome, flushDiagnosticLogsSync: mocks.flushDiagnosticLogsSync, + log: { + error: mocks.logError, + warn: actual.log.warn.bind(actual.log), + info: mocks.logInfo, + debug: actual.log.debug.bind(actual.log), + createChild: actual.log.createChild.bind(actual.log), + }, createKimiHarness: (...args: unknown[]) => { const options = args[0] as { readonly homeDir?: string } | undefined; const homeDir = options?.homeDir ?? '/tmp/kimi-code-test-home'; @@ -121,6 +152,7 @@ vi.mock('../../src/tui/config', () => ({ vi.mock('../../src/tui/index', () => ({ KimiTUI: class { onExit?: () => Promise; + onDeadTerminalError?: (context: Record) => void; constructor(...args: unknown[]) { mocks.kimiTuiConstructor(this, ...args); @@ -147,6 +179,7 @@ vi.mock('node:child_process', () => ({ describe('runShell', () => { afterEach(() => { + restoreProcessCrashListeners(); vi.clearAllMocks(); mocks.harnessGetConfig.mockResolvedValue({ providers: {}, @@ -577,6 +610,79 @@ describe('runShell', () => { } }); + it('flushes a structured dead-terminal record synchronously', async () => { + mocks.loadTuiConfig.mockResolvedValue({ + theme: 'dark', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }); + mocks.tuiStart.mockResolvedValue(undefined); + + await runShell( + { + session: undefined, + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + agent: undefined, + agentFiles: [], + }, + '1.2.3-test', + ); + + const [tui] = mocks.kimiTuiConstructor.mock.calls[0]!; + const error = Object.assign(new Error('write EPIPE'), { + code: 'EPIPE', + errno: -32, + syscall: 'write', + }); + const record = ( + tui as { + onDeadTerminalError: (context: Record) => void; + } + ).onDeadTerminalError; + + record({ + stream: 'stdout', + error, + sessionId: 'ses-dead-terminal', + turnId: 'turn-28', + step: 13, + streamingPhase: 'thinking', + }); + + expect(mocks.logError).toHaveBeenCalledWith( + 'terminal output stream failed, restoring terminal and exiting', + expect.objectContaining({ + error, + stream: 'stdout', + errorCode: 'EPIPE', + errno: -32, + syscall: 'write', + sessionId: 'ses-dead-terminal', + turnId: 'turn-28', + step: 13, + streamingPhase: 'thinking', + exitCode: 129, + stdoutIsTTY: process.stdout.isTTY, + stderrIsTTY: process.stderr.isTTY, + stdoutDestroyed: expect.any(Boolean), + stderrDestroyed: expect.any(Boolean), + pid: process.pid, + ppid: process.ppid, + }), + ); + expect(mocks.flushDiagnosticLogsSync).toHaveBeenCalledOnce(); + expect(mocks.logError.mock.invocationCallOrder[0]!).toBeLessThan( + mocks.flushDiagnosticLogsSync.mock.invocationCallOrder[0]!, + ); + }); + it('flushes diagnostic logs synchronously before exiting on an unhandled rejection', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', diff --git a/apps/kimi-code/test/tui/signal-handlers.test.ts b/apps/kimi-code/test/tui/signal-handlers.test.ts index 91dc5beacf..63633a9657 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -1,3 +1,9 @@ +/** + * Scenario: KimiTUI process-signal and terminal-output failure handling. + * Contract: preserve exit codes and record dead-stream context before emergency exit. + * Boundaries: process listeners and process.exit are mocked; KimiTUI lifecycle code is real. + * Run: pnpm exec vitest run apps/kimi-code/test/tui/signal-handlers.test.ts + */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { KimiTUI, type KimiTUIStartupInput, type TUIState } from '#/tui/kimi-tui'; @@ -246,29 +252,86 @@ describe('KimiTUI signal handlers', () => { expect(exitSpy).toHaveBeenCalledWith(143); }); - it('stdout EIO error triggers emergency exit; ENOENT does not', () => { - const { driver } = makeDriver(); + it('records stdout EIO context before exiting with code 129', () => { + const { driver, tui } = makeDriver(); + driver.state.appState.sessionId = 'ses-dead-terminal'; + driver.state.appState.streamingPhase = 'thinking'; + tui.streamingUI.setTurnId('turn-28'); + tui.streamingUI.setStep(13); + const record = vi.fn(); + tui.onDeadTerminalError = record; const captured = captureHandlers(driver); - const eio = Object.assign(new Error('write EIO'), { code: 'EIO' }); + const eio = Object.assign(new Error('write EIO'), { + code: 'EIO', + errno: -5, + syscall: 'write', + }); captured.stdoutErrorHandler?.(eio); + + expect(record).toHaveBeenCalledWith({ + stream: 'stdout', + error: eio, + sessionId: 'ses-dead-terminal', + turnId: 'turn-28', + step: 13, + streamingPhase: 'thinking', + }); expect(exitSpy).toHaveBeenCalledWith(129); + expect(record.mock.invocationCallOrder[0]!).toBeLessThan(exitSpy.mock.invocationCallOrder[0]); + + captured.restore(); + driver.unregisterSignalHandlers(); + }); + + it('keeps running when stdout reports an unrelated error', () => { + const { driver, tui } = makeDriver(); + const record = vi.fn(); + tui.onDeadTerminalError = record; + const captured = captureHandlers(driver); - exitSpy.mockClear(); const enoent = Object.assign(new Error('not found'), { code: 'ENOENT' }); captured.stdoutErrorHandler?.(enoent); + + expect(record).not.toHaveBeenCalled(); expect(exitSpy).not.toHaveBeenCalled(); captured.restore(); driver.unregisterSignalHandlers(); }); - it('stderr EPIPE error triggers emergency exit', () => { - const { driver } = makeDriver(); + it('records stderr EPIPE context before exiting with code 129', () => { + const { driver, tui } = makeDriver(); + const record = vi.fn(); + tui.onDeadTerminalError = record; const captured = captureHandlers(driver); const epipe = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }); captured.stderrErrorHandler?.(epipe); + + expect(record).toHaveBeenCalledWith( + expect.objectContaining({ + stream: 'stderr', + error: epipe, + streamingPhase: 'idle', + }), + ); + expect(exitSpy).toHaveBeenCalledWith(129); + + captured.restore(); + driver.unregisterSignalHandlers(); + }); + + it('exits when dead-terminal recording throws', () => { + const { driver, tui } = makeDriver(); + tui.onDeadTerminalError = () => { + throw new Error('log unavailable'); + }; + const captured = captureHandlers(driver); + + const epipe = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }); + captured.stdoutErrorHandler?.(epipe); + expect(exitSpy).toHaveBeenCalledWith(129); captured.restore();