mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
b905dd4910
commit
54703d9457
13 changed files with 251 additions and 40 deletions
5
.changeset/release-tui-resources.md
Normal file
5
.changeset/release-tui-resources.md
Normal 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.
|
||||
5
.changeset/restore-terminal-on-crash.md
Normal file
5
.changeset/restore-terminal-on-crash.md
Normal 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.
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@ export class MoonLoader extends Text {
|
|||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
setLabel(label: string): void {
|
||||
this.label = label;
|
||||
this.updateDisplay();
|
||||
|
|
|
|||
|
|
@ -284,6 +284,11 @@ export class EditorKeyboardController {
|
|||
this.pendingExit = null;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clearPendingExit();
|
||||
this.clearPendingUndoEsc();
|
||||
}
|
||||
|
||||
private armPendingUndoEsc(): void {
|
||||
this.clearPendingUndoEsc();
|
||||
const timer = setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -536,6 +536,7 @@ export class StreamingUIController {
|
|||
this.disposeAndClearPendingToolComponents();
|
||||
this._pendingAgentGroup = null;
|
||||
this._pendingReadGroup = null;
|
||||
this.resetToolCallState();
|
||||
}
|
||||
|
||||
resetToolCallState(): void {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
31
apps/kimi-code/src/utils/terminal-restore.ts
Normal file
31
apps/kimi-code/src/utils/terminal-restore.ts
Normal 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).
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue