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
130 changes: 126 additions & 4 deletions cli/commands/dev/port-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { assert, assertEquals, assertStringIncludes } from "#veryfront/testing/a
import { describe, it } from "#veryfront/testing/bdd.ts";
import {
findAvailablePort,
isAddressFamilyUnavailableError,
isPortAvailable,
isPortInUseError,
MAX_PORT_FALLBACK_ATTEMPTS,
Expand All @@ -24,6 +25,29 @@ function probeBusyOn(busy: number[]): {
};
}

/**
* How many freshly reserved ports to try before calling a free-port rejection
* real rather than contention from a parallel job.
*/
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).
*
* 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 {
try {
return Deno.listen({ hostname, port: 0 });
} catch (error) {
if (isAddressFamilyUnavailableError(error)) return null;
throw error;
}
}

Comment thread
kojiwakayama marked this conversation as resolved.
describe("cli/commands/dev/port-fallback", () => {
describe("findAvailablePort", () => {
it("keeps the requested port when it is free", async () => {
Expand Down Expand Up @@ -118,12 +142,49 @@ describe("cli/commands/dev/port-fallback", () => {
}
});

it("skips a port held on IPv6 only", async () => {
// `veryfront dev` starts its MCP server on `localhost`, which resolves to
// ::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");
if (!held) return; // no IPv6 on this host - nothing to collide with

const heldPort = (held.addr as Deno.NetAddr).port;
try {
assertEquals(await isPortAvailable(heldPort), false);
} finally {
held.close();
}
});

it("accepts a port nothing is holding", async () => {
const probeListener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const freePort = (probeListener.addr as Deno.NetAddr).port;
probeListener.close();
// 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
// process claims it - and CI runs ~30 jobs against one host, which is how
// asserting a single arbitrary port stays free ejected an unrelated PR
// from the merge queue.
//
// Retrying on a freshly reserved port drops that assumption without
// softening the assertion: a probe that rejects free ports rejects every
// one of these too, and still fails the test.
const rejected: number[] = [];
let accepted = false;

for (let attempt = 0; attempt < FREE_PORT_ATTEMPTS && !accepted; attempt++) {
const reserved = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const freePort = (reserved.addr as Deno.NetAddr).port;
reserved.close();

assertEquals(await isPortAvailable(freePort), true);
if (await isPortAvailable(freePort)) accepted = true;
else rejected.push(freePort); // lost the port to a parallel job - retry
}

assert(
accepted,
`isPortAvailable() rejected all ${FREE_PORT_ATTEMPTS} just-released ports: ` +
rejected.join(", "),
);
});
});

Expand Down Expand Up @@ -186,4 +247,65 @@ describe("cli/commands/dev/port-fallback", () => {
assert(!isPortInUseError(undefined));
});
});

describe("isAddressFamilyUnavailableError", () => {
// Probing both loopback families must not make a genuinely free port look
// busy on a host that has only one of them - an IPv4-only container would
// otherwise report every port as taken and never fall forward at all.
// The shapes are constructed rather than provoked from a real bind. There
// is no address a test can rely on being unbindable: `::2` is unassigned on
// most hosts but bindable on some, and on Linux with
// `net.ipv6.ip_nonlocal_bind=1` the bind simply succeeds. Depending on that
// would be the same ambient-state assumption this file just removed from
// "accepts a port nothing is holding". Constructing the shapes also reaches
// the Node branch, which a live bind on a Deno host cannot exercise at all.
it("recognises the error class Deno itself raises", () => {
// Deno's own constructor, not a hand-rolled Error with a spoofed name: if
// the runtime ever renames this class the test fails loudly here rather
// than drifting silently away from what the probe actually catches.
const error = new Deno.errors.AddrNotAvailable(
"Can't assign requested address (os error 49)",
);

assert(isAddressFamilyUnavailableError(error), "Deno's AddrNotAvailable must be recognised");
assert(!isPortInUseError(error), "an absent address is not a port collision");
});

it("recognises the Node error shapes", () => {
for (const code of ["EADDRNOTAVAIL", "EAFNOSUPPORT"]) {
const error = Object.assign(new Error(`listen ${code} ::1`), { code });
assert(isAddressFamilyUnavailableError(error), `${code} must be recognised`);
}
});

it("recognises a missing family reported only in the message", () => {
// Some runtimes surface the failure with neither a `code` nor a
// distinguishing `name` - EAFNOSUPPORT reaches Deno this way.
const messages = [
"Cannot assign requested address (os error 99)",
"Can't assign requested address (os error 49)",
"listen EADDRNOTAVAIL: address not available",
"Address family not supported by protocol (os error 97)",
"listen EAFNOSUPPORT ::1",
];

for (const message of messages) {
assert(
isAddressFamilyUnavailableError(new Error(message)),
`must be recognised from the message alone: ${message}`,
);
}
});

it("does not treat a port collision or an unrelated failure as a missing family", () => {
const inUse = Object.assign(new Error("listen EADDRINUSE: address already in use"), {
code: "EADDRINUSE",
});

assert(!isAddressFamilyUnavailableError(inUse));
assert(!isAddressFamilyUnavailableError(new Error("boom")));
assert(!isAddressFamilyUnavailableError("EADDRNOTAVAIL"));
assert(!isAddressFamilyUnavailableError(undefined));
});
});
});
106 changes: 85 additions & 21 deletions cli/commands/dev/port-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,84 @@ export function isPortInUseError(error: unknown): boolean {
}

