Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions cli/commands/dev/port-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
35 changes: 20 additions & 15 deletions cli/commands/dev/port-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down