Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-terminal-exits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Persist terminal stream failure diagnostics before emergency exits.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ coverage/
.vitest-results/
.vite/
.DS_Store
.idea/
.playwright-mcp/
.claude
.conductor
Expand Down
33 changes: 32 additions & 1 deletion apps/kimi-code/src/cli/run-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/index.ts
Original file line number Diff line number Diff line change
@@ -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';
45 changes: 37 additions & 8 deletions apps/kimi-code/src/tui/kimi-tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -359,6 +368,9 @@ export class KimiTUI {

public onExit?: (exitCode?: number) => Promise<void>;

/** 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;

Expand Down Expand Up @@ -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);
});
}

Expand Down
106 changes: 106 additions & 0 deletions apps/kimi-code/test/cli/run-shell.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -121,6 +152,7 @@ vi.mock('../../src/tui/config', () => ({
vi.mock('../../src/tui/index', () => ({
KimiTUI: class {
onExit?: () => Promise<void>;
onDeadTerminalError?: (context: Record<string, unknown>) => void;

constructor(...args: unknown[]) {
mocks.kimiTuiConstructor(this, ...args);
Expand All @@ -147,6 +179,7 @@ vi.mock('node:child_process', () => ({

describe('runShell', () => {
afterEach(() => {
restoreProcessCrashListeners();
vi.clearAllMocks();
mocks.harnessGetConfig.mockResolvedValue({
providers: {},
Expand Down Expand Up @@ -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<string, unknown>) => 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',
Expand Down
75 changes: 69 additions & 6 deletions apps/kimi-code/test/tui/signal-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down