Skip to content

Commit e4bcced

Browse files
test(cli): production-posture spawns take a kernel-assigned port; a pin holds every bin/run.js spawner to it (#24337)
Fixes #24295 Clause-②: no Basis for the Clause-② line: every changed file is under `packages/cli/test/**`, the CLI's test harness. `@objectstack/cli` publishes `dist`, `README.md` and `CHANGELOG.md` (its `files[]`). Measured after the build: the new symbols (`KERNEL_ASSIGNED_PORT`, `readKernelAssignedPort`, `OUTSIDE_THE_PRODUCTION_COLUMN`) occur in 0 of those files. The positive control `childPortEnv`, which comes from `src/`, occurs in 2. Nothing published moves, so this PR carries the `skip-changeset` label and no changeset. This repo refuses a new empty-frontmatter changeset (`scripts/check-empty-changeset.mjs`, rule 1), so an empty changeset was not an option. ## What changed - **The closed mechanism** (`test/helpers/serve-process.ts`): `KERNEL_ASSIGNED_PORT` (`'0'`) and `readKernelAssignedPort(output, what)`. - A production-posture spawn passes `--port 0`. Its own kernel picks the port when it binds, so no released number sits in a gap. - The test reads the bound port back from the banner tail. - `readKernelAssignedPort` returns an `Error` for three cases: no banner, an unreadable `API:` row, or `localhost:0` announced. - **Every production-posture spawn site** now uses that mechanism (9 files). Two sites needed a number before the spawn (A3). For them, the number the test needs is a `holdPort()` held for the whole boot, so the child never binds it. - **The enumeration pin**, `test/released-port-production-spawn.pin.test.ts`. It is a unit-tier vitest test, not a gate script or CI step. - It reads every `.ts` file under `packages/cli/test` with the TypeScript AST. - A file whose code names the built entry `bin/run.js` may call `reservePort()`/`randomPort()` only as often as its `OUTSIDE_THE_PRODUCTION_COLUMN` entry allows. With no entry, the allowance is zero. - "Names" covers both spellings this directory uses (patch round 1). One is a string that ends with the entry: `resolve(HERE, '../bin/run.js')`. The other is the entry spelled as path segments inside one call: `join(PACKAGE_ROOT, 'bin', 'run.js')`. For the second, the call's string-literal arguments are split on `/` and read in order, and the entry's segments must all appear among them, in that order. - `bin/run.js` is the only entry that can produce a production-posture child: `bin/run-dev.js` sets `NODE_ENV=development` unconditionally before argv is parsed. - The ledger holds the four `bin/run.js` files whose every spawn is development posture, each with its reason and its exact draw count. - **Docblocks.** The `reservePort()` / `randomPort()` docblocks no longer say the race is unclosed. They now say it is closed for the production column, and they name the two uses that may still draw: the development column and instruments. - `portContentionError()` now says it is not for a kernel-assigned child. Converted sites stopped calling it, because its "host race, not a verdict" message would hide a real defect there. - The stale "the ONLY file in the loud row" count in `boundPortFromBanner()`'s docblock now points at the pin. ## Measured before choosing the mechanism Built CLI, worktree on `83048c686`. The harness held a port with a live listener for the HELD legs. | leg | spawn | result | |---|---|---| | M1 | `bin/run.js serve -p HELD` (production) | exit 1, `Port 40349 is already in use` | | M2 | `bin/run.js serve -p 0` (production) | banner `API:` port 34647, `GET /api/v1/health` on it: 200 | | M3 | `bin/run.js serve`, `OS_PORT=0` | banner port 43365, health 200 | | M4 | `bin/run.js serve`, `PORT=0` | banner port 35705, health 200 | | M5 | `bin/run.js start -p HELD` (production) | exit 1, `Port 40349 is already in use` | | M6 | `bin/run.js start -p 0` (production) | banner port 33721, health 200 | | M7 | `bin/run.js serve -p 0 --dev` | banner port 42681, health 200 | | M8 | `bin/run.js serve -p HELD --dev` | green boot on 40350 (drift) | | M9 | tsx `run-dev.js serve -p HELD` (the `runServe()` shape) | green boot on HELD+1 (drift) | | M10 | tsx `run-dev.js start -p HELD` (the `package-install-local-*` shape) | green boot on HELD+1 (drift) | M9 and M10 confirm that every tsx child is in the development column, including an `os start` whose `serve` child inherits `NODE_ENV=development`. ## The enumeration — all 46 test files that draw from the helper (on `83048c686`) | file | column | mechanism, or why it still draws | |---|---|---| | `start-mcp-stdio-early-initialize.integration` | production (`os start`) + a `--dev` minting boot | both on `KERNEL_ASSIGNED_PORT`; the mint reads its port back for HTTP | | `start-stdio-stdout-purity.integration` | production (`os start`) + a `--dev` minting boot | both on `KERNEL_ASSIGNED_PORT` | | `serve-mcp-stdio-early-initialize.integration` | production `serve` + a `--dev` minting boot | both on `KERNEL_ASSIGNED_PORT` | | `dev-standalone-self-heal.integration` | production `serve` leg + two `os dev` legs through one boot helper | all three on `KERNEL_ASSIGNED_PORT` | | `serve-datasource-mapping-before-sync.integration` | production `serve` | `KERNEL_ASSIGNED_PORT` | | `serve-no-storage-boot-guard.integration` | production `serve` (and a `--dev` control through the same boot) | `KERNEL_ASSIGNED_PORT` | | `serve-ready-degraded-boot.e2e` | production `serve` | `KERNEL_ASSIGNED_PORT` | | `serve-node-env-production-default.e2e` | production `serve` (unset and `test` legs; the `development` leg shares the boot) | A3: the fixture's `baseUrl` and the probed Origin are two ports this file holds; the server takes `KERNEL_ASSIGNED_PORT` and is read back | | `start-port-banner-agreement.e2e` | production `os start`, legs 1–2 | A3: leg 1 holds the exported `OS_PORT` (it must lose) and passes `--port 0` (it must win); leg 2 exports `OS_PORT=0`. Both compare the bound port with `serve`'s built-in default 3000, and leg 1 also with the held port. Leg 3 already used `holdPort()` | | `dev-boot-output-order.integration` | development (`os dev` under a pty) via `bin/run.js` | ledger: reads no port, so a lost race is an unobserved auto-shift | | `serve-mcp-capability-collision.e2e` | development (`serve --dev`) via `bin/run.js` | ledger: a lost race drifts, and the file's own drift check reds it | | `serve-mcp-stdio-answers.e2e` | development (`serve --dev`) via `bin/run.js` | ledger: same | | `serve-stdio-stdout-purity.e2e` | development (`serve --dev`) via `bin/run.js` | ledger: same | | `artifact-flag-precedence.integration`, `package-install-local-{boot-steps,handlers,hooks,jobs,jobs-pull,jobs-shared-name,uninstall-cleanups}.integration`, `package-restart-acceptance.integration`, `serve-app-anchored-optional-import.e2e`, `serve-app-runtime-hooks.e2e`, `serve-boot-diagnostics-noise-budget.e2e`, `serve-boot-diagnostics.e2e`, `serve-cluster-driver-diagnosis.e2e`, `serve-config-boot-flat-docs.integration`, `serve-config-boot-residual.integration`, `serve-host-config-metadata-door.integration`, `serve-migrate-exit-schema-parity.integration`, `serve-no-artifact.e2e`, `serve-organization-record-page.e2e`, `serve-organizations-host-resolution.e2e`, `serve-organizations-mount-failure.e2e`, `serve-package-declared-capabilities.test`, `serve-package-registry-always-on.e2e`, `serve-port-drift-notice.e2e`, `serve-port-readback.e2e`, `serve-process-child-env.e2e`, `serve-publishes-bound-port.e2e`, `serve-runtime-assets-branding-warning.e2e`, `serve-view-container-read.integration`, `start-signal-forwarding.e2e` (31 files) | development: tsx entry (`bin/run-dev.js` pins `NODE_ENV=development`) | not converted, outside this card's column and outside the pin's population. See the acceptance notes | | `serve-port-bind-probe.test` | instrument: measures the draw itself, spawns no CLI | draws by design | | `serve-probe-child-attribution.test` | instrument: `reservePort()` as a port nothing listens on; spawns a driver script, not the CLI | draws by design | The PM's read was 29 test files calling `portContentionError(`, as the production column. Classified: - 8 are production posture: the first 8 rows. `start-port-banner-agreement` never called it. - 3 spawn `bin/run.js --dev`. - 17 spawn a tsx (development) child, where a lost race drifts instead of exiting. - 1 is the bind-probe instrument. ## Reverse verification and ablation (one-time, not kept) Each mutation went through `scripts/ablation-replace.mjs` in WRAP mode. Every restore ended with blob == HEAD and an empty `git diff HEAD`. - **The pin turns red on a reverted site.** In `start-mcp-stdio-early-initialize`, `[CLI, 'start', '-p', KERNEL_ASSIGNED_PORT, …]` was changed to `randomPort()`. - Result: `Tests 1 failed | 3 passed (4)`. - The failure names the site: `start-mcp-stdio-early-initialize.integration.test.ts: 1 released-port draw(s) (line 287), ledger allows 0`. - **The lost race, forced (A5), reproduces the hourly-run red deterministically.** The same `os start` leg was spawned on a port the test holds. - Result: `AssertionError: initialize was never answered — os start exited 1 before answering — ✗ Port 44877 is already in use.` - That is the shape the hourly run recorded on port 45195. - On the converted site the child is handed no number, so this hostile condition has nothing to act on. The converted file passes (see Tests). - **Patch round 1 — the pin sees the segment spelling.** - Mutation: a scratch `const SCRATCH_PORT = randomPort();` added beside `published-entry-stderr-error-listener.test.ts`'s `join(PACKAGE_ROOT, 'bin', 'run.js')`. That file names the entry only in segments. - Result: `Tests 1 failed | 3 passed (4)` — `published-entry-stderr-error-listener.test.ts: 1 released-port draw(s) (line 82), ledger allows 0`. - The first attempt was a no-op: its replacement still contained the anchor text, so the tool refused before running anything. The anchor was changed and the run repeated. - Removing the segment reading turns two of the pin's own checks red: the fixture battery (`the built entry spelled in path segments: population: expected false to be true`) and the live-tree check (`published-entry-stderr-error-listener.test.ts spells bin/run.js in segments but is not in the population`). - **The leg-1 assertion of `start-port-banner-agreement` can fail.** Dropping `--port 0` gives `os start exited 1 before its banner. ✗ Port 38959 is already in use.`: the child took the held `OS_PORT`. Leg 2's counterpart (unset `OS_PORT` ⇒ the child binds 3000) was **not run**, because it would bind 3000 on a container where `PORT=3000` is the shared frontend-debug backend. ## Tests Patch round 1, on `31f3f6444`: - `released-port-production-spawn.pin` 4/4 - `published-entry-stderr-error-listener` 6/6 - `published-entry-stderr-nonblocking.e2e` 5/5 (nightly tier) The files below are byte-identical between `7c29ce54a` and `31f3f6444`; their readings are from `7c29ce54a`, and CI's `Test Core` 1–6/6 passed on that head. Queue tier (`vitest run --maxWorkers=2`), 18/18: - `released-port-production-spawn.pin` 4/4 (project `unit`) - `start-mcp-stdio-early-initialize` 1/1 - `start-stdio-stdout-purity` 1/1 - `serve-mcp-stdio-early-initialize` 1/1 - `dev-standalone-self-heal` 4/4 - `serve-datasource-mapping-before-sync` 2/2 - `serve-no-storage-boot-guard` 5/5 Nightly tier (`OS_TEST_TIERS=nightly`), 8/8: - `serve-node-env-production-default` 3/3. The unset leg answers `403 INVALID_ORIGIN`, and the `development`/`test` legs pass the origin, on the same held-port fixture. - `serve-ready-degraded-boot` 2/2 - `start-port-banner-agreement` 3/3 Also run on `31f3f6444`: - `pnpm --filter @objectstack/cli typecheck`: exit 0. The test layer holds its existing 3-file / 28-error ledger. `tsc --listFiles -p tsconfig.test.json` puts all 11 changed files in the program with 0 errors. - `pnpm exec eslint --no-inline-config` over the 11 changed files: exit 0. ## Gates (local subset, head `31f3f6444`) - **Derived list.** `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derives 52 commands from the 11-path change set against merge base `1ef0ee89d`. Changed lines: 860 (+667 / -193). - **Every command exited 0.** Each exit code was captured before any pipe. - **Reconciled.** `--ran` with exit codes recorded reports `52 derived, 52 run, 0 NOT-MEASURED, 0 UNRUN`. - **Two reds on the first pass, both resolved** (the original round; the round-1 run had none): - `pnpm check:cli-test-child-env` was red on an intermediate head. It caught the probe split described in the acceptance notes, fixed in `7c29ce54a`. - `pnpm check:dual-build-cjs-loads` refused with `PREREQUISITE NOT MET` (exit 3) until the whole repo was built. After the build it reads exit 0. - **The repo-wide `pnpm lint` and the full CI farm are CI's**, read from this PR's check-runs. The 52 derived commands, all exit 0: ```text node scripts/check-ci-filter-parity.mjs node scripts/check-closing-keyword-parity.mjs node scripts/check-closing-keyword-parity.mjs --self-test node scripts/check-comment-mask-adoption.mjs node scripts/check-comment-mask-adoption.mjs --self-test node scripts/check-comment-mask-corpus.mjs node scripts/check-dts-emitted.mjs --self-test node scripts/check-issue-citations.mjs node scripts/check-keyed-text-bounds.mjs node scripts/check-keyed-text-bounds.mjs --self-test node scripts/check-platform-object-tenancy-census.mjs node scripts/check-platform-object-tenancy-census.mjs --self-test node scripts/check-plugin-teardown-shape.mjs node scripts/check-plugin-teardown-shape.mjs --self-test node scripts/check-position-name-fold-loaders.mjs node scripts/check-position-name-fold-loaders.mjs --self-test node scripts/check-registry-log-declared.mjs node scripts/check-registry-log-declared.mjs --self-test node scripts/check-rest-log-spy-declared.mjs node scripts/check-rest-log-spy-declared.mjs --self-test node scripts/check-system-context-census.mjs node scripts/check-system-context-census.mjs --self-test node scripts/check-undeclared-dep-imports.mjs node scripts/check-undeclared-dep-imports.mjs --self-test node scripts/docs-audit/check-affected-docs.mjs node scripts/docs-audit/check-drift-comment.mjs pnpm check:cli-test-child-env pnpm check:cross-package-test-inputs pnpm check:doc-authoring pnpm check:driver-memory-census pnpm check:dts-closure pnpm check:dual-build-cjs-loads pnpm check:engine-double-contract pnpm check:gitlink-declared pnpm check:issue-citations pnpm check:lean-entry-closure pnpm check:logger-receiver-detach pnpm check:nul-bytes pnpm check:objectql-double-limit pnpm check:org-identifier pnpm check:page-declaration-shape pnpm check:published-files pnpm check:query-options-erasure pnpm check:refd-timer-probe pnpm check:slot-lookup pnpm check:sourcemap-no-sources-content pnpm check:test-source-alias pnpm check:tier-file-adoption pnpm check:type-check-coverage pnpm check:type-check-debt pnpm check:watch-hint-literal pnpm check:where-matcher ``` ## Acceptance notes - **#24342 carries the development column of #24295's family; #24295 keeps the production column.** - **The development column still draws, and it goes red on the same race.** - That is 31 tsx-entry files plus the 4 ledger files. - Measured: M8–M10 above. `serve-port-readback.e2e`'s held-port leg already pins that `runServe()` rejects a drifted boot with `PORT DRIFT`. - This PR does not convert them. The card's column is production posture, and the dispatch asked for this reading to be reported, not silently widened. The minting and `os dev` boots converted here sit inside files that also hold a production spawn, through the same boot code. - **`serve-node-env-production-default`'s fixture `baseUrl` is now a held port, not the server's own port.** Its three legs still split 403 / not-403 on one fixture. The probed Origin is now guaranteed distinct from both the `baseUrl` and the bound port, where it used to be `port + 1`. - **`check:cli-test-child-env` rule 3 resolves a built-entry child's `NODE_ENV` through exactly one same-file hop.** Splitting that probe into a second function made it red. The spawn stays in the function the `it` blocks call, and a comment there says why. - **The pin's blind spots are named in its header:** - the development column; - a production-capable entry spelled some other way — a packed tarball's bin, `dist/` run directly — or with its directory held in a variable before the file name is joined on (`join(BIN_DIR, 'run.js')`); - a hand-written released draw that does not call the helper. --- _Generated by [Claude Code](https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dd72a04 commit e4bcced

11 files changed

Lines changed: 667 additions & 193 deletions

‎packages/cli/test/dev-standalone-self-heal.integration.test.ts‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,8 @@ import Database from 'better-sqlite3';
5656
import {
5757
childEnv,
5858
E2E_SECRET_KEY,
59-
portContentionError,
60-
portDriftError,
61-
randomPort,
59+
KERNEL_ASSIGNED_PORT,
60+
readKernelAssignedPort,
6261
requireBuiltCli,
6362
RUN_JS_RESOLVES_FROM_DIST,
6463
} from './helpers/serve-process.js';
@@ -120,8 +119,14 @@ function stageBareIndex(): void {
120119
}
121120
}
122121

