Skip to content

Commit db4618b

Browse files
test(cli): every bespoke CLI spawner takes a kernel-assigned port and reads it back; the released-port ledger keeps only deliberate draws (#24396)
Fixes #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 spreads or edits (`KERNEL_ASSIGNED_PORT`, `readKernelAssignedPort`, `RELEASED_DRAW_LEDGER`) 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 2 of #24342 and the card's last: the 25 files slice 1 (PR #24386, `c76e84922`) left in `RELEASED_DRAW_LEDGER` with the reason `slice 2 of #24342`. After it, no ledger entry carries a slice reason. Two files stay ledgered with their real reasons, so the card's "Done when" holds: every spawner takes the closed mechanism or is named exempt with its reason. ## Size 921 changed lines (+349 / -572, 25 files), measured by `dispatch-gates.mjs` against merge base `cf16a0d18`. That is under the card's 1,000-line split line, so all six groups of slice 1's "What slice 2 converts" are in this PR and there is no slice 3. ## What changed, by slice 1's groups The recipe is the same everywhere: spawn on `KERNEL_ASSIGNED_PORT` (`--port 0` / `-p 0`), read the bound port back from the child's own output, and build each boot's base URL from its own read-back. A file that reuses one drawn port across several boots now reads each boot's port separately. - **Bespoke boot helpers that settled on `portDriftError()`** (14 files, 16 draws). - Files: `artifact-flag-precedence`, `package-install-local-{boot-steps,handlers,hooks,jobs,jobs-pull,jobs-shared-name,uninstall-cleanups}`, `package-restart-acceptance`, `serve-config-boot-flat-docs`, `serve-config-boot-residual` (its `bootRefused()` too), `serve-host-config-metadata-door`, `serve-organization-record-page`, `serve-view-container-read`. - The settle step is now the read-back verdict, as slice 1's A4 did for `runServe()`. It settles on `readKernelAssignedPort(out, what)`, which resolves with the bound port, or rejects when a complete banner names no non-zero `http://localhost` port. - The exit handler drops `portContentionError()`. That helper says "⛔ Not for a child spawned on KERNEL_ASSIGNED_PORT", because such a child was handed no number to lose. An early exit now rejects with the child's own output. - Files with a `LiveServe` that carried no base gain `port`, and the caller builds `base` from it. `package-restart-acceptance` builds a second base for boot 2. `package-install-local-uninstall-cleanups` reads 8 boots, and each one's `base` is its own. - **Their own banner read-back** (2): `serve-runtime-assets-branding-warning`, `start-signal-forwarding`. `reservePort()` and `boundPortFromBanner()` become `KERNEL_ASSIGNED_PORT` and `readKernelAssignedPort()`. - **Copies of the `runServe()` shape** (3): - `serve-app-anchored-optional-import` (`runServeFrom`): the contention and drift checks become the read-back, taken only off a complete banner. The undeclared leg dies before `listen()` and announces no port. The header's port-apparatus section is rewritten to say so. - `serve-process-child-env`: `probeOrigin()` defaults to `KERNEL_ASSIGNED_PORT`. Its `readyVerdict()` already handled a requested 0 through `portDriftError()`'s 0 branch. The forced-drift arm still hands over a held port on purpose. - `serve-migrate-exit-schema-parity`: the one `--port` argument. - **`bin/run.js --dev` / `os dev`** (4): - `dev-boot-output-order`: the pty command line takes `-p 0`. - `serve-mcp-capability-collision`, `serve-mcp-stdio-answers`, `serve-stdio-stdout-purity`: the module-level drawn `port` is gone, and so is each file's local `portDriftError()` with its docblock. The local `boundPort()` gate stays: a boot resolves only once a port is named. It resolves with that port, and rejects at once on a `0`, which would be the request announced back. Each boot's HTTP base comes from boot 1's read-back. - **The drift notice's NEGATIVE arm** (`serve-port-drift-notice`): measured, then **kept and ledgered, not converted** (see below). - **Deliberate draws** (`serve-publishes-bound-port`, 2): re-ledgered with their real reasons. ## The two entries left in `RELEASED_DRAW_LEDGER` `SLICE_2` is deleted. Each remaining entry names why `--port 0` read back afterwards would change what the file proves: - `serve-port-drift-notice.e2e` (1). The NEGATIVE arm is a boot that binds exactly the non-zero port it asked for and says nothing about ports. Measured on this head, with the arm's own spawn (tsx `serve`, bare config, `NODE_ENV=development`, default log level): - control, a released non-zero port (36499): banner `API: http://localhost:36499/`, the arm's `DRIFT_NOTICE` regex matched nothing, and no stderr line named `Port`; - `--port 0`: banner `API: http://localhost:43037/`, `DRIFT_NOTICE` matched nothing, but stderr carried `WARN Port 0 is in use, using port 43037 instead`. That is #24387's line, from `plugin-hono-server`'s `kernel:listening` hook. - So the conversion would put a port line into the boot whose subject is "says nothing about ports", and it would trade "requested equals bound" for "requested is 0". Per the claim, it stays ledgered with that reason. - `serve-publishes-bound-port.e2e` (2), as slice 1 named them. `reservePort()` is the connect instrument's NEGATIVE arm: a port probed free and left unbound, with nothing spawned on it. `randomPort()` is the non-zero request that the ordinary-boot arm is about, and that arm reads a lost race out of the child's own drift notice instead of failing on it. The pin's other tests are unchanged. The positive controls `package-install-local-handlers` (harness `CLI`) and `start-signal-forwarding` (spells `bin/run-dev.js`) are still in the population. "Every ledger entry still names a file in the population" still guards against a stale row. ## Helper docblocks (`test/helpers/serve-process.ts`, comments only) `reservePort()`, `randomPort()` and `portDriftError()` now say what may still draw: - instruments that need a number nothing listens on, or that measure the draw itself; - a CLI spawn whose subject is a non-zero request, which the pin's ledger names with its reason. No helper code changed. ## Reverse verification (one-time, not kept) The pin reads source through the TypeScript AST, so no build or `dist` preflight applies. - **Mutation**, through `scripts/ablation-replace.mjs` (WRAP mode). In `serve-migrate-exit-schema-parity.integration`, `'--port', KERNEL_ASSIGNED_PORT, ...extraArgs` became `'--port', randomPort(), ...extraArgs`, which is this file's line before the conversion. - The tool reported `anchor 1 → 0`, `replace x0 → x1`, and blob `9d7ef446bad7 → febdea4dc573`. - **Result.** The pin went `Tests 1 failed | 3 passed (4)`, naming `serve-migrate-exit-schema-parity.integration.test.ts: 1 released-port draw(s) (line 96), ledger allows 0`. The file has no ledger entry any more, so one draw is a red. - **Restore.** Blob after restore `9d7ef446bad71d7fd5aba91e23f3265d414925e4` == HEAD's blob. `git diff HEAD` was empty, and `git status --porcelain` was empty, read separately afterwards. - **The negative-arm probe** above (control versus `--port 0`) was a scratch script outside the repo. Both children were stopped and awaited, and the tree is clean. ## Tests All on head `4fb88734c`, base `cf16a0d18`. The dependency closure was built first with `turbo run build --filter='@objectstack/cli^...'` (60/60, `VERDICT command-exit 0`), then `pnpm --filter @objectstack/cli build`. Every run went through `scripts/pm/os-verify-lock.sh` with a slot. Each file ran in the tier it lives in, and the tier of every changed file is unchanged from base (measured with `vitest-tiers.ts`'s own predicate at base and head: 24 test files, 0 changed; the pin stays `unit`). - `unit`: `released-port-production-spawn.pin` 4/4. - Nightly tier (`OS_TEST_TIERS=nightly`, `--project integration --maxWorkers=2`): - `serve-app-anchored-optional-import`, `serve-organization-record-page`, `serve-process-child-env`, `serve-runtime-assets-branding-warning`, `start-signal-forwarding`: 5 files, 23/23. - `serve-mcp-capability-collision`, `serve-mcp-stdio-answers`, `serve-stdio-stdout-purity`: 3 files, 5/5. - Queue tier (`--project integration --maxWorkers=2`): - `serve-config-boot-flat-docs`, `serve-config-boot-residual`, `serve-host-config-metadata-door`, `serve-migrate-exit-schema-parity`, `serve-view-container-read`, `package-restart-acceptance`: 6 files, 37/37. - `artifact-flag-precedence`, `dev-boot-output-order`: 2 files, 13/13. - `package-install-local-{boot-steps,handlers,hooks,jobs-pull}`: 4 files, 40/40. - `package-install-local-{jobs-shared-name,jobs,uninstall-cleanups}`: 3 files, 37/37. - In all: 23 converted files, 155/155, plus the pin's 4/4. - `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 25 changed files in the program, and the 28 errors are all in `data-model-rules` (23), `i18n-extract-action-description` (4) and `i18n-declared-surface-gate` (1), none in a changed file. - `pnpm exec eslint --no-inline-config` over the 25 changed files: exit 0. - Not merged with `origin/main`. Main has moved to `584b4225e` since the base. Its 3 new commits touch `packages/cli/src` (organization ownership, `verify.ts`) and none of the files here, and there is no conflict. So, per the dev contract (merge only on a conflict, a main-caused red or a dequeue), CI's merge ref is what reads the joint tree. ## Gates Local subset, head `4fb88734c`. - `node scripts/pm/dispatch-gates.mjs --commands` derives 52 commands (change set 25 paths vs merge base `cf16a0d18`, 921 changed lines). Each ran with its exit code captured before any pipe. - 51 exited 0 on the first pass. - `check:dual-build-cjs-loads` first read `PREREQUISITE NOT MET` (exit 3: some packages outside the CLI closure had no `dist/`). Re-run once those `dist/` trees existed, it read exit 0: `111 published require entry point(s) across 69 package(s) load`. - `--ran` reconciliation: `Run reconciliation — 52 derived, 52 run, 0 NOT-MEASURED, 0 UNRUN.` - The 11 declared-wide families (`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. So did the three artifact-roster gates whose roster sits under `packages/` (`check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`). - `check:cli-test-child-env`: `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. - `check:nul-bytes`: `no raw ASCII control bytes`, exit 0. - Not run locally: `check-platform-checklist` (no exported symbol was renamed or deleted; the deleted `portDriftError` copies and `SLICE_2` were module-local). The repo-wide `pnpm lint` and the CI farm are CI's, and are read from this PR's check-runs. ## Acceptance notes - **Kernel-assigned boots now print #24387's warning.** `WARN Port 0 is in use, using port N instead` now appears in the converted boots at any log level that admits a warn. No assertion here reads it: every converted file passed on this head. It is the one reason, of two, that keeps `serve-port-drift-notice`'s negative arm drawn; the other (requested 0 is not "the port it asked for") does not go away with it. #24387 remains open and unclaimed, and this PR does not touch `plugin-hono-server`. - **The nightly-tier readings are local.** The 8 `.e2e` files run nightly on `main`, not in this PR's `Test Core`. - **`package-restart-acceptance`'s boot 2 is no longer on boot 1's port.** The file's subject is the stored package surviving a restart on the same database file. Nothing in it read the port as state, and both boots pass on their own read-back ports. --- _Generated by [Claude Code](https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1827571 commit db4618b

25 files changed

Lines changed: 349 additions & 572 deletions

‎packages/cli/test/artifact-flag-precedence.integration.test.ts‎

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,9 @@ import {
8181
CLI,
8282
childEnv,
8383
E2E_SECRET_KEY,
84-
portContentionError,
85-
portDriftError,
84+
KERNEL_ASSIGNED_PORT,
8685
probeThroughChild,
87-
randomPort,
86+
readKernelAssignedPort,
8887
TSX,
8988
} from './helpers/serve-process.js';
9089
import { defineStackSourceFromLiteral, linkSpec, writeDefineStackConfig } from './helpers/define-stack-fixture.js';
@@ -125,7 +124,7 @@ interface Live {
125124
output: () => string;
126125
}
127126

128-
function boot(argv: string[], cwd: string, port: string, env: Record<string, string | undefined>): Promise<Live> {
127+
function boot(argv: string[], cwd: string, env: Record<string, string | undefined>): Promise<Live> {
129128
return new Promise((resolveBoot, rejectBoot) => {
130129
const child = spawn(TSX, [CLI, ...argv], {
131130
cwd,
@@ -147,26 +146,26 @@ function boot(argv: string[], cwd: string, port: string, env: Record<string, str
147146
groups.push(child);
148147
let out = '';
149148
let settled = false;
150-
const settle = (err: Error | null) => {
149+
const settle = (verdict: Error | number) => {
151150
if (settled) return;
152151
settled = true;
153152
clearTimeout(timer);
154-
if (err) rejectBoot(err);
155-
else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out });
153+
if (verdict instanceof Error) rejectBoot(verdict);
154+
else resolveBoot({ child, base: `http://localhost:${verdict}`, output: () => out });
156155
};
157156
const timer = setTimeout(
158157
() => settle(new Error(`${argv[0]} never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)),
159158
BOOT_TIMEOUT_MS,
160159
);
161160
const onData = (d: unknown) => {
162161
out += String(d);
163-
if (READY.test(out)) settle(portDriftError(out, `os ${argv[0]}`, port));
162+
// The child is the authority on the port it bound: a kernel-assigned one (#24342).
163+
if (READY.test(out)) settle(readKernelAssignedPort(out, `os ${argv[0]}`));
164164
};
165165
child.stdout?.on('data', onData);
166166
child.stderr?.on('data', onData);
167167
child.on('exit', (code) =>
168-
settle(portContentionError(out, `os ${argv[0]}`, port)
169-
?? new Error(`os ${argv[0]} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)),
168+
settle(new Error(`os ${argv[0]} exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)),
170169
);
171170
});
172171
}
@@ -250,10 +249,9 @@ interface Reading {
250249
}
251250

252251
async function measure(argv: string[], cwd: string, env: Record<string, string | undefined> = {}): Promise<Reading> {
253-
const port = randomPort();
254252
let live: Live | undefined;
255253
try {
256-
live = await boot([...argv.slice(0, 1), '-p', port, ...argv.slice(1)], cwd, port, env);
254+
live = await boot([...argv.slice(0, 1), '-p', KERNEL_ASSIGNED_PORT, ...argv.slice(1)], cwd, env);
257255
const signIn = await http(live, 'POST', '/api/v1/auth/sign-in/email', '', { email: EMAIL, password: PASSWORD });
258256
const token = signIn.body?.token;
259257
if (signIn.status !== 200 || typeof token !== 'string') {

‎packages/cli/test/dev-boot-output-order.integration.test.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ import { fileURLToPath } from 'node:url';
5858
import {
5959
childEnv,
6060
E2E_SECRET_KEY,
61-
randomPort,
61+
KERNEL_ASSIGNED_PORT,
6262
requireBuiltCli,
6363
RUN_JS_RESOLVES_FROM_DIST,
6464
} from './helpers/serve-process.js';
@@ -115,10 +115,9 @@ function screenLines(raw: string): string[] {
115115

116116
/** Boot `os dev` under a pty until the banner and the block have both printed, then Ctrl+C it. */
117117
function bootUnderPty(boot: number): Promise<string> {
118-
const port = randomPort();
119118
const command = [
120119
`exec '${process.execPath}' '${CLI}' dev --no-watch`,
121-
`-d 'file:boot${boot}.db' -p ${port}`,
120+
`-d 'file:boot${boot}.db' -p ${KERNEL_ASSIGNED_PORT}`,
122121
].join(' ');
123122
return new Promise((resolveBoot, rejectBoot) => {
124123
const child = spawn('script', ['-qfec', command, '/dev/null'], {

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

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,8 @@ function probeBind(want: number): number | null {
182182

183183
/**
184184
* 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}.
185+
* (#12441) — for the few sites that still take a number. ⛔ NOT for a CLI
186+
* spawn, in either posture: those take {@link KERNEL_ASSIGNED_PORT}.
187187
*
188188
* Listen on `0.0.0.0:0`, read the port the kernel assigned, close the listener,
189189
* return the port for the caller to hand to `os serve`.
@@ -224,21 +224,20 @@ function probeBind(want: number): number | null {
224224
* development-posture child hops instead, and `portDriftError()` reds the hop
225225
* just as surely, so that column closes the same way: `runServe()` hands its
226226
* child a kernel-assigned port whenever the caller passes no `--port`, and
227-
* returns the port the child bound (#24342).
227+
* returns the port the child bound; every bespoke spawner reads its own
228+
* kernel-assigned port back the same way (#24342).
228229
* `released-port-production-spawn.pin.test.ts` holds both: a file that spawns
229230
* the CLI may call this or `randomPort()` only as often as its ledger entry
230231
* there allows — zero without one.
231232
*
232233
* What may still call it, each with the race still real and unclosed there:
233234
*
234-
* • the development-column spawners slice 2 of #24342 has yet to convert —
235-
* the files that spawn `CLI` themselves (the `package-install-local-*`
236-
* `os start` family among them) and the four `bin/run.js --dev` / `os dev`
237-
* files, each ledgered in the pin with its exact count. A lost race there
238-
* is a hop to the next port, and `portDriftError()` below turns it into a
239-
* red on a correct tree;
240235
* • instruments that need a number NOTHING is listening on (a closed-port
241-
* probe), or that measure this draw itself (`serve-port-bind-probe`).
236+
* probe), or that measure this draw itself (`serve-port-bind-probe`);
237+
* • a CLI spawn whose SUBJECT is a non-zero request — a boot that must bind
238+
* exactly the port it asked for, which `--port 0` cannot ask. The pin's
239+
* ledger names each such file with its reason and exact count; a lost
240+
* race there is the residual this draw narrows and cannot close.
242241
*
243242
* `portContentionError()` below is what makes a residual loss SAY it is a port
244243
* race instead of `serve exited 1 before "Server is ready"`.
@@ -318,8 +317,8 @@ export function holdPort(): Promise<{ port: number; release: () => Promise<void>
318317
* ⚠️ The name is kept — and is now a slight misnomer, which is cheaper than the
319318
* alternative. It is `String(reservePort())` and nothing else; the draw, the
320319
* guarantees, the residual race and the callers it may still serve
321-
* (instruments, and the development-column files the pin's ledger names until
322-
* slice 2 of #24342 converts them — never another CLI spawn) are all
320+
* (instruments, and the few CLI spawns the pin's ledger names with their
321+
* reason — never another CLI spawn) are all
323322
* documented on `reservePort()` above, and there is no second mechanism hiding
324323
* behind this name.
325324
*/
@@ -599,8 +598,9 @@ export function boundPortFromBanner(output: string): BannerPortReadback {
599598
* the port between this harness's bind probe and the child's `listen()`. The
600599
* production column would die loudly, which is why it no longer draws (it takes
601600
* {@link KERNEL_ASSIGNED_PORT}, #24295); the development column drifts and gets
602-
* this. `runServe()` without a `--port` no longer draws either (#24342); the
603-
* spawners still ledgered in the pin do, and this is what makes their race a red.
601+
* this. `runServe()` without a `--port` no longer draws either, nor does any
602+
* bespoke spawner (#24342): what reaches this now is an EXPLICIT port — one held
603+
* on purpose to force the drift, or a draw the pin's ledger names.
604604
*
605605
* ## Why `unreadable` is an ERROR and not a skip
606606
*

‎packages/cli/test/package-install-local-boot-steps.integration.test.ts‎

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,9 @@ import {
5555
CLI,
5656
childEnv,
5757
E2E_SECRET_KEY,
58-
portContentionError,
59-
portDriftError,
58+
KERNEL_ASSIGNED_PORT,
6059
probeThroughChild,
61-
randomPort,
60+
readKernelAssignedPort,
6261
TSX,
6362
} from './helpers/serve-process.js';
6463

@@ -153,9 +152,9 @@ interface LiveStart {
153152
output: () => string;
154153
}
155154

156-
function bootStart(cwd: string, home: string, port: string, extra: string[] = []): Promise<LiveStart> {
155+
function bootStart(cwd: string, home: string, extra: string[] = []): Promise<LiveStart> {
157156
return new Promise((resolveBoot, rejectBoot) => {
158-
const child = spawn(TSX, [CLI, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], {
157+
const child = spawn(TSX, [CLI, 'start', '-p', KERNEL_ASSIGNED_PORT, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], {
159158
cwd,
160159
// `childEnv`, never a bare `...process.env` — see its header.
161160
env: childEnv({ NO_COLOR: '1', OS_CLOUD_URL: 'off', OS_LOG_LEVEL: 'warn', OS_SECRET_KEY: E2E_SECRET_KEY }),
@@ -166,27 +165,26 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = []
166165
groups.push(child);
167166
let out = '';
168167
let settled = false;
169-
const settle = (err: Error | null) => {
168+
const settle = (verdict: Error | number) => {
170169
if (settled) return;
171170
settled = true;
172171
clearTimeout(timer);
173-
if (err) rejectBoot(err);
174-
else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out });
172+
if (verdict instanceof Error) rejectBoot(verdict);
173+
else resolveBoot({ child, base: `http://localhost:${verdict}`, output: () => out });
175174
};
176175
const timer = setTimeout(
177176
() => settle(new Error(`os start never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)),
178177
BOOT_TIMEOUT_MS,
179178
);
180179
const onData = (d: unknown) => {
181180
out += String(d);
182-
// The child is the authority on the port it bound.
183-
if (READY.test(out)) settle(portDriftError(out, 'os start', port));
181+
// The child is the authority on the port it bound: a kernel-assigned one (#24342).
182+
if (READY.test(out)) settle(readKernelAssignedPort(out, 'os start'));
184183
};
185184
child.stdout?.on('data', onData);
186185
child.stderr?.on('data', onData);
187186
child.on('exit', (code) =>
188-
settle(portContentionError(out, 'os start', port)
189-
?? new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)),
187+
settle(new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)),
190188
);
191189
});
192190
}
@@ -320,24 +318,23 @@ beforeAll(async () => {
320318
const hostArtifact = join(runtimeDir, 'host.json');
321319
writeFileSync(hostArtifact, JSON.stringify(HOST_ARTIFACT, null, 2), 'utf8');
322320
const home = join(runtimeDir, 'home');
323-
const port = randomPort();
324321

325322
// ── boot 1: the host, hot install, probe ───────────────────────────────
326-
const first = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]);
323+
const first = await bootStart(runtimeDir, home, ['--artifact', hostArtifact]);
327324
const token = await authenticate(first);
328325
installs.push(await packageInstall(appDir, first));
329326
phases.install = await probe(first, token);
330327
await stopGroup(first.child);
331328

332329
// ── boot 2: same host, home and cwd — the ledger rehydrates on kernel:ready ──
333-
const second = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]);
330+
const second = await bootStart(runtimeDir, home, ['--artifact', hostArtifact]);
334331
phases.restart = await probe(second, await authenticate(second));
335332
await stopGroup(second.child);
336333

337334
// ── boot 3: the CONTROL — the same file as the boot artifact ───────────
338335
const controlDir = join(root, 'control');
339336
mkdirSync(controlDir, { recursive: true });
340-
const third = await bootStart(controlDir, join(controlDir, 'home'), port, ['--artifact', join(appDir, 'dist', 'objectstack.json')]);
337+
const third = await bootStart(controlDir, join(controlDir, 'home'), ['--artifact', join(appDir, 'dist', 'objectstack.json')]);
341338
phases.control = await probe(third, await authenticate(third));
342339
await stopGroup(third.child);
343340
}, 4 * BOOT_TIMEOUT_MS);

‎packages/cli/test/package-install-local-handlers.integration.test.ts‎

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@
4040
*
4141
* The tsx source entry (`bin/run-dev.js`), as every `runServe()` caller spawns
4242
* it, for `os start` and for `os package install` alike. It pins
43-
* `NODE_ENV=development`, which lets `serve` auto-shift off a port taken
44-
* between the probe and the bind, so the ready banner is read back
45-
* (`portDriftError`) before any request is addressed to the port. The CLI runs
43+
* `NODE_ENV=development`, where a taken port is a hop rather than an exit, so
44+
* each boot takes a kernel-assigned port and reads it back off its own ready
45+
* banner (`readKernelAssignedPort`) before any request is addressed to it. The CLI runs
4646
* from `src/`, but every workspace package it loads — `@objectstack/runtime`
4747
* and `@objectstack/cloud-connection` included — resolves through its
4848
* `exports` to `dist/`: an ablation of either package's source reaches this
@@ -60,10 +60,9 @@ import {
6060
CLI,
6161
childEnv,
6262
E2E_SECRET_KEY,
63-
portContentionError,
64-
portDriftError,
63+
KERNEL_ASSIGNED_PORT,
6564
probeThroughChild,
66-
randomPort,
65+
readKernelAssignedPort,
6766
TSX,
6867
} from './helpers/serve-process.js';
6968

@@ -142,9 +141,9 @@ interface LiveStart {
142141
output: () => string;
143142
}
144143

145-
function bootStart(cwd: string, home: string, port: string, extra: string[] = []): Promise<LiveStart> {
144+
function bootStart(cwd: string, home: string, extra: string[] = []): Promise<LiveStart> {
146145
return new Promise((resolveBoot, rejectBoot) => {
147-
const child = spawn(TSX, [CLI, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], {
146+
const child = spawn(TSX, [CLI, 'start', '-p', KERNEL_ASSIGNED_PORT, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], {
148147
cwd,
149148
// `childEnv`, never a bare `...process.env` — see its header.
150149
env: childEnv({ NO_COLOR: '1', OS_CLOUD_URL: 'off', OS_LOG_LEVEL: 'warn', OS_SECRET_KEY: E2E_SECRET_KEY }),
@@ -155,27 +154,26 @@ function bootStart(cwd: string, home: string, port: string, extra: string[] = []
155154
groups.push(child);
156155
let out = '';
157156
let settled = false;
158-
const settle = (err: Error | null) => {
157+
const settle = (verdict: Error | number) => {
159158
if (settled) return;
160159
settled = true;
161160
clearTimeout(timer);
162-
if (err) rejectBoot(err);
163-
else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out });
161+
if (verdict instanceof Error) rejectBoot(verdict);
162+
else resolveBoot({ child, base: `http://localhost:${verdict}`, output: () => out });
164163
};
165164
const timer = setTimeout(
166165
() => settle(new Error(`os start never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)),
167166
BOOT_TIMEOUT_MS,
168167
);
169168
const onData = (d: unknown) => {
170169
out += String(d);
171-
// The child is the authority on the port it bound.
172-
if (READY.test(out)) settle(portDriftError(out, 'os start', port));
170+
// The child is the authority on the port it bound: a kernel-assigned one (#24342).
171+
if (READY.test(out)) settle(readKernelAssignedPort(out, 'os start'));
173172
};
174173
child.stdout?.on('data', onData);
175174
child.stderr?.on('data', onData);
176175
child.on('exit', (code) =>
177-
settle(portContentionError(out, 'os start', port)
178-
?? new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)),
176+
settle(new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)),
179177
);
180178
});
181179
}
@@ -327,10 +325,9 @@ beforeAll(async () => {
327325
const runtimeDir = join(root, 'runtime');
328326
mkdirSync(runtimeDir, { recursive: true });
329327
const home = join(runtimeDir, 'home');
330-
const port = randomPort();
331328

332329
// ── boot 1: empty `os start`, install, probe, reinstall, probe ─────────
333-
const first = await bootStart(runtimeDir, home, port);
330+
const first = await bootStart(runtimeDir, home);
334331
const token = await authenticate(first);
335332
installs.push(await packageInstall(appDir, first));
336333
phases.install = await probe(first, token);
@@ -339,14 +336,14 @@ beforeAll(async () => {
339336
await stopGroup(first.child);
340337

341338
// ── boot 2: same home and cwd — the ledger rehydrates on kernel:ready ──
342-
const second = await bootStart(runtimeDir, home, port);
339+
const second = await bootStart(runtimeDir, home);
343340
phases.restart = await probe(second, await authenticate(second));
344341
await stopGroup(second.child);
345342

346343
// ── boot 3: the CONTROL — the same file as the boot artifact ───────────
347344
const controlDir = join(root, 'control');
348345
mkdirSync(controlDir, { recursive: true });
349-
const third = await bootStart(controlDir, join(controlDir, 'home'), port, ['--artifact', join(appDir, 'dist', 'objectstack.json')]);
346+
const third = await bootStart(controlDir, join(controlDir, 'home'), ['--artifact', join(appDir, 'dist', 'objectstack.json')]);
350347
phases.control = await probe(third, await authenticate(third));
351348
await stopGroup(third.child);
352349
}, 4 * BOOT_TIMEOUT_MS);

0 commit comments

Comments
 (0)