diff --git a/.changeset/dev-tunnel-waits-for-dev-server.md b/.changeset/dev-tunnel-waits-for-dev-server.md new file mode 100644 index 0000000000..4ab023ec08 --- /dev/null +++ b/.changeset/dev-tunnel-waits-for-dev-server.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: `pnpm dev --tunnel` waits for the dev server instead of publishing a tunnel to whatever holds the configured port. +category: fix +dev: When the dev child had not reported a bound port within 60s the wrapper fell back to `resolveDevTunnelPort(undefined)` (PORT, else 4040) and published that. In a container whose own Fusion owns 4040 — the case the port fix was written for — this handed out a dev-looking URL serving a different instance, and the only signal was a passing "which may not be it" warning. Observed with a dev server stopped on the interactive `Run central db now? (Y/n)` prompt, which never listens and so never reports. The wait is now unbounded (a tunnel is worthless before the server is up) with a once-a-minute notice naming the interactive-prompt case; an explicit `--tunnel=PORT` still publishes immediately, since that names a target the dev child knows nothing about. diff --git a/docs/contributing.md b/docs/contributing.md index 765adf4f28..8adbbae21f 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -136,6 +136,12 @@ Behaviour worth knowing: - **The token comes from the same place the dashboard's does** — `FUSION_DASHBOARD_TOKEN`, `FUSION_DAEMON_TOKEN`, then `~/.fusion/settings.json`. On a first authenticated run the token may not exist yet when the tunnel comes up; the banner then points at the dashboard's own startup line. +- **The tunnel waits for the dev server, and never guesses.** It publishes only the port the dev + server reports it actually bound. If startup is slow — or stopped on an interactive prompt such as + `Run central db now? (Y/n)` — no tunnel appears and the wrapper says so once a minute. It will not + fall back to the configured port: on a machine where something else already owns that port (a + container whose own Fusion holds 4040) that published a "dev server" URL serving a different + instance entirely. - **A failed tunnel never takes the dev server down.** If `cloudflared` is missing or no URL is published, it logs and carries on; losing a preview URL must not cost you your dev loop. - **Restarts reuse the tunnel.** In `--watch` mode a fresh quick tunnel would hand out a different diff --git a/scripts/dev-with-memory.mjs b/scripts/dev-with-memory.mjs index db90936415..c0ff0035dd 100644 --- a/scripts/dev-with-memory.mjs +++ b/scripts/dev-with-memory.mjs @@ -109,7 +109,13 @@ An explicit `--tunnel=PORT` names a target the operator chose (a Vite server the 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; +/* +FNXC:DevTunnel 2026-08-19-03:38: +How long to wait quietly before saying WHY no tunnel has appeared yet. Startup legitimately takes +minutes (workspace build, embedded Postgres) and can stop dead on an interactive prompt — a real +run sat on "Run central db now? (Y/n)" and never listened at all. +*/ +const DEV_SERVER_REPORT_NOTICE_MS = 60_000; let reportDevServerListening; const devServerListeningReport = new Promise((resolve) => { reportDevServerListening = resolve; }); @@ -117,19 +123,29 @@ async function resolveTunnelTarget() { const configured = resolveDevTunnelPort(undefined); if (tunnelPort) return { port: tunnelPort, token: null, source: "explicit" }; - const timeout = new Promise((resolve) => { - setTimeout(() => resolve(null), DEV_SERVER_PORT_REPORT_TIMEOUT_MS).unref?.(); - }); - const reported = await Promise.race([devServerListeningReport, timeout]); + /* + FNXC:DevTunnel 2026-08-19-03:38: + WAIT for the dev server rather than falling back to the configured port. The old fallback published + a tunnel to whatever held that port, which on the machine this matters for — a container whose own + Fusion owns 4040 — meant handing out a "dev server" URL that served a DIFFERENT instance entirely. + A missing tunnel is a visible, self-explaining problem; a tunnel to the wrong app is a silent one + that costs an afternoon. The wait is unbounded because a tunnel is worthless before the server is + up anyway, and it costs nothing: it holds no work, and the dev loop is already running. + */ + const notice = setInterval(() => { + console.warn(`[fusion:dev] waiting for the dev server to start before tunnelling (nothing is published yet) — if it is asking you something, answer it`); + }, DEV_SERVER_REPORT_NOTICE_MS); + notice.unref?.(); - if (!reported) { - console.warn(`[fusion:dev] dev server never reported its port — tunnelling ${configured}, which may not be it`); - return { port: configured, token: null, source: "assumed" }; + try { + const reported = await devServerListeningReport; + if (reported.port !== configured) { + console.log(`[fusion:dev] dev server bound ${reported.port} (not ${configured}) — tunnelling ${reported.port}`); + } + return { port: reported.port, token: reported.token, source: "reported" }; + } finally { + clearInterval(notice); } - if (reported.port !== configured) { - console.log(`[fusion:dev] dev server bound ${reported.port} (not ${configured}) — tunnelling ${reported.port}`); - } - return { port: reported.port, token: reported.token, source: "reported" }; } async function openDevTunnel() {