fix: wait for the dev server rather than tunnelling a guessed port

When the dev child had not reported a bound port within 60s, the wrapper fell
back to the configured port and published a tunnel to it. In the case the
port fix exists for — a container whose own Fusion owns 4040 — that hands out
a dev-looking URL serving a completely different instance, with only a
passing "which may not be it" warning to show for it.

Observed with a dev server stopped on the interactive `Run central db now?
(Y/n)` prompt: it never listens, so it never reports, so the tunnel published
the container's Fusion instead.

A missing tunnel is a visible problem that explains itself; a tunnel to the
wrong app is a silent one. The wrapper now waits, unbounded, printing a
notice once a minute that names the interactive-prompt case. An explicit
--tunnel=PORT still publishes immediately, since it names a target the dev
child knows nothing about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-18 20:38:48 -07:00
parent 6f461a4744
commit f12b9f8463
3 changed files with 41 additions and 12 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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() {