123-
/** Boot `os <argv>` until its ready banner, then stop the whole group and return what it printed. */
124-
function bootUntilReady(argv: string[], port: string, env: Record<string, string | undefined>): Promise<string> {
122+
/**
123+
* Boot `os <argv>` until its ready banner, then stop the whole group and return what it printed.
124+
*
125+
* Every call passes `-p KERNEL_ASSIGNED_PORT` (#24295): the production leg's child refuses to
126+
* auto-shift, so a port drawn and released beforehand is a red whenever another process takes
127+
* it in the gap. Nothing here talks to the port; the read-back only checks the banner names one.
128+
*/
129+
function bootUntilReady(argv: string[], env: Record<string, string | undefined>): Promise<string> {
125130
return new Promise((resolveBoot, rejectBoot) => {
126131
const child = spawn(process.execPath, [CLI, ...argv], {
127132
cwd: dir,
@@ -163,13 +168,14 @@ function bootUntilReady(argv: string[], port: string, env: Record<string, string
163168
);
164169
const onData = (d: unknown) => {
165170
out += String(d);
166-
if (READY.test(out)) finish(portDriftError(out, `os ${argv[0]}`, port));
171+
if (!READY.test(out)) return;
172+
const bound = readKernelAssignedPort(out, `os ${argv[0]}`);
173+
finish(bound instanceof Error ? bound : null);
167174
};
168175
child.stdout?.on('data', onData);
169176
child.stderr?.on('data', onData);
170177
child.on('exit', (code) =>
171-
finish(portContentionError(out, `os ${argv[0]}`, port)
172-
?? new Error(`os ${argv[0]} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)),
178+
finish(new Error(`os ${argv[0]} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)),
173179
);
174180
});
175181
}
@@ -216,18 +222,15 @@ beforeAll(async () => {
216222
db = join(dir, 'A.db');
217223

218224
// Boot 1: fresh, with the telemetry opt-out.
219-
let port = randomPort();
220-
await recordBoot('fresh', bootUntilReady(['dev', '--no-watch', '-d', 'file:A.db', '-p', port], port, { OS_TELEMETRY_DB: '0' }));
225+
await recordBoot('fresh', bootUntilReady(['dev', '--no-watch', '-d', 'file:A.db', '-p', KERNEL_ASSIGNED_PORT], { OS_TELEMETRY_DB: '0' }));
221226

222227
// Boot 2: the card's restart, on the restaged bare index, telemetry default-on.
223228
if (existsSync(db)) stageBareIndex();
224-
port = randomPort();
225-
await recordBoot('restart', bootUntilReady(['dev', '--no-watch', '-d', 'file:A.db', '-p', port], port, {}));
229+
await recordBoot('restart', bootUntilReady(['dev', '--no-watch', '-d', 'file:A.db', '-p', KERNEL_ASSIGNED_PORT], {}));
226230

227231
// Boot 3: production (`os serve`, NODE_ENV unset) on the restaged bare index.
228232
if (existsSync(db)) stageBareIndex();
229-
port = randomPort();
230-
await recordBoot('production', bootUntilReady(['serve', 'objectstack.config.ts', '-p', port], port, {
233+
await recordBoot('production', bootUntilReady(['serve', 'objectstack.config.ts', '-p', KERNEL_ASSIGNED_PORT], {
231234
OS_DATABASE_URL: 'file:A.db',
232235
OS_AUTH_SECRET: 'e2e-21733-production-leg-secret-not-for-real-use',
233236
OS_LOG_LEVEL: 'warn',

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

Lines changed: 111 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,9 @@ function probeBind(want: number): number | null {
181181
}
182182

183183
/**
184-
* The ONE port draw for every e2e spawn in this directory — a real bind probe,
185-
* not a blind `Math.random()` (#12441).
184+
* The released-port draw — a real bind probe, not a blind `Math.random()`
185+
* (#12441) — for the spawns that still take a number. ⛔ NOT for a spawn that
186+
* can reach production posture: those take {@link KERNEL_ASSIGNED_PORT}.
186187
*
187188
* Listen on `0.0.0.0:0`, read the port the kernel assigned, close the listener,
188189
* return the port for the caller to hand to `os serve`.
@@ -209,10 +210,33 @@ function probeBind(want: number): number | null {
209210
* ⛔ The comment this replaced claimed "a run never contends with another
210211
* agent's dev server on this host". That unqualified negative is the reason
211212
* nobody re-examined the draw until a real run in this fleet went red on
212-
* `✗ Port 49402 is already in use`. **Do not write another one here.** The
213-
* residual race is real and unclosed; what pays for it is
214-
* `portContentionError()` below, which makes the residual failure SAY it is a
215-
* port race instead of `serve exited 1 before "Server is ready"`.
213+
* `✗ Port 49402 is already in use`. **Do not write another one here.**
214+
*
215+
* ## Where the race is closed, and where this draw is still allowed (#24295)
216+
*
217+
* The residual race reached `main` once: an hourly run went red on
218+
* `start-mcp-stdio-early-initialize.integration.test.ts`, whose `os start`
219+
* child refused a port taken inside this gap. A production-posture child
220+
* refuses to auto-shift by design, so for that column the gap is a red, and it
221+
* is now CLOSED rather than narrowed: such a spawn passes
222+
* {@link KERNEL_ASSIGNED_PORT} and reads the port back with
223+
* {@link readKernelAssignedPort}, so no number crosses a gap at all.
224+
* `released-port-production-spawn.pin.test.ts` holds it: a file that names the
225+
* production-capable entry (`bin/run.js`) may call this or `randomPort()` only
226+
* as often as its ledger entry there allows — zero without one, and an entry is
227+
* a file whose every spawn of that entry is development posture.
228+
*
229+
* What may still call it, each with the race still real and unclosed there:
230+
*
231+
* • the DEVELOPMENT column — `runServe()` and every other child of
232+
* `bin/run-dev.js`, which pins `NODE_ENV=development` before argv is
233+
* parsed. A lost race there is a hop to the next port, not an exit, and
234+
* `portDriftError()` below is what turns that hop into a red;
235+
* • instruments that need a number NOTHING is listening on (a closed-port
236+
* probe), or that measure this draw itself (`serve-port-bind-probe`).
237+
*
238+
* `portContentionError()` below is what makes a residual loss SAY it is a port
239+
* race instead of `serve exited 1 before "Server is ready"`.
216240
*
217241
* ⚠️ One property that is worse than the old range and is stated rather than
218242
* hidden: the kernel assigns from its ephemeral range
@@ -288,15 +312,73 @@ export function holdPort(): Promise<{ port: number; release: () => Promise<void>
288312
*
289313
* ⚠️ The name is kept — and is now a slight misnomer, which is cheaper than the
290314
* alternative. It is `String(reservePort())` and nothing else; the draw, the
291-
* guarantees and the residual race are all documented on `reservePort()` above
292-
* and there is no second mechanism hiding behind this name. Renaming it would
293-
* be a rename-only edit across the 8 other files in this directory that call
294-
* it, which is churn this change deliberately does not spend.
315+
* guarantees, the residual race and the columns it may still serve (the
316+
* development column and instruments — never a production-capable spawn) are
317+
* all documented on `reservePort()` above, and there is no second mechanism
318+
* hiding behind this name.
295319
*/
296320
export function randomPort(): string {
297321
return String(reservePort());
298322
}
299323

324+
/**
325+
* ⭐ The CLOSED mechanism for a spawn that can reach production posture (#24295):
326+
* the `--port` value that asks the CHILD's own `listen()` for a kernel-assigned
327+
* port, so no number is ever unheld between a pick and a bind.
328+
*
329+
* `0` is a legal request, not an error (`utils/port-contract.ts` declares
330+
* `MIN_PORT = 0` from a measurement), and both doors honour it in production
331+
* posture: `os serve` on `--port 0`, `OS_PORT=0` and `PORT=0`, and `os start`
332+
* on `--port 0`. Measured on the built CLI for #24295: every leg printed a
333+
* banner whose `API:` row named a non-zero port, and `GET /api/v1/health` on
334+
* that port answered `200`; the same two doors on a port this harness HELD
335+
* exited 1 on `Port <n> is already in use`, which is the lost race forced on
336+
* purpose.
337+
*
338+
* The price is that the caller learns the port only AFTER the boot: read it with
339+
* {@link readKernelAssignedPort} once the banner tail has printed. A site that
340+
* needs the number BEFORE the spawn (a base URL written into a config, an
341+
* assertion on which of two numbers won) first asks whether the number it
342+
* needs is really the SERVER's: a port the test only has to know, and that must
343+
* not be the one the child binds, is a {@link holdPort} that stays bound for the
344+
* whole boot — and a held port cannot be the one the kernel assigns.
345+
*/
346+
export const KERNEL_ASSIGNED_PORT = '0';
347+
348+
/**
349+
* The port a child spawned on {@link KERNEL_ASSIGNED_PORT} actually bound, read
350+
* out of its own ready banner — or an `Error` saying why it cannot be read.
351+
*
352+
* Returned rather than thrown because every caller reads it inside a stream
353+
* handler, where a throw escapes the boot's promise; the shape follows
354+
* `portDriftError()` and `portContentionError()`, which return their `Error`.
355+
*
356+
* Call it once the banner TAIL (`Press Ctrl+C to stop`) has printed —
357+
* `boundPortFromBanner()` explains why the tail and not the head. Three answers
358+
* are refused rather than guessed at:
359+
*
360+
* • no complete banner — nothing announced a port;
361+
* • a banner whose `API:` row names no `http://localhost:<port>` — a child
362+
* carrying `OS_AUTH_URL` / `BETTER_AUTH_URL` / `OS_BASE_URL` prints that
363+
* origin instead, and the read-back channel goes with it;
364+
* • `http://localhost:0` — the REQUEST announced in place of the bound port,
365+
* the regression #13062 fixed and `portDriftError()` already names.
366+
*/
367+
export function readKernelAssignedPort(output: string, what: string): number | Error {
368+
const readback = boundPortFromBanner(output);
369+
if (readback.state === 'bound' && readback.port > 0) return readback.port;
370+
const said = readback.state === 'no-banner'
371+
? 'printed no complete ready banner, so it never announced a port'
372+
: readback.state === 'unreadable'
373+
? `printed a ready banner whose API row names no http://localhost address: ${readback.apiRow}`
374+
: 'announced http://localhost:0 — the request, not the port it bound';
375+
return new Error(
376+
`CANNOT READ BACK THE BOUND PORT of \`${what}\`: it was asked for port ${KERNEL_ASSIGNED_PORT} `
377+
+ `(a kernel-assigned port, so the harness holds no number of its own) and ${said}.\n`
378+
+ `--- child output ---\n${output}`,
379+
);
380+
}
381+
300382
/** How a boot says the port was taken — `serve.ts`'s own diagnostic, then the raw kernel error. */
301383
const PORT_TAKEN_PATTERNS = [
302384
/Port (\d+) is already in use/,
@@ -325,6 +407,12 @@ const PORT_TAKEN_PATTERNS = [
325407
* say, in the message, that the port WAS probed free moments earlier — which is
326408
* what tells the reader this is the residual TOCTOU gap and not a harness that
327409
* never looked.
410+
*
411+
* ⛔ Not for a child spawned on {@link KERNEL_ASSIGNED_PORT} (#24295). That child
412+
* was handed no number to lose, so a `Port <n> is already in use` from it is
413+
* NOT this race, and this message — "a HOST race, not a verdict about the code
414+
* under test" — would misattribute a real defect. Such a site reports the
415+
* child's own output instead.
328416
*/
329417
export function portContentionError(
330418
output: string,
@@ -457,14 +545,16 @@ export type BannerPortReadback =
457545
* `--dev`, whatever NODE_ENV SILENT drift, GREEN boot
458546
* `bin/run-dev.js` (NODE_ENV pinned at :28) SILENT drift, GREEN boot
459547
*
460-
* Measured on this tree: `serve-node-env-production-default.e2e.test.ts` is the
461-
* ONLY file in this directory that is in the loud row. Every `runServe()` child
462-
* is in the silent one by the third route, and the three `serve-mcp-*` /
463-
* `serve-stdio-*` spawners are in it by the second — they pass `--dev`, so the
464-
* `bin/run.js` entry buys them nothing. ⛔ Do not restate the older reading
465-
* ("`bin/run.js` + unset `NODE_ENV` ⇒ loud"): it is the sentence that made this
466-
* population look half its real size, and it survived because `--dev` on those
467-
* spawn lines predates the cards blamed for it and was simply never read.
548+
* Every `runServe()` child is in the silent row by the third route, and a
549+
* `bin/run.js` spawner that passes `--dev` is in it by the second. ⛔ Do not
550+
* restate the older reading ("`bin/run.js` + unset `NODE_ENV` ⇒ loud"): it is
551+
* the sentence that made this population look half its real size. Which files
552+
* are in the loud row is ⛔ not counted here either — an earlier count here
553+
* ("the ONLY file") went stale as `bin/run.js` spawners were added. The loud row
554+
* can only be reached through `bin/run.js`, so
555+
* `released-port-production-spawn.pin.test.ts` enumerates every file that names
556+
* it, and holds each of them to {@link KERNEL_ASSIGNED_PORT} unless its ledger
557+
* entry there says why not (#24295).
468558
*
469559
* And the port `runServe()` hands over is an opaque element of an argv list, so
470560
* until this read-back the harness never learned the child's real one. A test
@@ -500,8 +590,9 @@ export function boundPortFromBanner(output: string): BannerPortReadback {
500590
* Shaped like `portContentionError()` above and used at the same place, because
501591
* it is the same host race seen through the other entrypoint: something took
502592
* the port between this harness's bind probe and the child's `listen()`. The
503-
* production column dies loudly and gets `portContentionError()`; the
504-
* development column drifts and gets this.
593+
* production column would die loudly, which is why it no longer draws (it takes
594+
* {@link KERNEL_ASSIGNED_PORT}, #24295); the development column drifts and gets
595+
* this — the race is still real there, and this is what makes it a red.
505596
*
506597
* ## Why `unreadable` is an ERROR and not a skip
507598
*

0 commit comments

Comments
 (0)