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:
7
.changeset/tui-tunnel-row-and-enter-crash.md
Normal file
7
.changeset/tui-tunnel-row-and-enter-crash.md
Normal 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.
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user