Skip to content

Commit 6834eb2

Browse files
committed
Fix bind-probe self-conflict that rejected every port block on Linux
The bind-probe added for the port-claim race bound each candidate port on 0.0.0.0 and then on a plain ::. On Linux a plain :: listen is dual-stack (ipv6Only defaults to false), so it also claims the IPv4 side of the port and EADDRINUSEs against our own just-bound 0.0.0.0 probe. Every block therefore looked squatted, claimPorts walked all 400 blocks, and every e2e globalsetup died with "range is exhausted". macOS's BSD semantics let that pair coexist, which is why it passed local smoke testing. Probe shape now, verified empirically on both macOS and Linux (node/bun in a container): - Connect-probe 127.0.0.1 and ::1 first: on macOS a specific-address listener (a leaked vite dev server) coexists with a wildcard bind, so only a connect can see it. Runs before our own wildcard binds would answer the loopback. - Bind 0.0.0.0, then :: with ipv6Only: true. The v4 side and the v6-only side never overlap, so the pair coexists on both platforms while still catching squatters on either family, including ephemeral outbound sockets. Smoke-tested in a Linux container: a clean environment claims its preferred block with no walk (the shipped failure mode), service-shaped binds (dual-stack :: and 127.0.0.1) succeed after the probes release, and squatters on 0.0.0.0, dual-stack ::, and 127.0.0.1 are each detected.
1 parent 56421d5 commit 6834eb2

1 file changed

Lines changed: 54 additions & 21 deletions

File tree

e2e/src/ports.ts

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// instead of one clear bind error. Individual E2E_*_PORT env vars still
1313
// override everything, and E2E_<TARGET>_URL still attaches to a running
1414
// instance.
15-
import { createServer, type Server } from "node:net";
15+
import { connect, createServer, type Server } from "node:net";
1616
import { resolve } from "node:path";
1717
import { fileURLToPath } from "node:url";
1818

@@ -55,37 +55,70 @@ export const e2ePort = (envVar: string, offset: number): number => {
5555
return fromEnv ? Number(fromEnv) : portBlock + offset;
5656
};
5757

58-
const listenOn = (port: number, host: string): Promise<Server | undefined> =>
58+
const listenOn = (options: {
59+
readonly port: number;
60+
readonly host: string;
61+
readonly ipv6Only?: boolean;
62+
}): Promise<Server | undefined> =>
5963
new Promise((done) => {
6064
const server = createServer();
6165
server.once("error", () => done(undefined));
62-
server.listen(port, host, () => done(server));
66+
server.listen(options, () => done(server));
6367
});
6468

6569
const closeServer = (server: Server): Promise<void> =>
6670
new Promise((done) => server.close(() => done()));
6771

68-
// Bind-probe a single port on BOTH address families and hold both listeners.
69-
// Node's default `listen(port)` binds only `::` (IPv6), and an IPv4 `0.0.0.0`
70-
// listener on the same port coexists with it — so a single-family probe misses
71-
// a squatter on the other family (and our services bind a mix: the WorkOS
72-
// emulator on `::`, vite/dev-db on 127.0.0.1). Binding 0.0.0.0 AND :: means the
73-
// port is only "free" if NEITHER family is taken, and holding both listeners
74-
// keeps it that way until the caller closes them the instant before the real
75-
// services bind. A bind (unlike a connect-probe) also detects an ephemeral
76-
// outbound socket squatting the port. Resolves the held servers on success, or
77-
// undefined if either family was taken (partial binds are closed on failure).
78-
// This shrinks the time-of-check/time-of-use window from seconds (probe, then
79-
// boot) to microseconds (close, then boot).
72+
const isListening = (port: number, host: string): Promise<boolean> =>
73+
new Promise((done) => {
74+
const socket = connect({ port, host });
75+
socket.once("connect", () => {
76+
socket.destroy();
77+
done(true);
78+
});
79+
socket.once("error", () => done(false));
80+
socket.setTimeout(1_000, () => {
81+
socket.destroy();
82+
done(false);
83+
});
84+
});
85+
86+
// Bind-probe a single port and hold the listeners: resolves the held servers
87+
// when the port is provably free, or undefined when anything squats it. Three
88+
// checks, in a deliberate order:
89+
//
90+
// 1. Connect-probe the loopbacks (127.0.0.1 and ::1). On macOS a specific-
91+
// address listener (a leaked vite dev server on 127.0.0.1, or selfhost's
92+
// ::1) coexists with a wildcard bind, so the bind-probes below can't see
93+
// it — but a connect can. Runs FIRST because once our own wildcard probes
94+
// are up they would answer loopback connects themselves. Persistent
95+
// listeners are what this catches, so its check-vs-use gap is harmless.
96+
// 2. Bind 0.0.0.0 — catches v4 squatters, including ephemeral OUTBOUND
97+
// sockets, which no connect-probe can see (nothing is listening).
98+
// 3. Bind :: with ipv6Only — catches v6 squatters. ipv6Only is load-bearing:
99+
// a plain `::` bind is DUAL-STACK on Linux (ipv6Only defaults to false),
100+
// so it also claims the v4 side and EADDRINUSEs against our OWN 0.0.0.0
101+
// probe from step 2, rejecting every block; the v6-ONLY side never
102+
// overlaps the v4 side, so this pair coexists on both Linux and macOS
103+
// (verified empirically on both). Sequential on purpose: the second bind
104+
// must observe the first, not race it.
105+
//
106+
// The caller holds every probe server until the whole block is proven free,
107+
// then closes them the instant before the real services bind — shrinking the
108+
// time-of-check/time-of-use window from seconds (probe, then boot) to
109+
// microseconds (close, then boot). Services bind 127.0.0.1 or dual-stack `::`,
110+
// both of which succeed once the probes are released.
80111
const bindProbe = async (port: number): Promise<ReadonlyArray<Server> | undefined> => {
81-
const wanted = ["0.0.0.0", "::"];
82-
const held = await Promise.all(wanted.map((host) => listenOn(port, host)));
83-
const ok = held.filter((server): server is Server => server !== undefined);
84-
if (ok.length !== wanted.length) {
85-
await Promise.all(ok.map(closeServer));
112+
const loopbacks = await Promise.all(["127.0.0.1", "::1"].map((host) => isListening(port, host)));
113+
if (loopbacks.some(Boolean)) return undefined;
114+
const v4 = await listenOn({ port, host: "0.0.0.0" });
115+
if (!v4) return undefined;
116+
const v6 = await listenOn({ port, host: "::", ipv6Only: true });
117+
if (!v6) {
118+
await closeServer(v4);
86119
return undefined;
87120
}
88-
return ok;
121+
return [v4, v6];
89122
};
90123

91124
export interface PortClaim {

0 commit comments

Comments
 (0)