fix: TUI Enter crash, and show any running tunnel in the System panel

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 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-18 21:57:57 -07:00
parent 5422063fb9
commit ad24d9a0eb
6 changed files with 158 additions and 17 deletions

View File

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

View File

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

View File

@@ -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<typeof import("node:child_process")>();
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();
});
});

View File

@@ -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<string, number> = {
// 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
<Text color="yellow">{info.authToken}</Text>
</Box>
)}
{/* 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 && (
<Box flexDirection="row" gap={1} flexShrink={0}>
<Text dimColor>Tunnel</Text>
<Text color="cyanBright">{info.devTunnelUrl}</Text>
<Text color="cyanBright">{info.tunnelUrl}</Text>
</Box>
)}
{/* Inline hint row — always shown so the [Enter] / [c] shortcuts

View File

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

View File

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