1010 * rather than re-implements it.
1111 */
1212
13- import { spawn } from 'node:child_process' ;
13+ import { execFileSync , spawn } from 'node:child_process' ;
1414import { resolve } from 'node:path' ;
1515import { fileURLToPath } from 'node:url' ;
1616
@@ -20,9 +20,202 @@ const HERE = resolve(fileURLToPath(import.meta.url), '..');
2020export const CLI = resolve ( HERE , '../../bin/run-dev.js' ) ;
2121export const TSX = resolve ( HERE , '../../../../node_modules/.bin/tsx' ) ;
2222
23- /** A random high port, so a run never contends with a dev server on this host. */
23+ /**
24+ * The bind probe, run in a throwaway Node process: bind `0.0.0.0:<want>`, print
25+ * the port the kernel actually assigned, close. `want = 0` asks the kernel to
26+ * choose. Returns `null` when the bind failed — which for a specific `want`
27+ * means "that port is taken", and for `0` means the probe itself malfunctioned.
28+ *
29+ * ## Why a SUBPROCESS rather than an in-process `net.createServer()`
30+ *
31+ * `net.Server#listen()` reports its assigned port ASYNCHRONOUSLY. Measured on
32+ * this container (node 22.22.2): `server.address()` is `null` on the very next
33+ * line after `listen(0, '0.0.0.0')`, so an in-process probe can only be
34+ * `async`. `randomPort()` below is called from ~14 sites across 8 other files
35+ * in this directory, every one of them passing it straight into a `spawn()`
36+ * argument list; turning it async would edit all of them for no behavioural
37+ * gain. A `node -e` child does the same bind and is synchronous from this
38+ * process's point of view.
39+ *
40+ * The price, measured on the container this suite runs in: ~72-75 ms per draw
41+ * (10-draw means, `NODE_OPTIONS` inherited 74.7 ms, cleared 71.7 ms). Against
42+ * this package's own measured per-spawn floor — 2.9 s for `node bin/run.js
43+ * --version`, 6.5 s for the tsx source entry — one draw is ~2.6% of the
44+ * cheapest thing it precedes, and ~14 draws are ~1 s against a suite whose wall
45+ * was 495.8 s when `vitest.config.ts` last measured it (~0.2%).
46+ *
47+ * `NODE_OPTIONS` is cleared for the probe: it is a bare `net` bind, so nothing
48+ * this suite loads applies to it, and an inherited `--import` hook would run in
49+ * it for no reason.
50+ */
51+ function probeBind ( want : number ) : number | null {
52+ const src = [
53+ "const net = require('node:net');" ,
54+ 'const want = Number(process.argv[1] || 0);' ,
55+ 'const s = net.createServer();' ,
56+ "s.on('error', () => process.exit(3));" ,
57+ "s.listen(want, '0.0.0.0', () => {" ,
58+ ' const p = s.address().port;' ,
59+ ' s.close(() => process.stdout.write(String(p)));' ,
60+ '});' ,
61+ ] . join ( '\n' ) ;
62+ try {
63+ const out = execFileSync ( process . execPath , [ '-e' , src , String ( want ) ] , {
64+ encoding : 'utf8' ,
65+ stdio : [ 'ignore' , 'pipe' , 'pipe' ] ,
66+ timeout : 30_000 ,
67+ env : { ...process . env , NODE_OPTIONS : '' } ,
68+ } ) ;
69+ const port = Number ( out . trim ( ) ) ;
70+ return Number . isInteger ( port ) && port > 0 ? port : null ;
71+ } catch {
72+ return null ;
73+ }
74+ }
75+
76+ /**
77+ * The ONE port draw for every e2e spawn in this directory — a real bind probe,
78+ * not a blind `Math.random()` (#12441).
79+ *
80+ * Listen on `0.0.0.0:0`, read the port the kernel assigned, close the listener,
81+ * return the port for the caller to hand to `os serve`.
82+ *
83+ * ## ⚠️ What this guarantees, and what it does NOT — both halves, deliberately
84+ *
85+ * It is **still TOCTOU**. The listener is closed before `serve` binds, so the
86+ * port is unheld across the close-to-spawn gap and this can still lose a race.
87+ * What it actually buys:
88+ *
89+ * • It never draws a port that is **already held**. A blind draw picks a
90+ * number out of a range without asking anyone, so a neighbouring agent's
91+ * dev server — bound for that process's entire lifetime, minutes or hours —
92+ * is a live target on every single draw. The kernel does not assign a port
93+ * that is currently bound, so that whole population is off the table.
94+ * • It narrows the window from "the whole run" to "one close-to-spawn gap"
95+ * (milliseconds). Only something that binds *inside* that gap can take it.
96+ * • It removes this directory's second collision source. There used to be
97+ * three independent draws over two overlapping ranges (41000-60000 here,
98+ * 40000-60000 in `serve-app-anchored-optional-import.e2e.test.ts`), which
99+ * could collide with EACH OTHER under `--maxWorkers > 1`, not only with a
100+ * neighbour. There is one draw now and it asks the kernel.
101+ *
102+ * ⛔ The comment this replaced claimed "a run never contends with another
103+ * agent's dev server on this host". That unqualified negative is the reason
104+ * nobody re-examined the draw until a real run in this fleet went red on
105+ * `✗ Port 49402 is already in use`. **Do not write another one here.** The
106+ * residual race is real and unclosed; what pays for it is
107+ * `portContentionError()` below, which makes the residual failure SAY it is a
108+ * port race instead of `serve exited 1 before "Server is ready"`.
109+ *
110+ * ⚠️ One property that is worse than the old range and is stated rather than
111+ * hidden: the kernel assigns from its ephemeral range
112+ * (`/proc/sys/net/ipv4/ip_local_port_range`, 32768-60999 on this container),
113+ * which is also where it draws source ports for OUTBOUND connections. The old
114+ * 40000-60000 range overlapped that anyway, and the probe's win — never
115+ * handing out a port some listener already holds — is the larger term. Nothing
116+ * here makes the port immune once it is handed over.
117+ */
118+ export function reservePort ( ) : number {
119+ // Retried, because a `null` for `want = 0` is a malfunctioning probe (the
120+ // kernel cannot answer "busy" to a request for any free port), and turning a
121+ // transient subprocess hiccup into a hard failure would replace one flake
122+ // class with another.
123+ for ( let attempt = 0 ; attempt < 3 ; attempt ++ ) {
124+ const port = probeBind ( 0 ) ;
125+ if ( port !== null ) return port ;
126+ }
127+ throw new Error (
128+ 'bind probe failed: could not obtain a free TCP port from the kernel after 3 attempts '
129+ + '(listen on 0.0.0.0:0 in a `node -e` child). This is a host problem, not a verdict '
130+ + 'about the code under test.' ,
131+ ) ;
132+ }
133+
134+ /**
135+ * Is `port` bindable RIGHT NOW? The **negative arm** of the same probe.
136+ *
137+ * Exported so `serve-port-bind-probe.test.ts` can prove the instrument is able
138+ * to answer NO: a probe that reports "free" for a port the test is holding open
139+ * is not an instrument, and every claim `reservePort()` makes rests on this
140+ * being a real bind rather than a shape that always succeeds.
141+ */
142+ export function portIsFree ( port : number | string ) : boolean {
143+ return probeBind ( Number ( port ) ) !== null ;
144+ }
145+
146+ /**
147+ * `reservePort()` as a string, for the call sites that pass a port straight
148+ * into a `spawn()` argument list.
149+ *
150+ * ⚠️ The name is kept — and is now a slight misnomer, which is cheaper than the
151+ * alternative. It is `String(reservePort())` and nothing else; the draw, the
152+ * guarantees and the residual race are all documented on `reservePort()` above
153+ * and there is no second mechanism hiding behind this name. Renaming it would
154+ * be a rename-only edit across the 8 other files in this directory that call
155+ * it, which is churn this change deliberately does not spend.
156+ */
24157export function randomPort ( ) : string {
25- return String ( 40000 + Math . floor ( Math . random ( ) * 20000 ) ) ;
158+ return String ( reservePort ( ) ) ;
159+ }
160+
161+ /** How a boot says the port was taken — `serve.ts`'s own diagnostic, then the raw kernel error. */
162+ const PORT_TAKEN_PATTERNS = [
163+ / P o r t ( \d + ) i s a l r e a d y i n u s e / ,
164+ / E A D D R I N U S E [ ^ \n ] * ?: ( \d + ) / ,
165+ ] ;
166+
167+ /**
168+ * ⭐ Turn a boot that died on a taken port into a failure that SAYS SO (#12441
169+ * ruling ④).
170+ *
171+ * Returns an `Error` when `output` shows the child could not bind, else `null`.
172+ *
173+ * ## Why this exists, and why it is the half that pays
174+ *
175+ * The measured cost of a lost port race here is not the lost run. It is *"a red
176+ * suite that is not reproducible, on a test file the reader has no reason to
177+ * connect to a port"* — the failure surfaced as `serve exited 1 before "Server
178+ * is ready"` inside a file about `NODE_ENV` defaulting, and it cost an agent a
179+ * round to decide whether the failure belonged to the change under test. A fix
180+ * that only lowers the probability leaves that cost exactly where it was, just
181+ * rarer and therefore even more surprising when it lands.
182+ *
183+ * So the port number is read out of the CHILD's own diagnostic rather than
184+ * passed in: whatever the harness thought it reserved, the number the child
185+ * printed is the one that was contended. `probedPort` is threaded in only to
186+ * say, in the message, that the port WAS probed free moments earlier — which is
187+ * what tells the reader this is the residual TOCTOU gap and not a harness that
188+ * never looked.
189+ */
190+ export function portContentionError (
191+ output : string ,
192+ what : string ,
193+ probedPort ?: number | string ,
194+ ) : Error | null {
195+ let port : string | undefined ;
196+ for ( const pattern of PORT_TAKEN_PATTERNS ) {
197+ const match = pattern . exec ( output ) ;
198+ if ( match ) {
199+ port = match [ 1 ] ;
200+ break ;
201+ }
202+ }
203+ if ( port === undefined ) return null ;
204+ const probed = probedPort === undefined
205+ ? ''
206+ : `This harness bind-probed ${ probedPort } and the kernel reported it FREE moments earlier `
207+ + '(`reservePort()` in `test/helpers/serve-process.ts`), so this is the residual '
208+ + 'close-to-spawn gap that probe narrows but does not close.\n' ;
209+ return new Error (
210+ `PORT CONTENTION on port ${ port } : \`${ what } \` could not bind it.\n`
211+ + probed
212+ + 'Several agents share one container in this fleet, so another process took the port '
213+ + 'between the probe and the spawn.\n'
214+ + '⛔ This is a HOST race, not a verdict about the code under test. Do not spend a round '
215+ + 'deciding whether your change caused it — re-run this file in isolation. If it '
216+ + 'reproduces there, the port is genuinely held and the message above names it.\n'
217+ + `--- child output ---\n${ output } ` ,
218+ ) ;
26219}
27220
28221/**
@@ -214,13 +407,38 @@ export interface ServeRun {
214407 stderr : string ;
215408}
216409
410+ /**
411+ * The port a caller asked for, read back out of its own `args`.
412+ *
413+ * `runServe` takes the port as an opaque argv element rather than a parameter,
414+ * so this is how it learns which port it probed — for the message only, never
415+ * for the verdict (`portContentionError` reads the contended port out of the
416+ * child's own diagnostic). `undefined` when the caller passed no `--port`,
417+ * which just drops one sentence from the message.
418+ */
419+ function portOf ( args : string [ ] ) : string | undefined {
420+ for ( const flag of [ '--port' , '-p' ] ) {
421+ const at = args . indexOf ( flag ) ;
422+ if ( at !== - 1 && at + 1 < args . length ) return args [ at + 1 ] ;
423+ }
424+ return undefined ;
425+ }
426+
217427/**
218428 * Boot `os serve` in `cwd`, collect its output until `waitFor` matches (or the
219429 * process exits), then stop it. Never leaves the child running.
220430 *
221431 * A boot that DIES still has to have said why, so an early exit resolves rather
222432 * than rejects — the caller's assertions read what it printed on the way down.
223433 *
434+ * ⭐ ONE narrow exception to that, and it is deliberate (#12441): a boot that
435+ * died because it could not BIND rejects, with `portContentionError()`'s
436+ * message. That death says nothing about the code under test, and letting it
437+ * resolve hands the caller an output buffer whose assertions then fail on
438+ * whatever marker is missing — which is the illegible shape the card measured.
439+ * No test in this directory drives a deliberately-busy port, so nothing is
440+ * asserting on the resolved form of it.
441+ *
224442 * `waitFor` is matched against **stdout and stderr together** (#7915). `serve`
225443 * writes every human line — banner, boot progress, kernel logs — to stderr now,
226444 * because its stdout belongs to the MCP stdio transport when one is mounted;
@@ -271,8 +489,20 @@ export function runServe(
271489 } catch {
272490 /* already gone */
273491 }
274- if ( err ) rejectRun ( err ) ;
275- else resolveRun ( { stdout, stderr } ) ;
492+ if ( err ) {
493+ rejectRun ( err ) ;
494+ return ;
495+ }
496+ const contended = portContentionError (
497+ stdout + stderr ,
498+ 'os serve (bin/run-dev.js, via runServe)' ,
499+ portOf ( args ) ,
500+ ) ;
501+ if ( contended ) {
502+ rejectRun ( contended ) ;
503+ return ;
504+ }
505+ resolveRun ( { stdout, stderr } ) ;
276506 } ;
277507
278508 const timer = setTimeout (
0 commit comments