Skip to content

fix(cli): os serve writes the runtime state file before it announces the bound port - #13209

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-13193-bound-port-state-file-race
Aug 29, 2026
Merged

fix(cli): os serve writes the runtime state file before it announces the bound port#13209
os-zhuang merged 2 commits into
mainfrom
claude/issue-13193-bound-port-state-file-race

Conversation

@os-trump

@os-trumpos-trump commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Fixes#13193
Fixes#13158

Authored in Claude Code session session_01TvqBFLRzXdSPcbusDoED9k (durable copy kept in the body, since editing a PR body rewrites the footer link).

The ordering grep came first, and it decided the shape of the fix

The anchor's triage set one step ahead of any repair: establish whether the announcements precede the state-file write. Measured on origin/main33184fd, packages/cli/src/commands/serve.ts:

lineevent
4287const boundPort = resolveBoundPort(kernel, port);
4291printServerReady({ ... }) — ready banner, ANNOUNCE #1
4373process.send({ type: 'objectstack:listening', ... }) — IPC, ANNOUNCE #2
4380fs.writeFileSync(runtimeFile, ...) — the state file, WRITTEN LAST

Both announcements fire strictly before the file exists, so the race is structural, not environmental. That rules out the reader-side repair: a poll-with-deadline in the test would have hidden a live product defect, because a real supervisor that opens runtime.env_local.json when the banner says "ready", and an os dev parent that reacts to the IPC message, lose exactly the same race. Load never created it — a busy machine only widens it by descheduling the child between the announcement and the write.

The corollary that explains the reported symptom: bootServe resolves readiness on the banner tail AND the IPC message (test/serve-publishes-bound-port.e2e.test.ts:210), both of which precede the write. The e2e is an ordinary consumer, not a badly written test — which is why the only two arms that hit it are the two where bound port is not the requested port, i.e. the two for which the state file is the sole carrier of the truth.

The change

serve.ts publishes through one ordered seam:

publishBoundPort(boundPort, runtimeBoundPortChannels(printBanner))
1. writeRuntimeState — the file both announcements send a consumer to
2. announceListening — IPC
3. printBanner — the banner

The banner call became a thunk so the seam owns all three; nothing inside that literal changed and nothing in it is async, so deferring it is a pure move. Each leg keeps its own try, so a state-file write that fails still cannot take the announcements down with it — a boot does not die because a supervision file could not be written.

Reverse verification — the part that matters for a race

A race repair whose test passes once is not evidence, so the ordering is observed directly rather than raced for. publishBoundPort takes its channels as arguments; test/serve-bound-port-publish-order.test.ts records the sequence they are driven in and measures existsSyncat the instant each announcement fires — the exact predicate that was false in production.

Ablation, with the fix committed first so the restore had a real reference point: the order inside publishBoundPort was reversed to the pre-fix banner/IPC/file sequence. Mutation proven on disk by blob hash (f77ec4e8 to 196993e3) and by marker counts, not by an editor exit code; the script carried a trap with absolute paths.

AssertionError: all three channels must fire, in this exact order:
expected [ 'banner', 'ipc', 'state-file' ] to deeply equal [ 'state-file', 'ipc', 'banner' ]
AssertionError: runtime state file must exist when the IPC message is sent:
expected false to be true

3 of 4 red, deterministically, with no load, no sleep and no retry — expected false to be true is the merge queue's ENOENT reproduced directly. The one test that stayed green is the payload-agreement one, which does not assert order. Restore proven by git diff HEAD empty plus a blob hash equal to HEAD — not by an exit code. Mutating source alone flipped the result, which also proves the pin resolves src/ through its relative import, so no dist/ is involved.

Rework: #13062's publish pins, rewritten upward

The first push turned Test Core (1/6) red — honestly, and on this PR's own diff. packages/cli/src/commands/serve-bound-port-publication.test.ts carries #13062's sibling pins, and three of them assert the source text of serve.ts ("read off the code", as the suite says). Folding the three publish sites into the seam removed the literal text they grepped for. Reproduced locally before touching anything: 3 failed, 16 passed, exactly the three reported.

