fix: tunnel the dev server's real port, not whatever holds 4040

The tunnel target was resolved from PORT/4040 before anything bound, but an
occupied port makes the dashboard silently rebind to an ephemeral one
(server.listen(0) on EADDRINUSE). With a normal Fusion already running on
4040, `pnpm dev --tunnel` therefore published THAT instance under a
dev-looking URL while the dev server sat unreachable on a random port.

The dashboard now reports the port it actually bound to the dev supervisor
over IPC, and the wrapper tunnels that:

- IPC is enabled whenever --tunnel is set, not only in watch mode; a plain
  tunnel run previously had no channel at all.
- The tunnel waits for the report (60s cap, then falls back to the configured
  port with a warning), so it also cannot come up against a port nothing is
  serving yet.
- A mismatch is logged instead of silent.
- A reported port is treated as the dashboard whatever its number, so the
  banner keeps printing the bearer token; without that, the ephemeral case
  would classify the dev dashboard as a foreign port and drop it.
- An explicit --tunnel=PORT names a target the dev child knows nothing about,
  so it never waits and is still compared against the configured port.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-18 18:49:42 -07:00
parent ee57f8a3b9
commit 0289d26b13
6 changed files with 132 additions and 18 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: `pnpm dev --tunnel` now tunnels the dev server's real port instead of another instance on 4040.
category: fix
dev: The tunnel target came from `PORT`/4040, resolved before anything bound. When that port was occupied the dashboard silently rebound to an ephemeral port (`server.listen(0)` on EADDRINUSE), so with a normal Fusion already on 4040 the tunnel published that instance under a dev-looking URL. The dashboard now reports its bound port to the dev supervisor over IPC (`DEV_SERVER_LISTENING_MESSAGE`, a no-op without an IPC channel), the wrapper enables IPC whenever `--tunnel` is set rather than only in watch mode, and the tunnel waits for that report (60s cap, falling back to the configured port with a warning) before starting — which also stops it coming up against a port nothing serves yet. A reported port is treated as the dashboard whatever its number, so the banner still prints the bearer token; an explicit `--tunnel=PORT` never waits and is still compared against the configured dashboard port.

View File

