Repository navigation
test(cli): every bespoke CLI spawner takes a kernel-assigned port and reads it back; the released-port ledger keeps only deliberate draws - #24396
Conversation
… reads it back Slice 2 of #24342. The 23 development-column files that spawned the CLI themselves on a released port now spawn on KERNEL_ASSIGNED_PORT and read the bound port back off the child's own output; each boot builds its base URL from its own read-back. The dead local drift checks go with them. The two draws that are an arm's subject stay ledgered with their real reasons. Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
席位核对ACCEPT (lane review) ·
Generated by Claude Code |
Fixes #24342
Clause-②: no
Basis for the Clause-② line: every changed file is under
packages/cli/test/**, the CLI's test harness.@objectstack/clipublishesdist,README.mdandCHANGELOG.md(itsfiles[]). Measured afterpnpm --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 controlchildPortEnv, which comes fromsrc/, occurs in 2. Nothing published moves, so this PR carries theskip-changesetlabel and no changeset.This is slice 2 of #24342 and the card's last: the 25 files slice 1 (PR #24386,
c76e84922) left inRELEASED_DRAW_LEDGERwith the reasonslice 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.mjsagainst merge basecf16a0d18. 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.portDriftError()(14 files, 16 draws).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(itsbootRefused()too),serve-host-config-metadata-door,serve-organization-record-page,serve-view-container-read.runServe(). It settles onreadKernelAssignedPort(out, what), which resolves with the bound port, or rejects when a complete banner names no non-zerohttp://localhostport.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.LiveServethat carried no base gainport, and the caller buildsbasefrom it.package-restart-acceptancebuilds a second base for boot 2.package-install-local-uninstall-cleanupsreads 8 boots, and each one'sbaseis its own.serve-runtime-assets-branding-warning,start-signal-forwarding.reservePort()andboundPortFromBanner()becomeKERNEL_ASSIGNED_PORTandreadKernelAssignedPort().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 beforelisten()and announces no port. The header's port-apparatus section is rewritten to say so.serve-process-child-env:probeOrigin()defaults toKERNEL_ASSIGNED_PORT. ItsreadyVerdict()already handled a requested 0 throughportDriftError()'s 0 branch. The forced-drift arm still hands over a held port on purpose.serve-migrate-exit-schema-parity: the one--portargument.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 drawnportis gone, and so is each file's localportDriftError()with its docblock. The localboundPort()gate stays: a boot resolves only once a port is named. It resolves with that port, and rejects at once on a0, which would be the request announced back. Each boot's HTTP base comes from boot 1's read-back.serve-port-drift-notice): measured, then kept and ledgered, not converted (see below).serve-publishes-bound-port, 2): re-ledgered with their real reasons.The two entries left in
RELEASED_DRAW_LEDGERSLICE_2is deleted. Each remaining entry names why--port 0read 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 (tsxserve, bare config,NODE_ENV=development, default log level):API: http://localhost:36499/, the arm'sDRIFT_NOTICEregex matched nothing, and no stderr line namedPort;--port 0: bannerAPI: http://localhost:43037/,DRIFT_NOTICEmatched nothing, but stderr carriedWARN Port 0 is in use, using port 43037 instead. That is os serve --port 0 prints "WARN Port 0 is in use, using port N instead" at the default log level — a kernel-assigned port is a legal request, not a busy port (plugin-hono-server's kernel:listening compare) #24387's line, fromplugin-hono-server'skernel:listeninghook.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(harnessCLI) andstart-signal-forwarding(spellsbin/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()andportDriftError()now say what may still draw:No helper code changed.
Reverse verification (one-time, not kept)
The pin reads source through the TypeScript AST, so no build or
distpreflight applies.scripts/ablation-replace.mjs(WRAP mode). Inserve-migrate-exit-schema-parity.integration,'--port', KERNEL_ASSIGNED_PORT, ...extraArgsbecame'--port', randomPort(), ...extraArgs, which is this file's line before the conversion.anchor 1 → 0,replace x0 → x1, and blob9d7ef446bad7 → febdea4dc573.Tests 1 failed | 3 passed (4), namingserve-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.9d7ef446bad71d7fd5aba91e23f3265d414925e4== HEAD's blob.git diff HEADwas empty, andgit status --porcelainwas empty, read separately afterwards.--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, basecf16a0d18. The dependency closure was built first withturbo run build --filter='@objectstack/cli^...'(60/60,VERDICT command-exit 0), thenpnpm --filter @objectstack/cli build. Every run went throughscripts/pm/os-verify-lock.shwith a slot. Each file ran in the tier it lives in, and the tier of every changed file is unchanged from base (measured withvitest-tiers.ts's own predicate at base and head: 24 test files, 0 changed; the pin staysunit).unit:released-port-production-spawn.pin4/4.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.--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.pnpm --filter @objectstack/cli typecheck: exit 0. The test layer holds its 3-file / 28-error ledger.tsc --listFiles -p tsconfig.test.jsonputs all 25 changed files in the program, and the 28 errors are all indata-model-rules(23),i18n-extract-action-description(4) andi18n-declared-surface-gate(1), none in a changed file.pnpm exec eslint --no-inline-configover the 25 changed files: exit 0.origin/main. Main has moved to584b4225esince the base. Its 3 new commits touchpackages/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 --commandsderives 52 commands (change set 25 paths vs merge basecf16a0d18, 921 changed lines). Each ran with its exit code captured before any pipe.check:dual-build-cjs-loadsfirst readPREREQUISITE NOT MET(exit 3: some packages outside the CLI closure had nodist/). Re-run once thosedist/trees existed, it read exit 0:111 published require entry point(s) across 69 package(s) load.--ranreconciliation:Run reconciliation — 52 derived, 52 run, 0 NOT-MEASURED, 0 UNRUN.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 underpackages/(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.check-platform-checklist(no exported symbol was renamed or deleted; the deletedportDriftErrorcopies andSLICE_2were module-local). The repo-widepnpm lintand the CI farm are CI's, and are read from this PR's check-runs.Acceptance notes
WARN Port 0 is in use, using port N insteadnow 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 keepsserve-port-drift-notice's negative arm drawn; the other (requested 0 is not "the port it asked for") does not go away with it. os serve --port 0 prints "WARN Port 0 is in use, using port N instead" at the default log level — a kernel-assigned port is a legal request, not a busy port (plugin-hono-server's kernel:listening compare) #24387 remains open and unclaimed, and this PR does not touchplugin-hono-server..e2efiles run nightly onmain, not in this PR'sTest 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