Neither escape was taken: nothing was skipped, deleted or weakened, and the old text was not reintroduced to satisfy a grep — that would have undone the ordering repair. The pins were rewritten against the new structure, which the refactor makes better assertable, because the seam takes its channels as arguments:

  • the IPC leg — was a grep for the process.send(...) call; now drives runtimeBoundPortChannels().announceListening with process.send swapped for a recorder and asserts the message that really arrives. (⚠️ never delivered for real: under vitest's forks pool process.send is the runner's own control channel. The swap is synchronous, one call, restored in finally.) A new pin also asserts the leg stays silent rather than throwing when no IPC channel is open.
  • the state file — was a regex over the object literal's formatting; now writes through the real channel into a temp OS_HOME and reads the file off disk, asserting port, url, environmentId and pid (the supervisor contract that regex was really guarding).
  • const runtimeUrl — that variable is gone. Replaced by the pin that still cannot be driven in-process: the one wiring site hands the seam boundPort, never port. This is strictly stronger than before — there used to be three publish sites to get wrong, and there is now one, asserted by both a positive toContain and a publishBoundPort( count of exactly 2 (declaration plus that single call).
  • the negative not.toContain('const runtimeUrl = ...${port}') had gone vacuous (the variable no longer exists, so it passed for the wrong reason). It is replaced by the live spelling of the same regression: not.toContain('publishBoundPort(port,').

Suite now 21 passed, up from 19 — the rewrite adds coverage rather than trading it away.

Each rewritten pin proven able to fail

A pin that cannot fail is worse than the source-text pin it replaced, so all four were ablated against serve.ts, each mutation proven on disk by blob hash and marker counts, each restored and the restore proven by git diff HEAD empty plus a blob equal to HEAD:

ablationwhat it breaksresult
publishBoundPort(port, ...)the bound/requested distinction — #13062's whole reason to exist2 failed: the wiring pin, and "NONE has drifted back onto the requested port"
writeRuntimeState({ port: boundPort + 1, ... })the "one number, same on all three" property2 failed: the same-number pin, and the state-file pin
pid: process.pid, deletedthe supervisor contract1 failed: the state-file pin
IPC leg silencedthe announcement never reaching process.send1 failed: the IPC pin

Recorded rather than hidden: the fourth ablation's first attempt matched zero anchors and was a no-op, so it produced no reading at all. It was re-run with a corrected anchor; the row above is from the run that actually mutated the file.

The ordering property stays in test/serve-bound-port-publish-order.test.ts and the #13062 properties stay here — kept in separate files deliberately, so the two fail for different reasons and keep naming them.

Gates

Re-derived for the enlarged 4-path change set with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack at head 35574c9d. (It first refused — the recreated worktree was shallow, so it declined to fall back to a two-dot range that would have attributed other PRs' files to this branch; deepened, then re-derived.)

Green, each read from the gate's own verdict line with the exit code captured before any pipe: check:type-check-coverage, check:type-check-debt (31 ledger entries re-measured, 1570 raw errors, none above its recorded number, "surplus: none"), check:test-source-alias, check:cli-test-child-env, check:cross-package-test-inputs, check:comment-mask-adoption, check:route-envelope, check:published-files, check:nul-bytes, check:empty-changeset, check:objectui-changeset, check:page-declaration-shape, check:slot-lookup, check:logger-receiver-detach, check:objectql-double-limit, check:type-source-resolution, check:changeset-gate-self-tests, check:i18n, check:i18n-coverage, check:pm-half-states, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check-keyed-text-bounds, check-undeclared-dep-imports, check-ci-filter-parity, check-plugin-teardown-shape, check-adr-0087-registration, check-changeset-no-major, plus pnpm --filter @objectstack/cli typecheck.

check:type-check-debt first exited 1 — a refusal, not a ratchet failure: one workspace dependency had no built dist, and the gate declines to "silently measure a DIFFERENT WORLD". That closure was built and the gate then returned the green above. check:i18n / check:i18n-coverage hit the same class of prerequisite earlier and were likewise cleared before being read.

Declared narrowing, one gate:check:dual-build-cjs-loads is NOT MEASURED — it needs a full-workspace pnpm build, and the missing dist directories it names are unrelated packages. This diff adds no package and no exports entry, so it cannot move that gate, and CI builds the whole workspace regardless.

Tests at head 35574c9d: serve-bound-port-publication.test.ts 21 passed · serve-bound-port-publish-order.test.ts 4 passed · serve-publishes-bound-port.e2e.test.ts 6 passed (the suite that was ejecting PRs) — 31 across the three. Regression sweep over the whole serve-* source-test family plus the port-contract and bind-probe pins, since many tests read serve.ts: 32 files, 375 tests, all passing. Recorded honestly: the cli typecheck program excludes test files (tsc --listFiles returns 0 hits for them), so that green says nothing about the test files; their type coverage rests on check:type-check-debt.

Out of scope, not touched: #12884 remains open and is a different defect in the same family.

…d port
os serve announced its address on the ready banner and the objectstack:listening
IPC message BEFORE it wrote runtime.<environment>.json, so every consumer that
reacts to an announcement raced a file that did not exist yet.
Publish through one ordered seam instead: publishBoundPort() drives the state
file first, then IPC, then the banner. The channels are injected so the ORDER
is observable, and a new deterministic test records the sequence and goes red
if it is ever reversed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
@github-actions

github-actionsBot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 9 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 96d8b20ee4f4894b4281a552dd5f7eee1c8bb0c5.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 23 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 96d8b20ee4f4894b4281a552dd5f7eee1c8bb0c5packageMentionDocs.

Which tree this was computed on

This run read content/docs from fa43e1cebe593501bd1fe13eeea9704d7e39b675 — the merge of head 35574c9dbb7aff8559f055961f96b44471be8b63 into base 96d8b20ee4f4894b4281a552dd5f7eee1c8bb0c5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin fa43e1cebe593501bd1fe13eeea9704d7e39b675 && git checkout fa43e1cebe593501bd1fe13eeea9704d7e39b675
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 96d8b20ee4f4894b4281a552dd5f7eee1c8bb0c5 35574c9dbb7aff8559f055961f96b44471be8b63 && git checkout -B drift-repro 96d8b20ee4f4894b4281a552dd5f7eee1c8bb0c5 && git merge --no-ff 35574c9dbb7aff8559f055961f96b44471be8b63
node scripts/docs-audit/affected-docs.mjs --json 96d8b20ee4f4894b4281a552dd5f7eee1c8bb0c5

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 96d8b20ee4f4894b4281a552dd5f7eee1c8bb0c5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

The three pins that read serve.ts source text for the IPC call, the state-file
literal and const runtimeUrl went red when #13193 folded those three publish
sites into publishBoundPort(). The behaviour they guarded is intact, so they are
rewritten UPWARD rather than relaxed: two channels are now driven through the
seam and observed, the state file is read off disk (pid included), and the IPC
leg is observed reaching process.send.
The wiring half stays a source pin because run() is still un-enterable in
process - but it is now one call site instead of three publish sites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k
@os-zhuang
os-zhuang marked this pull request as ready for review August 29, 2026 12:58
@os-zhuang
os-zhuang enabled auto-merge August 29, 2026 12:58
@os-zhuang
os-zhuang added this pull request to the merge queueAug 29, 2026
Merged via the queue into main with commit faff497Aug 29, 2026
37 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-13193-bound-port-state-file-race branch August 29, 2026 13:44
os-elon pushed a commit that referenced this pull request Aug 29, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-trump@os-zhuang