Skip to content

test(cli): runServe() takes a kernel-assigned port and returns the bound one; the released-port pin widens to every CLI spawn - #24386

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24342-s1-dev-column-port
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-24342-s1-dev-column-port

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

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:
  • 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 cli test harness: reservePort()'s close-to-spawn race reached main — the hourly full run went red on start-mcp-stdio-early-initialize (port 45195 taken before os start bound it); 46 test files share the draw #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

…e bound port (WIP)

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
…wn; unconverted files ledgered for slice 2 (WIP)

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
…-dev-column-port

Claude-Session: https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Oct 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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): node scripts/docs-audit/affected-docs.mjs --json d65826fa7cef08145d6655c3fe500671aec678d2 → packageMentionDocs.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT (lane review) · domain:cli seat 1 (seat post #6024) · zhuangjianguo · session session_01SYEJqDL3NXxrJRBVqaMu9T · 2026-10-18T19:01Z · on head 98af344ee · #24342 slice 1 (claim 6252728803, report 6253829714). This is the seat's own review at the claimed default tier, read against GitHub, not the report.

  1. Shape:
    • Draft against main. The first line is Part of #24342: the card stays open for slice 2.
    • Clause-②: no is line-initial. Its basis: all 12 files are under packages/cli/test/**, which ships in no package; the dev measured the added symbols in 0 of @objectstack/cli's published files (positive control childPortEnv: 2).
    • Assignee zhuangjianguo. 12 files, +306 / −193. skip-changeset label, no changeset. Not governed, under 3,000.
    • Inside the claimed surface: the helper, 10 runServe() caller files and the pin. No hold-off path (packages/cli/src/**, scripts/**, .github/workflows/**) is touched.
  2. The helper, read on the diff (test/helpers/serve-process.ts):
    • runServe() appends --port KERNEL_ASSIGNED_PORT only when the caller passes no --port. The child's own listen() picks the port, so no released number crosses a gap.
    • A caller with its own --port keeps portContentionError() and portDriftError(); the held-port arm of serve-port-readback still depends on them.
    • On the kernel-assigned arm, a complete banner that names no non-zero http://localhost port rejects through readKernelAssignedPort(). A run with no complete banner resolves port: undefined and gets no verdict, as before.
  3. The pin, widened (released-port-production-spawn.pin.test.ts):
    • The population is now "spawns the CLI": either entry spelled, or CLI / runServe imported by name, alias or namespace. TSX alone does not count.
    • RELEASED_DRAW_LEDGER holds every unconverted file with its exact draw count and the reason slice 2 of #24342: 25 files, 28 draws. That ledger is slice 2's enumeration nail.
    • The runServe( call shape is spelled in pieces so vitest-tiers.ts keeps the pin in unit. The dev measured every changed test file at its base-commit tier.
  4. Reverse verification, read from the dev's logs:
    • A reverted caller (serve-boot-diagnostics.e2e given --port randomPort()) turns the pin red: 1 failed | 3 passed (4). The restore was proven: blob == HEAD e97518ea9587, git diff HEAD empty. Only the new runServe leg of the population could catch that file.
    • The A4 read-back rejects a real boot whose API: row is an OS_BASE_URL origin. The scratch test was deleted.
  5. Tests and gates:
    • 56/56 in 11 files on this head, after merging origin/main 5da2e8881. Nine of the files run nightly on main, so those readings are local (Acceptance notes).
    • dispatch-gates.mjs: 52 derived, 52 run, 0 NOT-MEASURED. The 11 declared-wide families exit 0. check:cli-test-child-env exits 0. CI on this head is running at this stamp.
  6. Out-of-scope findings:
  7. Slice 2 handover: the 25 files under "What slice 2 converts" in the PR body are slice 2's surface. A file leaves the ledger in the same commit that converts it, so the pin stays green slice by slice. test budget (domain:cli): 119 test file(s) over 10 s of wall in the hourly full battery (Part of #23151) #23181 still waits behind the whole card.
  8. Landing: ready plus auto-merge through the queue once every check on this head is green or an expected skip. After the merge the card returns to pm:queue with a Release: line for slice 2.

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants