|
12 | 12 | // instead of one clear bind error. Individual E2E_*_PORT env vars still |
13 | 13 | // override everything, and E2E_<TARGET>_URL still attaches to a running |
14 | 14 | // instance. |
15 | | -import { createServer, type Server } from "node:net"; |
| 15 | +import { connect, createServer, type Server } from "node:net"; |
16 | 16 | import { resolve } from "node:path"; |
17 | 17 | import { fileURLToPath } from "node:url"; |
18 | 18 |
|
@@ -55,37 +55,70 @@ export const e2ePort = (envVar: string, offset: number): number => { |
55 | 55 | return fromEnv ? Number(fromEnv) : portBlock + offset; |
56 | 56 | }; |
57 | 57 |
|
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> => |
59 | 63 | new Promise((done) => { |
60 | 64 | const server = createServer(); |
61 | 65 | server.once("error", () => done(undefined)); |
62 | | - server.listen(port, host, () => done(server)); |
| 66 | + server.listen(options, () => done(server)); |
63 | 67 | }); |
64 | 68 |
|
65 | 69 | const closeServer = (server: Server): Promise<void> => |
66 | 70 | new Promise((done) => server.close(() => done())); |
67 | 71 |
|
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. |
80 | 111 | 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); |
86 | 119 | return undefined; |
87 | 120 | } |
88 | | - return ok; |
| 121 | + return [v4, v6]; |
89 | 122 | }; |
90 | 123 |
|
91 | 124 | export interface PortClaim { |
|
0 commit comments