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 { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
import { render } from "ink-testing-library";
|
import { render } from "ink-testing-library";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import { DashboardApp, stripLeadingDetailedTimestamp } from "../app.js";
|
import { DashboardApp, estimateSystemContentRows, stripLeadingDetailedTimestamp } from "../app.js";
|
||||||
import { initCliI18n } from "../../../i18n/index.js";
|
import { initCliI18n } from "../../../i18n/index.js";
|
||||||
|
|
||||||
const copyToClipboardMock = vi.hoisted(() => vi.fn());
|
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", () => {
|
it("renders the dev tunnel URL in the system panel when one is published", () => {
|
||||||
const controller = newController();
|
const controller = newController();
|
||||||
const { lastFrame, unmount, rerender } = render(renderDashboardAppNode(controller));
|
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));
|
rerender(renderDashboardAppNode(controller));
|
||||||
const frame = lastFrame() ?? "";
|
const frame = lastFrame() ?? "";
|
||||||
expect(frame).toContain("Tunnel");
|
expect(frame).toContain("Tunnel");
|
||||||
@@ -261,6 +261,23 @@ describe("DashboardApp smoke", () => {
|
|||||||
unmount();
|
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", () => {
|
it("shows no tunnel row when no tunnel is running", () => {
|
||||||
const controller = newController();
|
const controller = newController();
|
||||||
const { lastFrame, unmount, rerender } = render(renderDashboardAppNode(controller));
|
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 {
|
try {
|
||||||
// process-supervisor-allowlist: user-facing browser opener must outlive the TUI process
|
// process-supervisor-allowlist: user-facing browser opener must outlive the TUI process
|
||||||
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
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();
|
child.unref();
|
||||||
} catch {
|
} catch {
|
||||||
// Best-effort — silently ignore if the platform tool isn't available.
|
// 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
|
// 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
|
// row. The hint and token must always be visible, so we never collapse
|
||||||
// those rows regardless of focus.
|
// those rows regardless of focus.
|
||||||
function estimateSystemContentRows(
|
export function estimateSystemContentRows(
|
||||||
info: SystemInfo | null,
|
info: SystemInfo | null,
|
||||||
cols: number,
|
cols: number,
|
||||||
_isFocused: boolean,
|
_isFocused: boolean,
|
||||||
@@ -232,9 +242,18 @@ function estimateSystemContentRows(
|
|||||||
const tokenRows = info.authToken
|
const tokenRows = info.authToken
|
||||||
? Math.max(1, Math.ceil((5 + 1 + info.authToken.length) / inner))
|
? Math.max(1, Math.ceil((5 + 1 + info.authToken.length) / inner))
|
||||||
: 0;
|
: 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.
|
// +1 for the always-on hint row.
|
||||||
return chipRows + urlRows + tokenRows + 1;
|
return chipRows + urlRows + tokenRows + tunnelRows + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function estimateSectionPanelHeight(
|
function estimateSectionPanelHeight(
|
||||||
@@ -396,12 +415,14 @@ function SystemPanel({ state, isFocused }: { state: DashboardState; isFocused: b
|
|||||||
<Text color="yellow">{info.authToken}</Text>
|
<Text color="yellow">{info.authToken}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{/* FNXC:DevTunnel 2026-08-19-04:30: the wrapper's tunnel banner is painted over by this
|
{/* FNXC:DevTunnel 2026-08-19-04:45: a tunnel's public URL — dev (--tunnel) or the
|
||||||
TUI, so the public URL is shown here — full width, never truncated, like the token. */}
|
operator's own remote tunnel — is shown here full width, never truncated, like the
|
||||||
{info.devTunnelUrl && (
|
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}>
|
<Box flexDirection="row" gap={1} flexShrink={0}>
|
||||||
<Text dimColor>Tunnel</Text>
|
<Text dimColor>Tunnel</Text>
|
||||||
<Text color="cyanBright">{info.devTunnelUrl}</Text>
|
<Text color="cyanBright">{info.tunnelUrl}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{/* Inline hint row — always shown so the [Enter] / [c] shortcuts
|
{/* Inline hint row — always shown so the [Enter] / [c] shortcuts
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ export interface SystemInfo {
|
|||||||
tokenizedUrl?: string;
|
tokenizedUrl?: string;
|
||||||
/*
|
/*
|
||||||
FNXC:DevTunnel 2026-08-19-04:30:
|
FNXC:DevTunnel 2026-08-19-04:30:
|
||||||
Public URL of the dev tunnel, when `pnpm dev --tunnel` started one. The wrapper prints its banner
|
Public URL of whatever tunnel is exposing this dashboard — `pnpm dev --tunnel` (forwarded over the
|
||||||
to stdout, but a TTY run hands the screen to this TUI, which paints straight over it — so the one
|
dev IPC channel) or the operator's own remote tunnel (from the engine's tunnel manager). Either
|
||||||
piece of information the flag exists to produce was unreadable. The wrapper forwards the URL over
|
way it belongs on screen: the dev wrapper prints a banner that a TTY run's TUI paints straight
|
||||||
the dev IPC channel and it renders here instead.
|
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";
|
engineMode: "no-engine" | "active" | "paused";
|
||||||
fileWatcher: boolean;
|
fileWatcher: boolean;
|
||||||
startTimeMs: number;
|
startTimeMs: number;
|
||||||
|
|||||||
@@ -813,7 +813,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
|||||||
complete in either order.
|
complete in either order.
|
||||||
*/
|
*/
|
||||||
let devTunnelUrl: string | undefined;
|
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.
|
// 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.*.
|
// 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;
|
: 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
|
// Get the trigger scheduler from any running engine
|
||||||
for (const engine of engineManager.getAllEngines().values()) {
|
for (const engine of engineManager.getAllEngines().values()) {
|
||||||
const ts = engine.getHeartbeatTriggerScheduler();
|
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 (parsed?.type !== DEV_TUNNEL_READY_MESSAGE) return;
|
||||||
if (typeof parsed.url !== "string" || parsed.url.length === 0) return;
|
if (typeof parsed.url !== "string" || parsed.url.length === 0) return;
|
||||||
devTunnelUrl = parsed.url;
|
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
|
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.
|
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.setReady(true);
|
||||||
tui.setSettings({
|
tui.setSettings({
|
||||||
maxConcurrent: settings.maxConcurrent ?? 1,
|
maxConcurrent: settings.maxConcurrent ?? 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user