From ad24d9a0eb1fbe4de85380fbbb4d886ddf3ede6d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 18 Aug 2026 21:57:57 -0700 Subject: [PATCH] fix: TUI Enter crash, and show any running tunnel in the System panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enter on the System panel killed the TUI. It opens the dashboard URL with a detached spawn, and a MISSING opener — xdg-open on any slim Linux container, exactly where Fusion runs headless — is reported asynchronously as an 'error' event rather than a synchronous throw. The try/catch around spawn never saw it, so Node re-threw an 'error' with no listener and took the dashboard down. The tunnel row now covers any tunnel, not just `pnpm dev --tunnel`: SystemInfo.devTunnelUrl becomes tunnelUrl, fed by the dev wrapper's IPC hand-off AND by the engine's TunnelProcessManager status subscription. An operator's remote tunnel URL previously lived only in the Settings UI and /remote/status, so a headless terminal could not read the address its own Fusion was reachable at. A dev tunnel wins when both exist, and a stopped tunnel clears the row rather than stranding a dead URL. And the panel measures the row it draws. estimateSystemContentRows accounted for URL and Token but not Tunnel, so a wrapping trycloudflare hostname was squeezed out of the panel it had just been added to. Co-Authored-By: Claude Opus 5 --- .changeset/tui-tunnel-row-and-enter-crash.md | 7 ++ .../dashboard-tui/__tests__/app.test.tsx | 21 +++++- .../__tests__/open-in-browser-crash.test.tsx | 71 +++++++++++++++++++ .../cli/src/commands/dashboard-tui/app.tsx | 33 +++++++-- .../cli/src/commands/dashboard-tui/state.ts | 10 +-- packages/cli/src/commands/dashboard.ts | 33 +++++++-- 6 files changed, 158 insertions(+), 17 deletions(-) create mode 100644 .changeset/tui-tunnel-row-and-enter-crash.md create mode 100644 packages/cli/src/commands/dashboard-tui/__tests__/open-in-browser-crash.test.tsx diff --git a/.changeset/tui-tunnel-row-and-enter-crash.md b/.changeset/tui-tunnel-row-and-enter-crash.md new file mode 100644 index 0000000000..671f7652e7 --- /dev/null +++ b/.changeset/tui-tunnel-row-and-enter-crash.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix a TUI crash when pressing Enter on the System panel, and always show a running tunnel's URL there. +category: fix +dev: Three fixes in the dashboard TUI's System panel. (1) Enter opens the dashboard URL with a detached `spawn`; a missing opener (`xdg-open` on slim Linux containers) is reported asynchronously as an `error` event, not a synchronous throw, so the surrounding try/catch never saw it and Node re-threw it as an unhandled `error`, killing the TUI. An `error` listener is now attached. (2) `SystemInfo.devTunnelUrl` becomes `tunnelUrl` and is fed by both sources — the dev wrapper's IPC hand-off and the engine's `TunnelProcessManager` status subscription — so an operator-started remote tunnel, whose URL previously existed only in the Settings UI and `/remote/status`, is readable from a headless terminal. A dev tunnel wins when both are present; a stopped tunnel clears the row. (3) `estimateSystemContentRows` now measures the tunnel row like URL and Token, since an unmeasured row let a wrapping trycloudflare hostname squeeze itself out of the panel. diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx index 095033e829..be896c0348 100644 --- a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx +++ b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { describe, it, expect, vi, afterEach } from "vitest"; import { render } from "ink-testing-library"; import { I18nextProvider } from "react-i18next"; -import { DashboardApp, stripLeadingDetailedTimestamp } from "../app.js"; +import { DashboardApp, estimateSystemContentRows, stripLeadingDetailedTimestamp } from "../app.js"; import { initCliI18n } from "../../../i18n/index.js"; const copyToClipboardMock = vi.hoisted(() => vi.fn()); @@ -253,7 +253,7 @@ describe("DashboardApp smoke", () => { it("renders the dev tunnel URL in the system panel when one is published", () => { const controller = newController(); const { lastFrame, unmount, rerender } = render(renderDashboardAppNode(controller)); - controller.setSystemInfo({ ...makeSystemInfo(), devTunnelUrl: "https://sign-bear-kinds-lay.trycloudflare.com" }); + controller.setSystemInfo({ ...makeSystemInfo(), tunnelUrl: "https://sign-bear-kinds-lay.trycloudflare.com" }); rerender(renderDashboardAppNode(controller)); const frame = lastFrame() ?? ""; expect(frame).toContain("Tunnel"); @@ -261,6 +261,23 @@ describe("DashboardApp smoke", () => { unmount(); }); + /* + FNXC:DevTunnel 2026-08-19-04:45: + The panel sizes itself from its rows, so a row it does not measure gets squeezed out of the panel + it was added to. A trycloudflare hostname is long enough to wrap, which is why the tunnel row must + be measured like URL and Token rather than assumed to be one line. + */ + it("grows the system panel to fit the tunnel row instead of collapsing it", () => { + const tunnelUrl = "https://elvis-background-brochures-addressing.trycloudflare.com"; + const short = estimateSystemContentRows(makeSystemInfo(), 80, false); + const withTunnel = estimateSystemContentRows({ ...makeSystemInfo(), tunnelUrl }, 80, false); + expect(withTunnel).toBeGreaterThan(short); + + // A hostname wider than the panel wraps, and the estimate must follow it. + const narrow = estimateSystemContentRows({ ...makeSystemInfo(), tunnelUrl }, 30, false); + expect(narrow).toBeGreaterThan(withTunnel); + }); + it("shows no tunnel row when no tunnel is running", () => { const controller = newController(); const { lastFrame, unmount, rerender } = render(renderDashboardAppNode(controller)); diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/open-in-browser-crash.test.tsx b/packages/cli/src/commands/dashboard-tui/__tests__/open-in-browser-crash.test.tsx new file mode 100644 index 0000000000..e9f9d0a019 --- /dev/null +++ b/packages/cli/src/commands/dashboard-tui/__tests__/open-in-browser-crash.test.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "node:events"; +import { render } from "ink-testing-library"; +import { I18nextProvider } from "react-i18next"; +import { initCliI18n } from "../../../i18n/index.js"; + +/* +FNXC:DashboardTUI 2026-08-19-04:45: +Pressing Enter on the System panel opens the dashboard URL with a detached `spawn`. A MISSING opener +(`xdg-open` on any slim Linux container — precisely where Fusion runs headless) is reported +asynchronously as an 'error' event, not a synchronous throw, so the try/catch around spawn never saw +it: Node re-throws an 'error' with no listener and the whole TUI died. + +This fake reproduces that exact shape — spawn returns, then emits 'error' on the next tick — and +asserts nothing escapes. Without the listener the emit throws out of the timer callback and fails +this test, which is why it is asserted through a real emit rather than by inspecting handlers. +*/ +const spawnMock = vi.hoisted(() => vi.fn()); +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawn: spawnMock }; +}); + +const { DashboardApp } = await import("../app.js"); +const { DashboardTUI } = await import("../controller.js"); + +const testI18n = initCliI18n("en"); + +function makeSystemInfo() { + return { + host: "localhost", + port: 4040, + baseUrl: "http://localhost:4040", + authEnabled: false, + engineMode: "active" as const, + fileWatcher: true, + startTimeMs: Date.now(), + }; +} + +describe("System panel Enter with no browser opener", () => { + beforeEach(() => { + spawnMock.mockReset(); + }); + + it("survives a spawn that fails asynchronously", async () => { + // Mimics ENOENT from a missing xdg-open: the call returns a child, the failure lands later. + const failing = new EventEmitter() as EventEmitter & { unref: () => void }; + failing.unref = () => undefined; + spawnMock.mockImplementation(() => { + setTimeout(() => failing.emit("error", Object.assign(new Error("spawn xdg-open ENOENT"), { code: "ENOENT" })), 0); + return failing; + }); + + const controller = new DashboardTUI(); + controller.setSystemInfo(makeSystemInfo()); + const { stdin, unmount, rerender } = render( + React.createElement(I18nextProvider, { i18n: testI18n }, React.createElement(DashboardApp, { controller })), + ); + rerender(React.createElement(I18nextProvider, { i18n: testI18n }, React.createElement(DashboardApp, { controller }))); + + stdin.write("\r"); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(spawnMock).toHaveBeenCalled(); + // An 'error' with no listener would have thrown out of the timer and taken the process down. + expect(failing.listenerCount("error")).toBeGreaterThan(0); + unmount(); + }); +}); diff --git a/packages/cli/src/commands/dashboard-tui/app.tsx b/packages/cli/src/commands/dashboard-tui/app.tsx index dcdbfbab1d..d1650f4cca 100644 --- a/packages/cli/src/commands/dashboard-tui/app.tsx +++ b/packages/cli/src/commands/dashboard-tui/app.tsx @@ -45,6 +45,16 @@ function openInBrowser(url: string): void { try { // process-supervisor-allowlist: user-facing browser opener must outlive the TUI process const child = spawn(cmd, args, { detached: true, stdio: "ignore" }); + /* + FNXC:DashboardTUI 2026-08-19-04:45: + A missing opener is reported ASYNCHRONOUSLY as an 'error' event, not a synchronous throw, so the + catch below never saw it: Node re-throws an 'error' with no listener and the TUI died. Pressing + Enter on the System panel therefore crashed the whole dashboard anywhere `xdg-open` is absent — + every slim Linux container, which is exactly where Fusion runs headless. + */ + child.on("error", () => { + // Best-effort — the platform tool is missing or not executable. + }); child.unref(); } catch { // Best-effort — silently ignore if the platform tool isn't available. @@ -193,7 +203,7 @@ const STATIC_SECTION_CONTENT_ROWS: Record = { // and Token (each may wrap onto multiple lines), then an always-on hint // row. The hint and token must always be visible, so we never collapse // those rows regardless of focus. -function estimateSystemContentRows( +export function estimateSystemContentRows( info: SystemInfo | null, cols: number, _isFocused: boolean, @@ -232,9 +242,18 @@ function estimateSystemContentRows( const tokenRows = info.authToken ? Math.max(1, Math.ceil((5 + 1 + info.authToken.length) / inner)) : 0; + /* + FNXC:DevTunnel 2026-08-19-04:45: + The tunnel row is measured like URL and Token ("Tunnel" is 6 chars + a gap). Omitting it made the + panel one-to-several rows too short for a trycloudflare hostname, so the row it was added for got + squeezed out of the panel it lives in. + */ + const tunnelRows = info.tunnelUrl + ? Math.max(1, Math.ceil((6 + 1 + info.tunnelUrl.length) / inner)) + : 0; // +1 for the always-on hint row. - return chipRows + urlRows + tokenRows + 1; + return chipRows + urlRows + tokenRows + tunnelRows + 1; } function estimateSectionPanelHeight( @@ -396,12 +415,14 @@ function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: b {info.authToken} )} - {/* FNXC:DevTunnel 2026-08-19-04:30: the wrapper's tunnel banner is painted over by this - TUI, so the public URL is shown here — full width, never truncated, like the token. */} - {info.devTunnelUrl && ( + {/* FNXC:DevTunnel 2026-08-19-04:45: a tunnel's public URL — dev (--tunnel) or the + operator's own remote tunnel — is shown here full width, never truncated, like the + token. The dev wrapper's stdout banner is painted over by this TUI, and the remote + tunnel's URL never reached the terminal at all. */} + {info.tunnelUrl && ( Tunnel - {info.devTunnelUrl} + {info.tunnelUrl} )} {/* Inline hint row — always shown so the [Enter] / [c] shortcuts diff --git a/packages/cli/src/commands/dashboard-tui/state.ts b/packages/cli/src/commands/dashboard-tui/state.ts index f602e5f2c1..fc51c81d36 100644 --- a/packages/cli/src/commands/dashboard-tui/state.ts +++ b/packages/cli/src/commands/dashboard-tui/state.ts @@ -20,12 +20,12 @@ export interface SystemInfo { tokenizedUrl?: string; /* FNXC:DevTunnel 2026-08-19-04:30: - Public URL of the dev tunnel, when `pnpm dev --tunnel` started one. The wrapper prints its banner - to stdout, but a TTY run hands the screen to this TUI, which paints straight over it — so the one - piece of information the flag exists to produce was unreadable. The wrapper forwards the URL over - the dev IPC channel and it renders here instead. + Public URL of whatever tunnel is exposing this dashboard — `pnpm dev --tunnel` (forwarded over the + dev IPC channel) or the operator's own remote tunnel (from the engine's tunnel manager). Either + way it belongs on screen: the dev wrapper prints a banner that a TTY run's TUI paints straight + over, and the remote tunnel never printed its URL anywhere the terminal could show it at all. */ - devTunnelUrl?: string; + tunnelUrl?: string; engineMode: "no-engine" | "active" | "paused"; fileWatcher: boolean; startTimeMs: number; diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 97532df60c..807fec2b0e 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -813,7 +813,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: complete in either order. */ let devTunnelUrl: string | undefined; - let applyDevTunnelUrl: (() => void) | undefined; + let remoteTunnelUrl: string | undefined; + let applyTunnelUrl: (() => void) | undefined; // Single sink/logger pair for all dashboard command diagnostics. // In TTY mode this routes to DashboardTUI; in non-TTY mode it falls back to console.*. @@ -2305,6 +2306,25 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: ) : undefined; + /* + FNXC:DevTunnel 2026-08-19-04:45: + Surface the operator's own remote tunnel in the system panel too. Its URL previously existed only + in the dashboard's Settings UI and the /remote/status route, so a terminal user running headless + — the exact case a tunnel is for — had no way to read the address their Fusion was reachable at. + Subscribing beats polling: the manager already pushes status, including the transition to null + when the tunnel stops, which must clear the row rather than strand a dead URL on screen. + */ + for (const engine of engineManager.getAllEngines().values()) { + const tunnelManager = engine.getRemoteTunnelManager?.(); + if (!tunnelManager) continue; + tunnelManager.subscribeStatus((status) => { + const url = typeof status?.url === "string" && status.url.length > 0 ? status.url : undefined; + if (url === remoteTunnelUrl) return; + remoteTunnelUrl = url; + applyTunnelUrl?.(); + }); + } + // Get the trigger scheduler from any running engine for (const engine of engineManager.getAllEngines().values()) { const ts = engine.getHeartbeatTriggerScheduler(); @@ -2995,7 +3015,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: if (parsed?.type !== DEV_TUNNEL_READY_MESSAGE) return; if (typeof parsed.url !== "string" || parsed.url.length === 0) return; devTunnelUrl = parsed.url; - applyDevTunnelUrl?.(); + applyTunnelUrl?.(); }); /* @@ -3106,8 +3126,13 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: paints over it, so the public URL (the entire point of the flag) was unreadable. Render it in the system panel instead, whether the URL arrived before this point or arrives later. */ - applyDevTunnelUrl = () => tui.setSystemInfo({ ...systemInfo, devTunnelUrl }); - tui.setSystemInfo({ ...systemInfo, devTunnelUrl }); + /* + FNXC:DevTunnel 2026-08-19-04:45: + A dev tunnel is the one the operator started by hand, so it wins when both exist; otherwise the + remote tunnel's URL shows. Either way the panel is where a terminal user can actually read it. + */ + applyTunnelUrl = () => tui.setSystemInfo({ ...systemInfo, tunnelUrl: devTunnelUrl ?? remoteTunnelUrl }); + applyTunnelUrl(); tui.setReady(true); tui.setSettings({ maxConcurrent: settings.maxConcurrent ?? 1,