Repository navigation
Commit c76e849
test(cli): runServe() takes a kernel-assigned port and returns the bound one; the released-port pin widens to every CLI spawn (#24386)
Part of #24342
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 `pnpm --filter @objectstack/cli build`: the symbols this
PR adds or spreads (`RELEASED_DRAW_LEDGER`, `cliSpawners`, `SLICE_2`,
`KERNEL_ASSIGNED_PORT`, `readKernelAssignedPort`) 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 is slice 1 of #24342, the `runServe()` family. The card stays open
for slice 2: the 25 files listed under "What slice 2 converts" below.
## Why the card is split
Triage `6251398054`: "If the change is over 1,000 lines, split it by
file family, as the card says."
- This slice measures 499 changed lines (+306 / -193, 12 files).
- A conversion of 31 of the 35 development-column files was measured at
788 changed lines before the pin and the helper docblocks were touched.
The widened pin is 269 changed lines here, about 25 of them the slice-2
ledger. Even without that ledger the whole card is past 1,000, and that
is before the last three `bin/run.js --dev` files.
- So this slice is the files that call `runServe()`. The bespoke
spawners and the `bin/run.js --dev` files are slice 2.
## What changed
- **`runServe()`** (`test/helpers/serve-process.ts`):
- A caller that passes no `--port` now gets `--port
KERNEL_ASSIGNED_PORT` (`0`). The child's own `listen()` picks the port,
so no released number crosses a gap.
- `ServeRun` gains `port`: the port the child bound, read back from its
own banner. It is `undefined` when the run ended without a complete
banner (a boot that died, or a `waitFor` that matched an earlier line).
- A caller that passes its own `--port` keeps both old checks,
`portContentionError()` and `portDriftError()`. The held-port arm of
`serve-port-readback` depends on that.
- **The drift check for the converted family (A4).** With `--port 0`
there is no number to drift from, so the check becomes the read-back.
`runServe()` rejects a complete banner that names no non-zero
`http://localhost` port, through `readKernelAssignedPort()`. That covers
an unreadable `API:` row (an `OS_AUTH_URL` / `BETTER_AUTH_URL` /
`OS_BASE_URL` origin) and an announced `localhost:0`. A boot with no
complete banner gets no verdict, as before.
- **10 `runServe()` caller files**, every draw removed (21 draws):
- `serve-app-runtime-hooks.e2e` (1),
`serve-boot-diagnostics-noise-budget.e2e` (1),
`serve-boot-diagnostics.e2e` (2), `serve-cluster-driver-diagnosis.e2e`
(1), `serve-no-artifact.e2e` (5),
`serve-organizations-host-resolution.e2e` (3),
`serve-organizations-mount-failure.e2e` (4),
`serve-package-declared-capabilities.test` (1),
`serve-port-readback.e2e` (1).
- `serve-package-registry-always-on.e2e` (2): its `runServe()` call, and
also its own `os serve --dev` boot, so the whole file leaves the ledger.
The boot reads its port back and each of its two boots builds its own
base URL.
- **`serve-port-readback.e2e`.**
- The positive control is now a kernel-assigned boot: `runServe()`
resolves with a `port` greater than 0 that equals the banner's
read-back.
- Three cheap cases pin `readKernelAssignedPort()`'s answers: a complete
banner, `localhost:0`, and an unreadable or missing banner.
- The held-port arm is unchanged.
- **The pin** (`released-port-production-spawn.pin.test.ts`). Its
population widens from "names `bin/run.js`" to "spawns the CLI". A file
is in it when its code does any of these:
- spells either entry, `bin/run.js` or `bin/run-dev.js`, as one string
or as segments in one call;
- imports `CLI` or `runServe` from the harness, by name, under an alias,
or as a namespace member.
- `TSX` alone does not count, because it runs whatever script it is
handed.
- The ledger is renamed `RELEASED_DRAW_LEDGER`, since an entry is no
longer "outside the production column". It holds every unconverted file
with its exact draw count and the reason `slice 2 of #24342`: 25 files,
28 draws. The four old `OUTSIDE_THE_PRODUCTION_COLUMN` entries are among
them. The pin is green.
- New fixtures cover a `runServe` caller, an aliased `CLI`, a namespace
`CLI`, `TSX` alone, and a local non-harness `CLI`. The source entry is
now in the population.
- The call shape `runServe` + `(` is spelled in pieces inside the pin.
`vitest-tiers.ts` reads that shape in code position, string literals
included, and would otherwise move the pin to the `integration` tier.
Measured: every changed test file keeps its tier from the base commit.
- **Docblocks.** `reservePort()`, `randomPort()`,
`boundPortFromBanner()` and `portDriftError()` now say what may still
draw: instruments, and the slice-2 files while they stay in the ledger.
## Measured before writing it (A1 to A3)
- **A1.** `git grep -l "reservePort\|randomPort"` names 39 files at
`6a49efb9c`, and `git grep -l "runServe("` names 21. A TypeScript AST
census, counting calls rather than lines, splits the 37 drawing test
files like this:
- 10 call `runServe()`: 21 draws, all converted here.
- 21 spawn `CLI` themselves (bespoke tsx spawners): 24 draws, slice 2.
- 4 spawn `bin/run.js --dev` or `os dev`: 4 draws, slice 2.
- 2 are instruments that spawn no CLI (`serve-port-bind-probe` 7,
`serve-probe-child-attribution` 1). They stay outside the population.
- Of the 21 `runServe(` hits: 10 are the callers above. 4 draw but name
`runServe` only in prose (`package-install-local-handlers`,
`serve-migrate-exit-schema-parity`, `serve-port-drift-notice`,
`serve-process-child-env`), so they are bespoke spawners and slice 2. 2
define a local function of that name and draw nothing. 3 are production
files #24295 already converted. The other 2 are the helper and the pin.
- **A2.** Re-verified for the `runServe()` shape (tsx `serve --port 0`):
every converted boot reached its banner with a non-zero
`http://localhost` port. One reading: port 44449.
- **A3.** No `runServe()` caller needs the number before the spawn.
`runServe()` stops the child once `waitFor` matches, and no caller
addresses the port afterwards. The one caller that passes a number, the
held-port arm, holds it on purpose.
## Reverse verification (one-time, not kept)
- **The pin turns red on a reverted caller.**
- Mutation through `scripts/ablation-replace.mjs` (WRAP mode):
`serve-boot-diagnostics.e2e`'s `runServe(dir, ['--log-level', 'debug'],
…)` became `['--port', randomPort(), '--log-level', 'debug']`.
- Result: `Tests 1 failed | 3 passed (4)`, with
`serve-boot-diagnostics.e2e.test.ts: 1 released-port draw(s) (line 112),
ledger allows 0`.
- That file names no entry and imports no `CLI`, so only the new
`runServe` leg of the population could catch it.
- Restore: blob == HEAD (`e97518ea9587`), `git diff HEAD` empty.
- **The A4 read-back can fail on a real boot.** A scratch test, deleted
afterwards (`git status` clean):
- A plain `runServe(dir, [], …)` resolved with port 44449.
- The same boot with `OS_BASE_URL=https://app.example.com` rejected with
`CANNOT READ BACK THE BOUND PORT of \`os serve (bin/run-dev.js, via
runServe)\`: it was asked for port 0 …`.
- **The lost race, forced.** `serve-port-readback`'s held-port arm hands
an explicit held port to `runServe()`, and the boot is rejected with
`PORT DRIFT`. That is the red this card describes. The kernel-assigned
positive control in the same run resolves. Both pass (16/16).
## Tests
All on head `98af344ee`, after merging `origin/main` `5da2e8881`;
closure rebuilt with `turbo run build --filter='@objectstack/cli...'`.
- Queue tier (`vitest run --maxWorkers=2`):
- `released-port-production-spawn.pin` 4/4 (project `unit`)
- `serve-package-declared-capabilities` 5/5
- Nightly tier (`OS_TEST_TIERS=nightly`):
- `serve-port-readback` 16/16
- `serve-boot-diagnostics` 2/2
- `serve-boot-diagnostics-noise-budget` 3/3
- `serve-app-runtime-hooks` 1/1
- `serve-cluster-driver-diagnosis` 8/8
- `serve-no-artifact` 5/5
- `serve-organizations-host-resolution` 3/3
- `serve-organizations-mount-failure` 4/4
- `serve-package-registry-always-on` 5/5
- 56/56 in 11 files. The same 56 also passed on the pre-merge head
`188625089`.
- `pnpm --filter @objectstack/cli typecheck`: exit 0. The test layer
holds its 3-file / 28-error ledger. `tsc --listFiles -p
tsconfig.test.json` puts all 12 changed files in the program, with 0
errors in them.
- `pnpm exec eslint --no-inline-config` over the 12 changed files: exit
0.
## Gates (local subset, head `98af344ee`)
- `node scripts/pm/dispatch-gates.mjs --commands` derives 52 commands.
Each ran with its exit code captured before any pipe, and all 52 exited
0.
- `--ran` reconciliation: `Run reconciliation — 52 derived, 52 run, 0
NOT-MEASURED, 0 UNRUN.`
- The 11 families of the "declared wide population" block
(`check:init-service-contract`, `check:live-db-isolation`,
`check:meta-type-normalized`, `check:optional-error-sink`,
`check:resume-authority-declared`, `check:route-envelope`,
`check:runner-env-posture`, `check:settings-bind-window`,
`check:startup-registry-verdict`, `check:verify-stand-in`,
`check:wildcard-fallthrough`): all 11 exited 0.
- `check:cli-test-child-env` (red once on #24295) printed `155 spawner
source(s) among 305 under packages/cli/test/**; no new bulk process.env
copy reaches a spawned child, all 189 spawn call(s) declare their
child's env`, exit 0.
- The repo-wide `pnpm lint` and the CI farm are CI's, read from this
PR's check-runs.
## What slice 2 converts (25 files, 28 draws, each ledgered as `slice 2
of #24342`)
- **Bespoke boot helpers that settle on `portDriftError()` and name
`portContentionError()` on exit** (15 files). Spawn on
`KERNEL_ASSIGNED_PORT`, settle on `readKernelAssignedPort(out, what)`,
carry the port on the handle, and build each boot's base URL from it.
Several of these reuse one drawn port across 2 to 8 boots.
- `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-config-boot-flat-docs.integration`
- `serve-config-boot-residual.integration` (2: plus `bootRefused`)
- `serve-host-config-metadata-door.integration` (2)
- `serve-organization-record-page.e2e`
- `serve-view-container-read.integration`
- **Their own banner read-back, no drift check** (2):
`serve-runtime-assets-branding-warning.e2e`,
`start-signal-forwarding.e2e`.
- **Copies of the `runServe()` shape** (3):
- `serve-app-anchored-optional-import.e2e` (`runServeFrom`)
- `serve-process-child-env.e2e` (`probeOrigin`'s default draw; its
`readyVerdict` already handles a requested 0)
- `serve-migrate-exit-schema-parity.integration` (migrate-and-exit)
- **A negative arm** (1): `serve-port-drift-notice.e2e`'s NEGATIVE boot.
`--port 0` never shifts, because `getAvailablePort(0)` answers 0.
- **Deliberate draws, to re-ledger with their real reason rather than
convert** (1): `serve-publishes-bound-port.e2e` (2).
- One draw is a free, unbound port for the connect instrument's negative
arm.
- The other is the non-zero request that is the arm's subject, and that
arm already tolerates the race through the child's own drift notice.
- **`bin/run.js --dev` / `os dev`** (4):
- `dev-boot-output-order.integration`: a pty command line that reads no
port.
- `serve-mcp-capability-collision.e2e`, `serve-mcp-stdio-answers.e2e`,
`serve-stdio-stdout-purity.e2e`: a module-level drawn `port`, a local
`boundPort()` and a local `portDriftError()`. Convert by reading the
first boot's port back and dropping the local drift function.
## Acceptance notes
- **A misleading warning on every kernel-assigned boot.** `os serve
--port 0` prints `WARN Port 0 is in use, using port N instead` in the
ready banner's *Boot diagnostics* block, at the default log level.
- Measured on this head with tsx `serve --port 0` against `export
default {};`: one such line at the default level and one at `info`,
beside a banner that names the real port.
- The source is `plugin-hono-server`'s `kernel:listening` hook, which
compares the requested port (0) with the bound one. Port 0 is a legal
request (`utils/port-contract.ts`, `MIN_PORT = 0`), not a busy port.
- It does not change any verdict here: the noise budget counts it as
informational and stays green. It is reported to the seat for filing,
not fixed here (out of scope).
- **`runServe()`'s new default is a default.** A test of the port doors
themselves (`OS_PORT`, `PORT`, the built-in default) has to pass its
port explicitly, and the docblock says so. No caller does that today:
all 21 call sites passed `--port`.
- **The nightly-tier readings are local.** Nine of the eleven files run
nightly on `main`, not in this PR's `Test Core`, so their readings above
are this machine's.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 741aa85 commit c76e849
12 files changed
Lines changed: 306 additions & 193 deletions
File tree
- packages/cli/test
- helpers
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
212 | 212 | | |
213 | 213 | | |
214 | 214 | | |
215 | | - | |
| 215 | + | |
216 | 216 | | |
217 | 217 | | |
218 | 218 | | |
219 | 219 | | |
220 | 220 | | |
221 | 221 | | |
222 | 222 | | |
223 | | - | |
224 | | - | |
225 | | - | |
226 | | - | |
227 | | - | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
228 | 231 | | |
229 | 232 | | |
230 | 233 | | |
231 | | - | |
232 | | - | |
233 | | - | |
234 | | - | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
235 | 240 | | |
236 | 241 | | |
237 | 242 | | |
| |||
312 | 317 | | |
313 | 318 | | |
314 | 319 | | |
315 | | - | |
316 | | - | |
317 | | - | |
318 | | - | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
319 | 325 | | |
320 | 326 | | |
321 | 327 | | |
| |||
551 | 557 | | |
552 | 558 | | |
553 | 559 | | |
554 | | - | |
555 | | - | |
556 | | - | |
557 | | - | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
558 | 565 | | |
559 | 566 | | |
560 | 567 | | |
| |||
592 | 599 | | |
593 | 600 | | |
594 | 601 | | |
595 | | - | |
| 602 | + | |
| 603 | + | |
596 | 604 | | |
597 | 605 | | |
598 | 606 | | |
| |||
609 | 617 | | |
610 | 618 | | |
611 | 619 | | |
612 | | - | |
| 620 | + | |
| 621 | + | |
613 | 622 | | |
614 | 623 | | |
615 | 624 | | |
| |||
873 | 882 | | |
874 | 883 | | |
875 | 884 | | |
| 885 | + | |
| 886 | + | |
| 887 | + | |
| 888 | + | |
| 889 | + | |
| 890 | + | |
| 891 | + | |
876 | 892 | | |
877 | 893 | | |
878 | 894 | | |
| |||
922 | 938 | | |
923 | 939 | | |
924 | 940 | | |
925 | | - | |
| 941 | + | |
926 | 942 | | |
927 | 943 | | |
| 944 | + | |
| 945 | + | |
| 946 | + | |
| 947 | + | |
| 948 | + | |
| 949 | + | |
| 950 | + | |
| 951 | + | |
| 952 | + | |
928 | 953 | | |
929 | 954 | | |
930 | 955 | | |
| |||
941 | 966 | | |
942 | 967 | | |
943 | 968 | | |
| 969 | + | |
| 970 | + | |
944 | 971 | | |
945 | | - | |
| 972 | + | |
946 | 973 | | |
947 | 974 | | |
948 | 975 | | |
| |||
980 | 1007 | | |
981 | 1008 | | |
982 | 1009 | | |
983 | | - | |
| 1010 | + | |
| 1011 | + | |
| 1012 | + | |
| 1013 | + | |
| 1014 | + | |
| 1015 | + | |
| 1016 | + | |
| 1017 | + | |
| 1018 | + | |
| 1019 | + | |
| 1020 | + | |
| 1021 | + | |
| 1022 | + | |
| 1023 | + | |
984 | 1024 | | |
985 | 1025 | | |
986 | 1026 | | |
| |||
989 | 1029 | | |
990 | 1030 | | |
991 | 1031 | | |
992 | | - | |
| 1032 | + | |
993 | 1033 | | |
994 | 1034 | | |
995 | 1035 | | |
996 | 1036 | | |
997 | | - | |
| 1037 | + | |
| 1038 | + | |
998 | 1039 | | |
999 | 1040 | | |
1000 | 1041 | | |
| |||
0 commit comments