From 0289d26b13e3a114b241d52f7f62acc5a6ad2676 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 18 Aug 2026 18:49:42 -0700 Subject: [PATCH] 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 --- .changeset/dev-tunnel-targets-bound-port.md | 7 ++ .../src/__tests__/dev-with-memory-lib.test.ts | 25 ++++++ packages/cli/src/commands/dashboard.ts | 5 ++ .../cli/src/commands/dev-source-restart.ts | 10 +++ scripts/dev-with-memory-lib.mjs | 20 +++++ scripts/dev-with-memory.mjs | 83 +++++++++++++++---- 6 files changed, 132 insertions(+), 18 deletions(-) create mode 100644 .changeset/dev-tunnel-targets-bound-port.md diff --git a/.changeset/dev-tunnel-targets-bound-port.md b/.changeset/dev-tunnel-targets-bound-port.md new file mode 100644 index 0000000000..d97bc95fbc --- /dev/null +++ b/.changeset/dev-tunnel-targets-bound-port.md @@ -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. diff --git a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts index 7cd31a5382..4a977624a8 100644 --- a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts +++ b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts @@ -6,6 +6,7 @@ import { getPrebuildCommand, normalizePrebuildMode, parseDevWrapperArgs, + readDevServerListeningPort, resolveDevTunnelPort, resolvePrebuildMode, } from "../../../../scripts/dev-with-memory-lib.mjs"; @@ -409,6 +410,30 @@ describe("development source restart watcher", () => { 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", () => { const url = "https://neat-fox-tree.trycloudflare.com"; const tokenLines = formatDevTunnelBanner({ url, port: 4040, auth: { kind: "token", token: "fn_abc" } }).join("\n"); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 01f9fdcdd4..a61144dc81 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -147,6 +147,7 @@ import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type G import { DASHBOARD_STARTUP_STATUS, runTuiStartupPrelude } from "./dashboard-startup-chain.js"; import { phaseTime } from "../startup-phase.js"; import { + DEV_SERVER_LISTENING_MESSAGE, DEV_SOURCE_RESTART_ARMED_MESSAGE, registerDevSourceRestart, } 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"); } + // 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 ─────── // // Advertises this node on the local network and discovers other Fusion nodes diff --git a/packages/cli/src/commands/dev-source-restart.ts b/packages/cli/src/commands/dev-source-restart.ts index 5609ea7a47..820cd47c81 100644 --- a/packages/cli/src/commands/dev-source-restart.ts +++ b/packages/cli/src/commands/dev-source-restart.ts @@ -3,6 +3,16 @@ import type { EventEmitter } from "node:events"; export const DEV_SOURCE_CHANGE_MESSAGE = "fusion:dev-source-changed"; 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 { type: typeof DEV_SOURCE_CHANGE_MESSAGE; } diff --git a/scripts/dev-with-memory-lib.mjs b/scripts/dev-with-memory-lib.mjs index 6aa38ee4a3..a7f12d0c1d 100644 --- a/scripts/dev-with-memory-lib.mjs +++ b/scripts/dev-with-memory-lib.mjs @@ -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. * * 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 * 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) { if (tunnelPort) return tunnelPort; diff --git a/scripts/dev-with-memory.mjs b/scripts/dev-with-memory.mjs index 4c31e6c8b2..2b3ea83213 100644 --- a/scripts/dev-with-memory.mjs +++ b/scripts/dev-with-memory.mjs @@ -14,6 +14,7 @@ import { createDevWatchRestartCoordinator, getPrebuildCommand, parseDevWrapperArgs, + readDevServerListeningPort, resolveDevTunnelPort, resolvePrebuildMode, } from "./dev-with-memory-lib.mjs"; @@ -97,6 +98,59 @@ function ensureSourceWatcher() { 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) { const tsx = spawn(process.execPath, buildDevNodeArgs({ inspectFlags, @@ -105,7 +159,9 @@ function runApp(extraArgs) { entry: ENTRY, 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 // flag so the child can tell a real supervising parent from an inherited // 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. */ 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: () => {} }; - void startDevTunnel({ port, auth }) - .then((started) => { devTunnel = started; }) - .catch((error) => { - console.error(`[fusion:dev] tunnel error: ${error instanceof Error ? error.message : String(error)}`); - }); + void openDevTunnel().catch((error) => { + console.error(`[fusion:dev] tunnel error: ${error instanceof Error ? error.message : String(error)}`); + }); } 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(); tsx.on("close", (c) => { const sourceRestart = watchRestart.detach(tsx);