Skip to content

Commit 1f491d3

Browse files
committed
wip: bind-probe port draw + legible contention (#12441)
1 parent 2f665a1 commit 1f491d3

4 files changed

Lines changed: 504 additions & 20 deletions

File tree

‎packages/cli/test/helpers/serve-process.ts‎

Lines changed: 235 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* rather than re-implements it.
1111
*/
1212

13-
import { spawn } from 'node:child_process';
13+
import { execFileSync, spawn } from 'node:child_process';
1414
import { resolve } from 'node:path';
1515
import { fileURLToPath } from 'node:url';
1616

@@ -20,9 +20,202 @@ const HERE = resolve(fileURLToPath(import.meta.url), '..');
2020
export const CLI = resolve(HERE, '../../bin/run-dev.js');
2121
export 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+
*/
24157
export 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+
/Port (\d+) is already in use/,
164+
/EADDRINUSE[^\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(

‎packages/cli/test/serve-app-anchored-optional-import.e2e.test.ts‎

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,12 @@
3535
*
3636
* The spawn is written out here rather than taken from `test/helpers/
3737
* serve-process.ts` on purpose: that helper always runs the child WITH `cwd` set
38-
* to the app, which is the one shape this file must not use. Only its
39-
* `childEnv()` choke point is borrowed (#11267) — what the child INHERITS is
40-
* orthogonal to which directory it is started in, and this file boots the real
41-
* stack, better-auth included, which reads `TEST` directly.
38+
* to the app, which is the one shape this file must not use. What IS borrowed
39+
* from it is everything orthogonal to the directory the child starts in:
40+
* `childEnv()` (#11267) — this file boots the real stack, better-auth included,
41+
* which reads `TEST` directly — plus `randomPort()` and `portContentionError()`
42+
* (#12441), because a port draw is not a property of the CWD either and this
43+
* file used to carry its own second, overlapping one.
4244
*
4345
* ── The anti-vacuity floor ───────────────────────────────────────────────
4446
*
@@ -57,7 +59,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
5759
import { tmpdir } from 'node:os';
5860
import { dirname, join, resolve } from 'node:path';
5961
import { fileURLToPath } from 'node:url';
60-
import { childEnv } from './helpers/serve-process.js';
62+
import { childEnv, portContentionError, randomPort } from './helpers/serve-process.js';
6163

6264
const HERE = dirname(fileURLToPath(import.meta.url));
6365

@@ -149,15 +151,29 @@ interface Run { stdout: string; stderr: string; both: string }
149151
*
150152
* An early exit resolves rather than rejects: a boot that DIES still has to have
151153
* said why, and the refusal case below reads exactly that.
154+
*
155+
* ⭐ ONE exception (#12441): a boot that died because it could not BIND rejects,
156+
* naming the port. Resolving it would hand the caller an output buffer with no
157+
* marker in it, and the assertions would then fail with "the cluster gate was
158+
* not loaded from the app" — a sentence about resolution bases, for a failure
159+
* that is entirely about a port. That mis-signalling is the whole cost the card
160+
* measured, and it is more expensive than the lost run.
152161
*/
153162
function runServeFrom(
154163
cwd: string,
155164
configArg: string,
156165
waitFor: RegExp,
157166
timeoutMs = 240_000,
158167
): Promise<Run> {
159-
return new Promise((resolveRun) => {
160-
const port = String(40000 + Math.floor(Math.random() * 20000));
168+
return new Promise((resolveRun, rejectRun) => {
169+
// ⛔ Was an inline `String(40000 + Math.random() * 20000)`, a SECOND blind
170+
// draw whose range overlapped the one in
171+
// `serve-node-env-production-default.e2e.test.ts` (41000-60000) — so under
172+
// `--maxWorkers > 1` the two files could collide with EACH OTHER, not only
173+
// with a neighbouring agent's dev server. One bind-probed draw now, in the
174+
// helper; its docblock is the authority on what that does and does not
175+
// guarantee.
176+
const port = randomPort();
161177
const child = spawn(TSX, [CLI, 'serve', configArg, '--port', port], {
162178
cwd,
163179
env: childEnv({
@@ -179,6 +195,15 @@ function runServeFrom(
179195
settled = true;
180196
clearTimeout(timer);
181197
try { child.kill('SIGTERM'); } catch { /* already gone */ }
198+
const contended = portContentionError(
199+
stdout + stderr,
200+
'os serve (bin/run-dev.js ⇒ NODE_ENV=development)',
201+
port,
202+
);
203+
if (contended) {
204+
rejectRun(contended);
205+
return;
206+
}
182207
resolveRun({ stdout, stderr, both: stdout + stderr });
183208
};
184209
const timer = setTimeout(finish, timeoutMs);

0 commit comments

Comments
 (0)