@@ -6,6 +6,7 @@ import {
getPrebuildCommand, getPrebuildCommand,
normalizePrebuildMode, normalizePrebuildMode,
parseDevWrapperArgs, parseDevWrapperArgs,
readDevServerListeningPort,
resolveDevTunnelPort, resolveDevTunnelPort,
resolvePrebuildMode, resolvePrebuildMode,
} from "../../../../scripts/dev-with-memory-lib.mjs"; } from "../../../../scripts/dev-with-memory-lib.mjs";
@@ -409,6 +410,30 @@ describe("development source restart watcher", () => {
expect(auth()).toEqual({ kind: "token-pending" }); expect(auth()).toEqual({ kind: "token-pending" });
}); });
/*
FNXC:DevTunnel 2026-08-19-02:05:
The port the dev server is ASKED for is not the port it gets: an occupied port makes the
dashboard rebind to an ephemeral one. With a normal Fusion already on 4040 the tunnel therefore
pointed at THAT instance and served the wrong app under a dev-looking URL. The child's listening
report is the only fact about where the dev server actually is.
*/
it("reads the dev server's bound port from its listening report only", () => {
expect(readDevServerListeningPort({ type: "fusion:dev-server-listening", port: 51234 })).toBe(51234);
expect(readDevServerListeningPort({ type: "fusion:dev-source-restart-armed" })).toBeNull();
expect(readDevServerListeningPort({ type: "fusion:dev-server-listening" })).toBeNull();
expect(readDevServerListeningPort({ type: "fusion:dev-server-listening", port: 0 })).toBeNull();
expect(readDevServerListeningPort({ type: "fusion:dev-server-listening", port: "51234" })).toBe(51234);
expect(readDevServerListeningPort(null)).toBeNull();
expect(readDevServerListeningPort("fusion:dev-server-listening")).toBeNull();
});
it("still treats a dev dashboard on an ephemeral port as token-gated", () => {
// The reported port IS the dashboard, so the banner must keep lending it the token rather
// than classifying it "foreign" for not matching 4040.
expect(auth({ port: 51234, dashboardPort: 51234, readToken: () => "fn_abc" }))
.toEqual({ kind: "token", token: "fn_abc" });
});
it("prints an openable URL for the token case and a warning only where it is true", () => { it("prints an openable URL for the token case and a warning only where it is true", () => {
const url = "https://neat-fox-tree.trycloudflare.com"; const url = "https://neat-fox-tree.trycloudflare.com";
const tokenLines = formatDevTunnelBanner({ url, port: 4040, auth: { kind: "token", token: "fn_abc" } }).join("\n"); const tokenLines = formatDevTunnelBanner({ url, port: 4040, auth: { kind: "token", token: "fn_abc" } }).join("\n");

View File

@@ -147,6 +147,7 @@ import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type G
import { DASHBOARD_STARTUP_STATUS, runTuiStartupPrelude } from "./dashboard-startup-chain.js"; import { DASHBOARD_STARTUP_STATUS, runTuiStartupPrelude } from "./dashboard-startup-chain.js";
import { phaseTime } from "../startup-phase.js"; import { phaseTime } from "../startup-phase.js";
import { import {
DEV_SERVER_LISTENING_MESSAGE,
DEV_SOURCE_RESTART_ARMED_MESSAGE, DEV_SOURCE_RESTART_ARMED_MESSAGE,
registerDevSourceRestart, registerDevSourceRestart,
} from "./dev-source-restart.js"; } from "./dev-source-restart.js";
@@ -2963,6 +2964,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
logSink.warn(`Port ${selectedPort} in use, using ${actualPort} instead`, "dashboard"); logSink.warn(`Port ${selectedPort} in use, using ${actualPort} instead`, "dashboard");
} }
// FNXC:DevTunnel 2026-08-19-02:05: report the REAL port to the dev supervisor (no-op without an
// IPC channel, i.e. every non-`pnpm dev` launch). See DEV_SERVER_LISTENING_MESSAGE.
process.send?.({ type: DEV_SERVER_LISTENING_MESSAGE, port: actualPort, host: selectedHost });
// ── mDNS discovery: broadcast presence and listen for other nodes ─────── // ── mDNS discovery: broadcast presence and listen for other nodes ───────
// //
// Advertises this node on the local network and discovers other Fusion nodes // Advertises this node on the local network and discovers other Fusion nodes

View File

@@ -3,6 +3,16 @@ import type { EventEmitter } from "node:events";
export const DEV_SOURCE_CHANGE_MESSAGE = "fusion:dev-source-changed"; export const DEV_SOURCE_CHANGE_MESSAGE = "fusion:dev-source-changed";
export const DEV_SOURCE_RESTART_ARMED_MESSAGE = "fusion:dev-source-restart-armed"; export const DEV_SOURCE_RESTART_ARMED_MESSAGE = "fusion:dev-source-restart-armed";
/**
* FNXC:DevTunnel 2026-08-19-02:05:
* The dev supervisor cannot know which port the dashboard ended up on: an occupied port makes the
* server silently rebind to an ephemeral one (`listen(0)`), so the port the supervisor ASKED for is
* a guess, not a fact. The child reports the bound port over the existing IPC channel so
* `--tunnel` targets the dev server rather than whatever else holds the configured port — which,
* when a normal Fusion is already running on 4040, was the other instance entirely.
*/
export const DEV_SERVER_LISTENING_MESSAGE = "fusion:dev-server-listening";
interface DevSourceChangeMessage { interface DevSourceChangeMessage {
type: typeof DEV_SOURCE_CHANGE_MESSAGE; type: typeof DEV_SOURCE_CHANGE_MESSAGE;
} }

View File

@@ -204,12 +204,32 @@ export function parseDevWrapperArgs(rawArgs, env = process.env) {
}; };
} }
/*
FNXC:DevTunnel 2026-08-19-02:05:
Mirrors DEV_SERVER_LISTENING_MESSAGE in packages/cli/src/commands/dev-source-restart.ts. The literal
is duplicated rather than imported because this wrapper is plain JS that must not load the TS build.
*/
export const DEV_SERVER_LISTENING_MESSAGE = "fusion:dev-server-listening";
/** Port from a dev child's listening report, or null for any other message. */
export function readDevServerListeningPort(message) {
if (!message || typeof message !== "object") return null;
if (message.type !== DEV_SERVER_LISTENING_MESSAGE) return null;
const port = Number(message.port);
return Number.isInteger(port) && port > 0 ? port : null;
}
/** /**
* Port the tunnel should point at. * Port the tunnel should point at.
* *
* FNXC:DevTunnel 2026-08-18-23:40: defaults to the dashboard's port, because `pnpm dev` with no * FNXC:DevTunnel 2026-08-18-23:40: defaults to the dashboard's port, because `pnpm dev` with no
* target starts the dashboard. An explicit `--tunnel=PORT` wins so a Vite dev server (or anything * target starts the dashboard. An explicit `--tunnel=PORT` wins so a Vite dev server (or anything
* else the operator started) can be exposed instead. * else the operator started) can be exposed instead.
*
* FNXC:DevTunnel 2026-08-19-02:05: this is the port the dev server is ASKED for, which is only a
* guess — an occupied port makes it rebind to an ephemeral one. Without an explicit --tunnel=PORT
* the caller must prefer the port the child reports over this value; see
* readDevServerListeningPort.
*/ */
export function resolveDevTunnelPort(tunnelPort, env = process.env) { export function resolveDevTunnelPort(tunnelPort, env = process.env) {
if (tunnelPort) return tunnelPort; if (tunnelPort) return tunnelPort;

View File

@@ -14,6 +14,7 @@ import {
createDevWatchRestartCoordinator, createDevWatchRestartCoordinator,
getPrebuildCommand, getPrebuildCommand,
parseDevWrapperArgs, parseDevWrapperArgs,
readDevServerListeningPort,
resolveDevTunnelPort, resolveDevTunnelPort,
resolvePrebuildMode, resolvePrebuildMode,
} from "./dev-with-memory-lib.mjs"; } from "./dev-with-memory-lib.mjs";
@@ -97,6 +98,59 @@ function ensureSourceWatcher() {
console.log(`[fusion:dev] source watch active (${sourceWatcher.watchedPaths.join(", ")})`); console.log(`[fusion:dev] source watch active (${sourceWatcher.watchedPaths.join(", ")})`);
} }
/*
FNXC:DevTunnel 2026-08-19-02:05:
Which port to tunnel is NOT knowable up front. `resolveDevTunnelPort` returns the port the dev
server is asked for, but an occupied port makes the dashboard rebind to an ephemeral one — so with a
normal Fusion already holding 4040, the tunnel pointed at THAT instance and served the wrong app
under a dev-looking URL. Wait for the child's listening report and tunnel the port it actually got.
An explicit `--tunnel=PORT` names a target the operator chose (a Vite server the dev child knows
nothing about), so it is used immediately and never waits. If the report never arrives the tunnel
still comes up on the configured port, since a mis-targeted preview beats no preview at all.
*/
const DEV_SERVER_PORT_REPORT_TIMEOUT_MS = 60_000;
let reportDevServerPort;
const devServerPortReport = new Promise((resolve) => { reportDevServerPort = resolve; });
async function resolveTunnelTargetPort() {
if (tunnelPort) return { port: tunnelPort, source: "explicit" };
const configured = resolveDevTunnelPort(undefined);
const timeout = new Promise((resolve) => {
setTimeout(() => resolve(null), DEV_SERVER_PORT_REPORT_TIMEOUT_MS).unref?.();
});
const reported = await Promise.race([devServerPortReport, timeout]);
if (reported == null) {
console.warn(`[fusion:dev] dev server never reported its port — tunnelling ${configured}, which may not be it`);
return { port: configured, source: "assumed" };
}
if (reported !== configured) {
console.log(`[fusion:dev] dev server bound ${reported} (not ${configured}) — tunnelling ${reported}`);
}
return { port: reported, source: "reported" };
}
async function openDevTunnel() {
const { port, source } = await resolveTunnelTargetPort();
/*
FNXC:DevTunnel 2026-08-19-02:05:
A port the CHILD reported is the dev dashboard by definition, whatever number it landed on — so it
is its own dashboardPort. Treating it as "some other port" would drop the token from the banner
precisely in the ephemeral-rebind case this fix exists for. An explicit --tunnel=PORT names an
arbitrary target, so that one is still compared against the configured dashboard port.
*/
const dashboardPort = source === "explicit" ? resolveDevTunnelPort(undefined) : port;
/*
FNXC:DevTunnel 2026-08-19-01:18:
Resolved at print time (not at parse time) so the token the dev child mints on a first
authenticated run is already on disk by the time the banner needs it.
*/
const auth = resolveDevTunnelAuth({ port, dashboardPort, args: forwardedArgs });
devTunnel = await startDevTunnel({ port, auth });
}
function runApp(extraArgs) { function runApp(extraArgs) {
const tsx = spawn(process.execPath, buildDevNodeArgs({ const tsx = spawn(process.execPath, buildDevNodeArgs({
inspectFlags, inspectFlags,
@@ -105,7 +159,9 @@ function runApp(extraArgs) {
entry: ENTRY, entry: ENTRY,
args: extraArgs, args: extraArgs,
}), { }), {
stdio: watchSource ? ["inherit", "inherit", "inherit", "ipc"] : "inherit", // FNXC:DevTunnel 2026-08-19-02:05: the tunnel needs the child's IPC channel too, to learn the
// port it actually bound — not only watch mode.
stdio: (watchSource || tunnel) ? ["inherit", "inherit", "inherit", "ipc"] : "inherit",
// FNXC:SystemPanel 2026-07-25-10:05: stamp the supervisor pid alongside the // FNXC:SystemPanel 2026-07-25-10:05: stamp the supervisor pid alongside the
// flag so the child can tell a real supervising parent from an inherited // flag so the child can tell a real supervising parent from an inherited
// copy of the variable (see hasLiveSupervisingParent in commands/dashboard.ts). // copy of the variable (see hasLiveSupervisingParent in commands/dashboard.ts).
@@ -126,26 +182,17 @@ function runApp(extraArgs) {
unchanged and a fresh quick tunnel would hand out a different hostname every reload. unchanged and a fresh quick tunnel would hand out a different hostname every reload.
*/ */
if (tunnel && !devTunnel) { if (tunnel && !devTunnel) {
const port = resolveDevTunnelPort(tunnelPort);
/*
FNXC:DevTunnel 2026-08-19-01:18:
Resolved at print time (not at parse time) so the token the dev child mints on a first
authenticated run is already on disk by the time the banner needs it.
*/
const auth = resolveDevTunnelAuth({
port,
dashboardPort: resolveDevTunnelPort(undefined),
args: forwardedArgs,
});
devTunnel = { url: null, stop: () => {} }; devTunnel = { url: null, stop: () => {} };
void startDevTunnel({ port, auth }) void openDevTunnel().catch((error) => {
.then((started) => { devTunnel = started; }) console.error(`[fusion:dev] tunnel error: ${error instanceof Error ? error.message : String(error)}`);
.catch((error) => { });
console.error(`[fusion:dev] tunnel error: ${error instanceof Error ? error.message : String(error)}`);
});
} }
watchRestart.attach(tsx); watchRestart.attach(tsx);
tsx.on("message", (message) => watchRestart.onMessage(message)); tsx.on("message", (message) => {
const listeningPort = readDevServerListeningPort(message);
if (listeningPort) reportDevServerPort(listeningPort);
watchRestart.onMessage(message);
});
ensureSourceWatcher(); ensureSourceWatcher();
tsx.on("close", (c) => { tsx.on("close", (c) => {
const sourceRestart = watchRestart.detach(tsx); const sourceRestart = watchRestart.detach(tsx);