From 54703d9457dcda7bc782301fc2dbb41a2c8d7293 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 1 Jul 2026 21:14:19 +0800 Subject: [PATCH] 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. --- .changeset/release-tui-resources.md | 5 ++ .changeset/restore-terminal-on-crash.md | 5 ++ apps/kimi-code/src/cli/run-shell.ts | 64 ++++++++++++++-- apps/kimi-code/src/tui/commands/info.ts | 55 +++++++++----- .../src/tui/components/chrome/footer.ts | 7 ++ .../src/tui/components/chrome/moon-loader.ts | 4 + .../src/tui/controllers/editor-keyboard.ts | 5 ++ .../src/tui/controllers/streaming-ui.ts | 1 + apps/kimi-code/src/tui/kimi-tui.ts | 73 +++++++++++++++---- .../src/tui/utils/image-attachment-store.ts | 13 ++++ apps/kimi-code/src/utils/terminal-restore.ts | 31 ++++++++ apps/kimi-code/test/cli/run-shell.test.ts | 2 +- .../tui/input/image-attachment-store.test.ts | 26 +++++++ 13 files changed, 251 insertions(+), 40 deletions(-) create mode 100644 .changeset/release-tui-resources.md create mode 100644 .changeset/restore-terminal-on-crash.md create mode 100644 apps/kimi-code/src/utils/terminal-restore.ts diff --git a/.changeset/release-tui-resources.md b/.changeset/release-tui-resources.md new file mode 100644 index 000000000..296e7340f --- /dev/null +++ b/.changeset/release-tui-resources.md @@ -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. diff --git a/.changeset/restore-terminal-on-crash.md b/.changeset/restore-terminal-on-crash.md new file mode 100644 index 000000000..5563aad77 --- /dev/null +++ b/.changeset/restore-terminal-on-crash.md @@ -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. diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 1bd55a84b..f21046752 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -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 }); diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index 278938c27..51ccd3fc1 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -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 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; } } diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 3f3fc9b5a..b91193b53 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -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; diff --git a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts index ba19b0c2a..a9d21452e 100644 --- a/apps/kimi-code/src/tui/components/chrome/moon-loader.ts +++ b/apps/kimi-code/src/tui/components/chrome/moon-loader.ts @@ -59,6 +59,10 @@ export class MoonLoader extends Text { } } + dispose(): void { + this.stop(); + } + setLabel(label: string): void { this.label = label; this.updateDisplay(); diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 735fb0e0e..f3539d39e 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -284,6 +284,11 @@ export class EditorKeyboardController { this.pendingExit = null; } + dispose(): void { + this.clearPendingExit(); + this.clearPendingUndoEsc(); + } + private armPendingUndoEsc(): void { this.clearPendingUndoEsc(); const timer = setTimeout(() => { diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index cb620801e..8658c7532 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -536,6 +536,7 @@ export class StreamingUIController { this.disposeAndClearPendingToolComponents(); this._pendingAgentGroup = null; this._pendingReadGroup = null; + this.resetToolCallState(); } resetToolCallState(): void { diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 9f9d0d27f..90d4c35e8 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -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 ( diff --git a/apps/kimi-code/src/tui/utils/image-attachment-store.ts b/apps/kimi-code/src/tui/utils/image-attachment-store.ts index bac4ab8fb..a1cff5c3f 100644 --- a/apps/kimi-code/src/tui/utils/image-attachment-store.ts +++ b/apps/kimi-code/src/tui/utils/image-attachment-store.ts @@ -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): void { + for (const id of ids) this.byId.delete(id); + } + size(): number { return this.byId.size; } diff --git a/apps/kimi-code/src/utils/terminal-restore.ts b/apps/kimi-code/src/utils/terminal-restore.ts new file mode 100644 index 000000000..5a93f3821 --- /dev/null +++ b/apps/kimi-code/src/utils/terminal-restore.ts @@ -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 (`4;0m`). +const TERMINAL_RESTORE_SEQUENCE = '\u001B[?25h\u001B[?2004l\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). + } +} diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 27f0bea57..b582c8543 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -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', diff --git a/apps/kimi-code/test/tui/input/image-attachment-store.test.ts b/apps/kimi-code/test/tui/input/image-attachment-store.test.ts index cb3e88c59..6add1e428 100644 --- a/apps/kimi-code/test/tui/input/image-attachment-store.test.ts +++ b/apps/kimi-code/test/tui/input/image-attachment-store.test.ts @@ -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(); + }); });