Repository navigation
Commit e4bcced
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
File tree
- packages/cli/test
- helpers
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
56 | 56 | | |
57 | 57 | | |
58 | 58 | | |
59 | | - | |
60 | | - | |
61 | | - | |
| 59 | + | |
| 60 | + | |
62 | 61 | | |
63 | 62 | | |
64 | 63 | | |
| |||
120 | 119 | | |
121 | 120 | | |
122 | 121 | | |
123 | | - | |
124 | | - | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
125 | 130 | | |
126 | 131 | | |
127 | 132 | | |
| |||
163 | 168 | | |
164 | 169 | | |
165 | 170 | | |
166 | | - | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
167 | 174 | | |
168 | 175 | | |
169 | 176 | | |
170 | 177 | | |
171 | | - | |
172 | | - | |
| 178 | + | |
173 | 179 | | |
174 | 180 | | |
175 | 181 | | |
| |||
216 | 222 | | |
217 | 223 | | |
218 | 224 | | |
219 | | - | |
220 | | - | |
| 225 | + | |
221 | 226 | | |
222 | 227 | | |
223 | 228 | | |
224 | | - | |
225 | | - | |
| 229 | + | |
226 | 230 | | |
227 | 231 | | |
228 | 232 | | |
229 | | - | |
230 | | - | |
| 233 | + | |
231 | 234 | | |
232 | 235 | | |
233 | 236 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
181 | 181 | | |
182 | 182 | | |
183 | 183 | | |
184 | | - | |
185 | | - | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
186 | 187 | | |
187 | 188 | | |
188 | 189 | | |
| |||
209 | 210 | | |
210 | 211 | | |
211 | 212 | | |
212 | | - | |
213 | | - | |
214 | | - | |
215 | | - | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
216 | 240 | | |
217 | 241 | | |
218 | 242 | | |
| |||
288 | 312 | | |
289 | 313 | | |
290 | 314 | | |
291 | | - | |
292 | | - | |
293 | | - | |
294 | | - | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
295 | 319 | | |
296 | 320 | | |
297 | 321 | | |
298 | 322 | | |
299 | 323 | | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
300 | 382 | | |
301 | 383 | | |
302 | 384 | | |
| |||
325 | 407 | | |
326 | 408 | | |
327 | 409 | | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
328 | 416 | | |
329 | 417 | | |
330 | 418 | | |
| |||
457 | 545 | | |
458 | 546 | | |
459 | 547 | | |
460 | | - | |
461 | | - | |
462 | | - | |
463 | | - | |
464 | | - | |
465 | | - | |
466 | | - | |
467 | | - | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
468 | 558 | | |
469 | 559 | | |
470 | 560 | | |
| |||
500 | 590 | | |
501 | 591 | | |
502 | 592 | | |
503 | | - | |
504 | | - | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
505 | 596 | | |
506 | 597 | | |
507 | 598 | | |
| |||
0 commit comments