diff --git a/.changeset/tui-quit-hard-exit.md b/.changeset/tui-quit-hard-exit.md index 22e1ace74b..eb1ea434e2 100644 --- a/.changeset/tui-quit-hard-exit.md +++ b/.changeset/tui-quit-hard-exit.md @@ -2,6 +2,6 @@ "@runfusion/fusion": patch --- -summary: Pressing q (or Ctrl+C) in the TUI now always quits, even if a teardown step stalls. +summary: Pressing q (or Ctrl+C) in the TUI now quits cleanly without engine logs bleeding onto your shell. category: fix -dev: dashboard.ts shutdown/devShutdown arm an unref'd 3s hard-exit watchdog on the first signal and force an immediate process.exit(0) on a second signal, so a hung stopAllDevServers/engine/central-core teardown can no longer leave the process alive repainting the restored shell. Each teardown step now runs through timeShutdownStep, which tracks the in-flight step so the watchdog names the exact stalling step on stderr; set FUSION_DEBUG_SHUTDOWN=1 for per-step timings (slow steps >1s are always surfaced). +dev: Two-part fix. (1) dashboard.ts shutdown/devShutdown arm an unref'd 3s hard-exit watchdog on the first signal and force an immediate process.exit(0) on a second signal, so a hung stopAllDevServers/engine/central-core teardown can no longer leave the process alive. (2) Root cause of the "TUI keeps rendering after q" symptom: dispose() called logSink.releaseConsole() (re-pointing console.* at the terminal) before tui.stop() restored the shell, so slow engine/mesh/dev-server teardown logs painted over the recovered prompt. dispose() now calls the new logSink.silence() instead, dropping all sink + console.* output from quit to exit. Shutdown step diagnostics (timeShutdownStep + the watchdog stall line) are gated behind FUSION_DEBUG_SHUTDOWN=1 so a normal quit is pristine. diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/log-sink.test.ts b/packages/cli/src/commands/dashboard-tui/__tests__/log-sink.test.ts index 6ae6bfdd35..95ea5247e6 100644 Binary files a/packages/cli/src/commands/dashboard-tui/__tests__/log-sink.test.ts and b/packages/cli/src/commands/dashboard-tui/__tests__/log-sink.test.ts differ diff --git a/packages/cli/src/commands/dashboard-tui/log-sink.ts b/packages/cli/src/commands/dashboard-tui/log-sink.ts index 44644d6144..4fd582ae28 100644 --- a/packages/cli/src/commands/dashboard-tui/log-sink.ts +++ b/packages/cli/src/commands/dashboard-tui/log-sink.ts @@ -70,6 +70,7 @@ export interface LogSinkTarget { export class DashboardLogSink { private tui: LogSinkTarget | null = null; private isTTY: boolean; + private silenced = false; private originalConsole: { log: typeof console.log; warn: typeof console.warn; @@ -86,7 +87,28 @@ export class DashboardLogSink { this.isTTY = true; } + /* + FNXC:DashboardShutdown 2026-06-28-00:00: + Quit teardown spilled engine/mesh/dev-server log lines onto the user's + restored shell — after `q`, dispose() called releaseConsole() (re-pointing + console.* at the real terminal) and then tui.stop() left the alt-screen, so + every subsequent slow-teardown log painted over the recovered prompt. That + read as "the TUI keeps rendering after I get my terminal back." + silence() makes the sink — and console.* — drop everything from here to + process exit. It is irreversible by design; only shutdown calls it. + */ + silence(): void { + this.silenced = true; + const noop = (): void => {}; + // Drop direct console.* from engine teardown too (captureConsole patched + // these to route here at startup; repoint them to no-ops, not the shell). + console.log = noop; + console.warn = noop; + console.error = noop; + } + log(message: string, prefix?: string): void { + if (this.silenced) return; const line = prefix ? `[${prefix}] ${message}` : message; if (this.tui && this.isTTY) { this.tui.log(message, prefix); @@ -98,6 +120,7 @@ export class DashboardLogSink { } warn(message: string, prefix?: string): void { + if (this.silenced) return; const line = prefix ? `[${prefix}] ${message}` : message; if (this.tui && this.isTTY) { this.tui.warn(message, prefix); @@ -109,6 +132,7 @@ export class DashboardLogSink { } error(message: string, prefix?: string): void { + if (this.silenced) return; const line = prefix ? `[${prefix}] ${message}` : message; if (this.tui && this.isTTY) { this.tui.error(message, prefix); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index e95f39efd3..926880b145 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -1128,7 +1128,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: let currentShutdownStep: string | null = null; function armHardExitWatchdog(): void { setTimeout(() => { - if (currentShutdownStep) { + // Only surface the stall on stderr under FUSION_DEBUG_SHUTDOWN — by this + // point tui.stop() has restored the user's shell, so an unconditional + // write would itself paint the recovered prompt. The force-exit always + // happens regardless of the flag. + if (currentShutdownStep && process.env.FUSION_DEBUG_SHUTDOWN) { process.stderr.write( `fusion: graceful shutdown stalled on "${currentShutdownStep}" after ${SHUTDOWN_HARD_EXIT_GRACE_MS}ms — forcing exit\n`, ); @@ -1144,9 +1148,12 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: try { await fn(); const ms = Date.now() - startedAt; - if (debug) process.stderr.write(`fusion: shutdown step: ${label} done in ${ms}ms\n`); - else if (ms >= SHUTDOWN_STEP_SLOW_MS) { - process.stderr.write(`fusion: slow shutdown step: ${label} took ${ms}ms\n`); + // Per-step timing only under the debug flag. A non-debug stderr write for + // "slow" steps would paint the shell tui.stop() already restored; the + // SHUTDOWN_STEP_SLOW_MS threshold only governs debug emphasis now. + if (debug) { + const slow = ms >= SHUTDOWN_STEP_SLOW_MS ? " (slow)" : ""; + process.stderr.write(`fusion: shutdown step: ${label} done in ${ms}ms${slow}\n`); } } catch (err) { // Best-effort teardown: log and continue so one failing step can't strand @@ -1671,10 +1678,15 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // Stop TUI if active if (tui) { - // Restore console.* before stopping the TUI so any log lines emitted - // during teardown (or by late-firing listeners) go to the real terminal - // instead of a ring buffer that's about to disappear. - logSink.releaseConsole(); + // FNXC:DashboardShutdown 2026-06-28-00:00: + // Silence (do NOT releaseConsole) before stopping the TUI. tui.stop() + // leaves the alt-screen and restores the user's shell prompt; releasing + // console here would re-point console.* at that restored shell, so the + // engine/mesh/dev-server logs emitted during the slow teardown that + // follows painted over the recovered prompt — the "TUI keeps rendering + // after q" regression. We are exiting; drop teardown output instead. + // FUSION_DEBUG_SHUTDOWN still surfaces per-step timing on stderr. + logSink.silence(); void tui.stop(); }