fix(tui): restore terminal state on crash and release leaked resources (#1272)

* fix(tui): restore terminal state on crash and release leaked resources

Restore raw mode, cursor, and flow control on uncaughtException, unhandledRejection, and SIGTERM cleanup failure; reclaim pasted image bytes when transcript entries are trimmed; and stop feedback, activity, transcript, footer, and editor timers during shutdown.

* fix(tui): keep crash handlers installed and attach stty to the tty

Keep uncaughtException / unhandledRejection handlers installed for the whole interactive session; removing them right after start() resolved left runtime crashes uncaught. Run stty with stdin inherited from the TTY, since stty fails when stdin is /dev/null.
This commit is contained in:
liruifengv 2026-07-01 21:14:19 +08:00 committed by GitHub
parent b905dd4910
commit 54703d9457
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 251 additions and 40 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Release pasted images and streaming timers once they are no longer shown, so memory stops growing in long sessions.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix the terminal being left in raw mode with a hidden cursor and disabled flow control after a crash or abrupt exit.

View file

@ -1,4 +1,4 @@
import { execSync } from 'node:child_process';
import { execSync, spawnSync } from 'node:child_process';
import { homedir } from 'node:os';
import { join } from 'node:path';
@ -25,6 +25,7 @@ import { KimiTUI } from '#/tui/index';
import { currentTheme, getColorPalette } from '#/tui/theme';
import { combineStartupNotice } from '#/tui/utils/startup';
import { toTerminalHyperlink } from '#/utils/terminal-hyperlink';
import { restoreTerminalModes } from '#/utils/terminal-restore';
import type { CLIOptions } from './options';
import { createCliTelemetryBootstrap, initializeCliTelemetry } from './telemetry';
@ -133,6 +134,59 @@ export async function runShell(
trackLifecycleForSession(tui.getCurrentSessionId(), event, properties);
};
let savedStty: string | undefined;
try {
// stty operates on the terminal behind stdin, so stdin must be the TTY —
// piping /dev/null (ignore) makes stty fail with "not a tty".
const saved = execSync('stty -g', {
encoding: 'utf8',
stdio: ['inherit', 'pipe', 'ignore'],
});
savedStty = typeof saved === 'string' ? saved.trim() : undefined;
execSync('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] });
} catch {
/* ignore */
}
const restoreStty = (): void => {
if (savedStty === undefined) return;
const args = savedStty.split(/\s+/).filter((arg) => arg.length > 0);
if (args.length === 0) return;
spawnSync('stty', args, { stdio: ['inherit', 'ignore', 'ignore'] });
};
// If we crash without going through KimiTUI.stop(), the terminal is left in
// raw mode with a hidden cursor and XON/XOFF flow control disabled. Restore
// both before exiting so the user's shell is usable afterwards.
const emergencyExit = (exitCode: number): void => {
restoreTerminalModes();
restoreStty();
process.exit(exitCode);
};
const onUncaughtException = (error: unknown): void => {
try {
log.error('uncaughtException, restoring terminal and exiting', { error: String(error) });
} catch {
/* ignore */
}
emergencyExit(1);
};
const onUnhandledRejection = (reason: unknown): void => {
try {
log.error('unhandledRejection, restoring terminal and exiting', { reason: String(reason) });
} catch {
/* ignore */
}
emergencyExit(1);
};
process.on('uncaughtException', onUncaughtException);
process.on('unhandledRejection', onUnhandledRejection);
// Remove the crash handlers once the TUI exits cleanly so repeated runShell()
// calls in the same process (e.g. tests) don't accumulate process listeners.
const removeCrashHandlers = (): void => {
process.off('uncaughtException', onUncaughtException);
process.off('unhandledRejection', onUnhandledRejection);
};
tui.onExit = async (exitCode = 0) => {
const sessionId = tui.getCurrentSessionId();
const hasContent = tui.hasSessionContent();
@ -151,13 +205,10 @@ export async function runShell(
if (hints.length > 0) {
process.stderr.write(`\n${hints.join('\n')}\n`);
}
removeCrashHandlers();
restoreStty();
process.exit(exitCode);
};
try {
execSync('stty -ixon', { stdio: 'ignore' });
} catch {
/* ignore */
}
try {
const initStartedAt = Date.now();
await tui.start();
@ -171,6 +222,7 @@ export async function runShell(
mcp_ms: mcpMs,
});
} catch (error) {
removeCrashHandlers();
setCrashPhase('shutdown');
trackLifecycle('exit', { duration_ms: Date.now() - startedAt });
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });

View file

@ -9,6 +9,7 @@ import {
FEEDBACK_ISSUE_URL,
FEEDBACK_STATUS_CANCELLED,
FEEDBACK_STATUS_FALLBACK,
FEEDBACK_STATUS_NETWORK_ERROR,
FEEDBACK_STATUS_NOT_SIGNED_IN,
FEEDBACK_STATUS_SUBMITTING,
FEEDBACK_STATUS_SUCCESS,
@ -58,29 +59,43 @@ export async function handleFeedbackCommand(host: SlashCommandHost): Promise<voi
const version = withFeedbackVersionPrefix(host.state.appState.version);
const spinner = host.showLoginProgressSpinner(FEEDBACK_STATUS_SUBMITTING);
const res = await host.harness.auth.submitFeedback({
content: input.value,
sessionId: host.state.appState.sessionId,
version,
os: `${osType()} ${osRelease()}`,
model: host.state.appState.model.length > 0 ? host.state.appState.model : null,
});
// Guarantee the spinner's underlying setInterval is always cleared, even when
// submitFeedback or submitFeedbackWithAttachments throws — otherwise the
// interval (and its per-frame requestRender) leaks for the rest of the session.
let stopped = false;
const stopSpinner = (opts: { ok: boolean; label: string }): void => {
if (stopped) return;
stopped = true;
spinner.stop(opts);
};
try {
const res = await host.harness.auth.submitFeedback({
content: input.value,
sessionId: host.state.appState.sessionId,
version,
os: `${osType()} ${osRelease()}`,
model: host.state.appState.model.length > 0 ? host.state.appState.model : null,
});
if (res.kind !== 'ok') {
spinner.stop({ ok: false, label: res.message });
fallback(FEEDBACK_STATUS_FALLBACK);
return;
}
if (res.kind !== 'ok') {
stopSpinner({ ok: false, label: res.message });
fallback(FEEDBACK_STATUS_FALLBACK);
return;
}
// Stage 3: prepare and upload each requested attachment independently.
const attachmentFailed = await submitFeedbackWithAttachments(host, res.feedbackId, level);
// Stage 3: prepare and upload each requested attachment independently.
const attachmentFailed = await submitFeedbackWithAttachments(host, res.feedbackId, level);
spinner.stop({ ok: true, label: FEEDBACK_STATUS_SUCCESS });
host.showStatus(feedbackSessionLine(host.state.appState.sessionId));
host.showStatus(feedbackIdLine(res.feedbackId));
host.track(FEEDBACK_TELEMETRY_EVENT);
if (attachmentFailed) {
host.showStatus(FEEDBACK_STATUS_UPLOAD_FAILED);
stopSpinner({ ok: true, label: FEEDBACK_STATUS_SUCCESS });
host.showStatus(feedbackSessionLine(host.state.appState.sessionId));
host.showStatus(feedbackIdLine(res.feedbackId));
host.track(FEEDBACK_TELEMETRY_EVENT);
if (attachmentFailed) {
host.showStatus(FEEDBACK_STATUS_UPLOAD_FAILED);
}
} catch (error) {
stopSpinner({ ok: false, label: FEEDBACK_STATUS_NETWORK_ERROR });
throw error;
}
}

View file

@ -383,6 +383,13 @@ export class FooterComponent implements Component {
}
}
dispose(): void {
if (this.goalTimer !== null) {
clearInterval(this.goalTimer);
this.goalTimer = null;
}
}
private goalWallClockMs(goal: AppState['goal']): number | undefined {
if (goal === null || goal === undefined) return undefined;
if (goal.status !== 'active') return goal.wallClockMs;

View file

@ -59,6 +59,10 @@ export class MoonLoader extends Text {
}
}
dispose(): void {
this.stop();
}
setLabel(label: string): void {
this.label = label;
this.updateDisplay();

View file

@ -284,6 +284,11 @@ export class EditorKeyboardController {
this.pendingExit = null;
}
dispose(): void {
this.clearPendingExit();
this.clearPendingUndoEsc();
}
private armPendingUndoEsc(): void {
this.clearPendingUndoEsc();
const timer = setTimeout(() => {

View file

@ -536,6 +536,7 @@ export class StreamingUIController {
this.disposeAndClearPendingToolComponents();
this._pendingAgentGroup = null;
this._pendingReadGroup = null;
this.resetToolCallState();
}
resetToolCallState(): void {

View file

@ -30,6 +30,7 @@ import { openUrl } from '#/utils/open-url';
import { getInputHistoryFile } from '#/utils/paths';
import { detectFdPath, ensureFdPath } from '#/utils/process/fd-detect';
import { quoteShellArg } from '#/utils/shell-quote';
import { restoreTerminalModes } from '#/utils/terminal-restore';
import { BannerProvider } from './banner/banner-provider';
import { readBannerDisplayState, writeBannerDisplayState } from './banner/state';
@ -565,6 +566,9 @@ export class KimiTUI {
}
private startEventLoop(): void {
// Dispose any previous focus/clipboard/theme tracking so re-entering the
// event loop (e.g. a future TUI reconnect) can't stack duplicate listeners.
this.disposeTerminalTracking();
this.state.ui.start();
this.startClipboardImageHintController();
this.terminalFocusTrackingDispose = installTerminalFocusTracking(this.state);
@ -764,18 +768,42 @@ export class KimiTUI {
this.unregisterSignalHandlers();
this.aborted = true;
this.streamingUI.discardPending();
this.editorKeyboard.clearPendingExit();
// Stop background polling, streaming intervals, and per-component timers
// before tearing the UI down, so they can't keep firing requestRender after
// stop() returns (or leak when stop() runs without process.exit).
this.tasksBrowserController.close();
this.btwPanelController.clear();
this.stopActivitySpinner();
this.streamingUI.disposeActiveCompactionBlock();
this.streamingUI.resetToolUi();
this.disposeTranscriptChildren();
this.editorKeyboard.dispose();
this.state.footer.dispose();
for (const dispose of this.reverseRpcDisposers) {
dispose();
}
this.reverseRpcDisposers.length = 0;
this.disposeTerminalTracking();
await this.closeSession('shutting down');
await this.harness.close();
this.sessionEventHandler.stopAllMcpServerStatusSpinners();
this.uninstallRainbowDance();
await this.state.terminal.drainInput();
this.state.ui.stop();
// Restore the terminal even if closing the session / harness throws — a
// SIGTERM during a network or MCP shutdown must not leave the user stuck in
// raw mode with a hidden cursor.
try {
await this.closeSession('shutting down');
await this.harness.close();
} finally {
this.sessionEventHandler.stopAllMcpServerStatusSpinners();
this.uninstallRainbowDance();
try {
await this.state.terminal.drainInput();
} catch {
// best effort — the terminal may already be dead (SIGHUP / EIO).
}
try {
this.state.ui.stop();
} catch {
// best effort terminal restore.
}
}
if (this.onExit) {
await this.onExit(exitCode);
}
@ -839,6 +867,10 @@ export class KimiTUI {
private emergencyTerminalExit(exitCode = 129): never {
this.isShuttingDown = true;
this.unregisterSignalHandlers();
// Best-effort terminal restore: stop() may not have run (SIGHUP) or may
// have thrown (SIGTERM cleanup failure), so recover raw mode / cursor /
// bracketed paste before exiting instead of leaving the user's shell broken.
restoreTerminalModes();
process.exit(exitCode);
}
@ -1490,6 +1522,7 @@ export class KimiTUI {
for (const dispose of this.reverseRpcDisposers) {
dispose();
}
this.reverseRpcDisposers.length = 0;
}
private registerSessionHandlers(session: Session): void {
@ -1847,6 +1880,16 @@ export class KimiTUI {
this.state.terminal.write(deleteAllKittyImages());
}
private disposeTranscriptChildren(): void {
// Dispose disposable children (e.g. ShellRunComponent's 1s timer,
// ThinkingComponent's spinner) before dropping them, so a /clear, session
// switch, or shutdown can't leak intervals that keep firing requestRender
// on a removed component.
for (const child of this.state.transcriptContainer.children) {
if (hasDispose(child)) child.dispose();
}
}
private clearTranscriptAndRedraw(): void {
this.streamingUI.discardPending();
this.state.transcriptEntries = [];
@ -1854,12 +1897,7 @@ export class KimiTUI {
this.streamingUI.resetLiveText();
this.streamingUI.resetToolUi();
this.sessionEventHandler.stopAllMcpServerStatusSpinners();
// Dispose disposable children (e.g. ShellRunComponent's 1s timer) before
// dropping them, so a /clear or session switch can't leak intervals that
// keep firing requestRender on a removed component.
for (const child of this.state.transcriptContainer.children) {
if (hasDispose(child)) child.dispose();
}
this.disposeTranscriptChildren();
this.state.transcriptContainer.clear();
this.btwPanelController.clear();
this.clearTerminalInlineImages();
@ -1906,6 +1944,15 @@ export class KimiTUI {
const toRemove = turnsToTrim(turns, TRANSCRIPT_MAX_TURNS, TRANSCRIPT_HYSTERESIS);
if (toRemove.size === 0) return false;
// Reclaim image bytes referenced by trimmed user messages. The transcript
// renders historical thumbnails via imageStore.get(id), so an attachment can
// only be dropped once its owning user message leaves the transcript.
for (const entry of toRemove) {
if (entry.kind === 'user' && entry.imageAttachmentIds !== undefined) {
this.imageStore.removeMany(entry.imageAttachmentIds);
}
}
let boundariesToRemove = 0;
for (const entry of toRemove) {
if (

View file

@ -88,6 +88,19 @@ export class ImageAttachmentStore {
this.nextId = 1;
}
/**
* Drop a single attachment, releasing its bytes. Used to reclaim image
* memory once the transcript entry that references it is trimmed.
*/
remove(id: number): void {
this.byId.delete(id);
}
/** Drop many attachments at once. See {@link remove}. */
removeMany(ids: Iterable<number>): void {
for (const id of ids) this.byId.delete(id);
}
size(): number {
return this.byId.size;
}

View file

@ -0,0 +1,31 @@
/**
* Best-effort terminal restoration for crash / emergency-exit paths.
*
* The normal shutdown path goes through pi-tui's `TUI.stop()`, which restores
* raw mode, the cursor, bracketed paste, and the Kitty / modifyOtherKeys
* keyboard protocols. When we bail out without running `TUI.stop()` an
* uncaught exception, a SIGTERM whose cleanup throws, or a SIGHUP the
* terminal would otherwise be left stuck in raw mode with a hidden cursor, and
* the user's shell would look broken afterwards. Writing these sequences lets
* the terminal recover.
*
* Every step is wrapped: the terminal may already be dead (EIO), and an exit
* path must never throw.
*/
// Show cursor (`?25h`), disable bracketed paste (`?2004l`), pop the Kitty
// keyboard protocol (`<u`), and reset modifyOtherKeys (`>4;0m`).
const TERMINAL_RESTORE_SEQUENCE = '\u001B[?25h\u001B[?2004l\u001B[<u\u001B[>4;0m';
export function restoreTerminalModes(): void {
try {
process.stdin.setRawMode(false);
} catch {
// ignore — raw mode may not be active, or stdin may not be a TTY.
}
try {
process.stdout.write(TERMINAL_RESTORE_SEQUENCE);
} catch {
// ignore — the terminal may already be dead (EIO).
}
}

View file

@ -199,7 +199,7 @@ describe('runShell', () => {
expect(mocks.harnessEnsureConfigFile.mock.invocationCallOrder[0]).toBeLessThan(
mocks.harnessGetConfig.mock.invocationCallOrder[0]!,
);
expect(execSync).toHaveBeenCalledWith('stty -ixon', { stdio: 'ignore' });
expect(execSync).toHaveBeenCalledWith('stty -ixon', { stdio: ['inherit', 'ignore', 'ignore'] });
expect(mocks.kimiTuiConstructor).toHaveBeenCalledTimes(1);
expect(mocks.createKimiDeviceId).toHaveBeenCalledWith(
'/tmp/kimi-code-test-home',

View file

@ -59,4 +59,30 @@ describe('ImageAttachmentStore', () => {
const next = s.addImage(new Uint8Array(), 'image/png', 10, 10);
expect(next.id).toBe(1);
});
it('remove() drops a single attachment without resetting ids', () => {
const s = new ImageAttachmentStore();
const a = s.addImage(new Uint8Array([1]), 'image/png', 10, 10);
const b = s.addImage(new Uint8Array([2]), 'image/png', 10, 10);
expect(s.size()).toBe(2);
s.remove(a.id);
expect(s.size()).toBe(1);
expect(s.get(a.id)).toBeUndefined();
expect(s.get(b.id)).toBe(b);
// Unlike clear(), remove() must not reset the id counter.
const next = s.addImage(new Uint8Array([3]), 'image/png', 10, 10);
expect(next.id).toBe(3);
});
it('removeMany() drops many attachments at once', () => {
const s = new ImageAttachmentStore();
const a = s.addImage(new Uint8Array([1]), 'image/png', 10, 10);
const b = s.addImage(new Uint8Array([2]), 'image/png', 10, 10);
const c = s.addImage(new Uint8Array([3]), 'image/png', 10, 10);
s.removeMany([a.id, c.id]);
expect(s.size()).toBe(1);
expect(s.get(b.id)).toBe(b);
expect(s.get(a.id)).toBeUndefined();
expect(s.get(c.id)).toBeUndefined();
});
});