diff --git a/cli/commands/dev/port-fallback.test.ts b/cli/commands/dev/port-fallback.test.ts index 33f4b9e815..943f223914 100644 --- a/cli/commands/dev/port-fallback.test.ts +++ b/cli/commands/dev/port-fallback.test.ts @@ -32,14 +32,14 @@ function probeBusyOn(busy: number[]): { const FREE_PORT_ATTEMPTS = 25; /** - * Binds an ephemeral loopback port on one family, or returns null when the host - * has no address in that family at all (CI containers are routinely IPv4-only). + * Binds an ephemeral port on one address, or returns null when the host has no + * address in that family at all (CI containers are routinely IPv4-only). * * Only a missing address family is worth skipping for. Every other bind failure * - a permission error, a resource limit - is rethrown, so it fails the test * that called this rather than quietly turning it into a no-op. */ -function listenOnLoopback(hostname: string): Deno.Listener | null { +function listenOn(hostname: string): Deno.Listener | null { try { return Deno.listen({ hostname, port: 0 }); } catch (error) { @@ -147,7 +147,7 @@ describe("cli/commands/dev/port-fallback", () => { // ::1 wherever IPv6 is available - so a second dev server's port scan sees // an IPv4-only probe succeed on a port the first instance already holds, // and hands out a port that is not actually free. - const held = listenOnLoopback("::1"); + const held = listenOn("::1"); if (!held) return; // no IPv6 on this host - nothing to collide with const heldPort = (held.addr as Deno.NetAddr).port; @@ -158,6 +158,27 @@ describe("cli/commands/dev/port-fallback", () => { } }); + it("skips a port held on a wildcard address", async () => { + // BSD and macOS let a more specific address bind over a wildcard holder, + // so loopback-only probes call a bare `listen(port)` port free and the + // dev server lands beside that listener instead of falling forward. + for (const wildcard of ["::", "0.0.0.0"]) { + const held = listenOn(wildcard); + if (!held) continue; // no address in that family on this host + + const heldPort = (held.addr as Deno.NetAddr).port; + try { + assertEquals( + await isPortAvailable(heldPort), + false, + `a listener on ${wildcard}:${heldPort} must count as holding that port`, + ); + } finally { + held.close(); + } + } + }); + it("accepts a port nothing is holding", async () => { // Nothing can hold a port open and leave it free to bind at the same // time, so a port this test releases is only free until some other diff --git a/cli/commands/dev/port-fallback.ts b/cli/commands/dev/port-fallback.ts index 78a1e81a05..3f69229130 100644 --- a/cli/commands/dev/port-fallback.ts +++ b/cli/commands/dev/port-fallback.ts @@ -54,26 +54,31 @@ export function isAddressFamilyUnavailableError(error: unknown): boolean { message.includes("address family not supported"); } +/** The wildcards a listener binds when it names no host. */ +const ANY_ADDRESS = Object.freeze({ IPV4: "0.0.0.0", IPV6: "::" } as const); + /** - * The loopback addresses a `veryfront dev` listener can land on. + * The addresses a port has to be free on before `veryfront dev` can have it. * - * Which family a listener gets is decided by the runtime, not by the CLI: the - * Deno HTTP adapter defaults to `LOCALHOST.IPV4`, while the Node adapter that - * the published npm build runs defaults to the *name* `localhost`, which - * resolves to `::1` first on any dual-stack host. That is why one `veryfront - * dev` serves the app on `127.0.0.1:3000` but its MCP server - `--port + 2` - - * on `[::1]:3002`. + * Both loopback families are probed because the runtime, not the CLI, picks + * which one a listener lands on: the Deno adapter defaults to `LOCALHOST.IPV4`, + * the published npm build's Node adapter to the name `localhost`, which + * resolves to `::1` first on a dual-stack host. * - * A probe that bound only IPv4 therefore reported IPv6-held ports as free, and - * a second `veryfront dev` announced "Port 3000 is in use, using 3002 instead" - * while 3002 was already reserved by the first instance's MCP server. Nothing - * hard-failed only because the two listeners landed on different families. + * Both wildcards are probed because a bare `listen(port)` binds `::` or + * `0.0.0.0`, and BSD and macOS let a more specific address bind over a wildcard + * holder - so loopback-only probes call such a port free and the dev server + * silently lands beside the existing listener instead of falling forward. * - * The literal addresses are probed rather than the name `localhost` because a - * listen on a name binds just the first address it resolves to, which would - * leave the other family unchecked exactly as before. + * Literal addresses rather than the name `localhost`: a listen on a name binds + * only the first address it resolves to, leaving the other family unchecked. */ -const PROBE_HOSTNAMES: readonly string[] = [LOCALHOST.IPV4, LOCALHOST.IPV6]; +const PROBE_HOSTNAMES: readonly string[] = [ + LOCALHOST.IPV4, + LOCALHOST.IPV6, + ANY_ADDRESS.IPV4, + ANY_ADDRESS.IPV6, +]; /** What one bind-and-release attempt learned about a port on one address. */ type ProbeOutcome = "free" | "in-use" | "no-such-family";