/**
* Binds `port` and releases it again, to see whether the dev server could have it.
* True when `error` means "this host has no address in that family at all",
* rather than "that port is taken".
*
* The two have to be told apart, because probing a family a host does not have
* must not make every port look busy: an IPv4-only CI container or a machine
* with IPv6 disabled would otherwise never find a free port to fall back to.
*/
export function isAddressFamilyUnavailableError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
// Deno names EADDRNOTAVAIL "AddrNotAvailable"; EAFNOSUPPORT surfaces only in
// the message. Node reports both through `code`.
const code = (error as { code?: string }).code ?? "";
const message = error.message.toLowerCase();
return error.name === "AddrNotAvailable" ||
code === "EADDRNOTAVAIL" || code === "EAFNOSUPPORT" ||
message.includes("eaddrnotavail") || message.includes("eafnosupport") ||
message.includes("assign requested address") ||
message.includes("address family not supported");
}

/**
* The loopback addresses a `veryfront dev` listener can land on.
*
* 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`.
*
* 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.
*
* 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.
*/
const PROBE_HOSTNAMES: readonly string[] = [LOCALHOST.IPV4, LOCALHOST.IPV6];

/** What one bind-and-release attempt learned about a port on one address. */
type ProbeOutcome = "free" | "in-use" | "no-such-family";

function probeWithDeno(deno: typeof Deno, hostname: string, port: number): ProbeOutcome {
try {
deno.listen({ hostname, port }).close();
return "free";
} catch (error) {
if (isPortInUseError(error)) return "in-use";
if (isAddressFamilyUnavailableError(error)) return "no-such-family";
throw error;
}
}

async function probeWithNode(hostname: string, port: number): Promise<ProbeOutcome> {
const net = await import("node:net");
return await new Promise<ProbeOutcome>((resolve, reject) => {
const server = net.createServer();
server.unref?.();
server.once("error", (error: unknown) => {
if (isPortInUseError(error)) resolve("in-use");
else if (isAddressFamilyUnavailableError(error)) resolve("no-such-family");
else reject(error);
});
server.listen({ port, host: hostname, exclusive: true }, () => {
server.close(() => resolve("free"));
});
});
}

/**
* Binds `port` on every loopback family the dev server might use, and releases
* it again, to see whether the dev server could have it.
*
* A port counts as available only when nothing holds it on *any* of those
* addresses - see `PROBE_HOSTNAMES` for why one family is not enough. A family
* the host does not have is skipped rather than counted as a collision.
*
* The Deno runtime is read through `getDenoRuntime()` rather than through the
* `Deno` global directly: dnt rewrites every bare `Deno.` reference in the
Expand All @@ -46,28 +123,15 @@ export function isPortInUseError(error: unknown): boolean {
*/
export async function isPortAvailable(port: number): Promise<boolean> {
const deno = getDenoRuntime();
if (deno) {
try {
deno.listen({ hostname: LOCALHOST.IPV4, port }).close();
return true;
} catch (error) {
if (isPortInUseError(error)) return false;
throw error;
}

for (const hostname of PROBE_HOSTNAMES) {
const outcome = deno
? probeWithDeno(deno, hostname, port)
: await probeWithNode(hostname, port);
if (outcome === "in-use") return false;
}

const net = await import("node:net");
return await new Promise<boolean>((resolve, reject) => {
const server = net.createServer();
server.unref?.();
server.once("error", (error: unknown) => {
if (isPortInUseError(error)) resolve(false);
else reject(error);
});
server.listen({ port, host: LOCALHOST.IPV4, exclusive: true }, () => {
server.close(() => resolve(true));
});
});
return true;
}

/**
Expand Down