fix(cli): stop engine teardown logs repainting shell after TUI quit

Root cause of "the TUI keeps rendering after I get my terminal back": on
quit, dispose() called logSink.releaseConsole() (re-pointing console.* at
the real terminal) and then tui.stop() left the alt-screen and restored the
user's shell. Every log line from the slow engine/mesh/dev-server teardown
that followed then painted over the recovered prompt.

dispose() now calls a new logSink.silence() instead, which drops all sink
and console.* output from quit through process exit. Shutdown-step
diagnostics (timeShutdownStep + the watchdog stall line) are gated behind
FUSION_DEBUG_SHUTDOWN so a normal quit is pristine; the 3s hard-exit
watchdog still guarantees the process dies.

Adds a silence() regression guard to log-sink.test.ts asserting sink
methods and captured console.* both go silent across surfaces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-28 10:56:38 -07:00
parent c48dcae331
commit 2be00efc04
4 changed files with 46 additions and 10 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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();
}