Skip to content

fix(devx): reserve the port smoke_pick_free_port hands out, instead of probing and letting go - #10263

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-10212-smoke-pick-free-port-toctou
Aug 20, 2026
Merged

fix(devx): reserve the port smoke_pick_free_port hands out, instead of probing and letting go#10263
os-zhuang merged 2 commits into
mainfrom
claude/issue-10212-smoke-pick-free-port-toctou

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#10212

What was wrong, re-derived rather than taken from the card

smoke_pick_free_port bound a probe socket, closed it, and only then reported the
port free; objectstack dev bound it afterwards. The scan walks base upward
deterministically, so concurrent callers did not diverge — they were handed the same
port, and the first one every time. Check-then-use, with the "use" in another process.

Both of the PM's mechanism assumptions were measured and both hold: the probe closes
before reporting, and the collision test draws from base 3210 exactly four times
(FIRST, SECOND, and the two REQUESTEDs).

Measured on this tree before the change, eight concurrent callers from base 3210:

== phase 1: 8 concurrent callers scanning from base 3210 ==
5 3210
3 3211
DISTINCT_PORTS=2 of 8
== phase 2: 8 concurrent callers pick-then-BIND ==
BIND_OK_COUNT=2
BIND_ERR_COUNT=6 # Error: listen EADDRINUSE 0.0.0.0:3210

After the change, the same construction:

 DISTINCT_PORTS=8 of 8 # 3210 3211 3212 3213 3214 3215 3216 3217
BIND_OK_COUNT=8
BIND_ERR_COUNT=0

The distinct-count moved between rounds on the unfixed script (1 of 8, then 2 of 8)
while BIND_ERR_COUNT stayed at 6 — the collision is by construction, its exact shape
varies with load. That is the property that let it survive: it is never absent, only
sometimes cheaper.

⚠️A correction to my own first measurement, because it nearly went into this PR.
The first repro harness computed DISTINCT_PORTS by cat-ing the eight result files
together. The picker prints without a trailing newline, so all eight numbers landed
on one line and the count read 1 of 8 — for the fixed script too. The instrument, not
the picker, was reporting. It is fixed above and in the test (each file read separately),
and the same trap is commented at the assertion.

The fix

Ported from #10217 (#10167), which landed on main at 318f96ae while this card was in
flight. I read the merged source, not the card's summary — and to show the port is
faithful rather than approximate, here is a diff of the two node programs with the
sdui/smoke naming normalised away:

3c3 < node - "$base" "$span" "$$" << 'PICK_PICK_FREE_PORT' # heredoc spacing
> node - "$base" "$span" "$$" <<'PICK_PICK_FREE_PORT'
13c13 < path.join(..., 'PICK-port-reservations'); # registry dir
> path.join(..., 'objectstack-port-reservations');
72c72 < probe.listen(port, '127.0.0.1'); # probe address
> probe.listen(port);

Everything else — O_EXCL claim, release-on-probe-failure, the sweep with its floor and
ceiling, pid liveness, flock around the sweep only — is byte-identical. The three
differences are deliberate:

  1. Heredoc spacing matches each file's own existing style.
  2. The probe stays on the wildcard address. This is load-bearing and predates the
    card: it is the spelling serve.ts's own isPortAvailable() uses, so this script
    sees a busy port exactly when the CLI would. Copying the sdui helper's '127.0.0.1'
    would have been the silent-divergence failure, not the faithful port.
  3. The registry directory is neutral, not sdui-prefixed. Explained below.

So a port is claimed before it is probed, in a registry every caller on the host
shares (${TMPDIR:-/tmp}/objectstack-port-reservations, overridable via
SMOKE_PORT_RESERVATION_DIR), and the claim outlives the function — released when
the claiming process dies, not when the function returns. Verified that the claim file
records the caller's pid and not the node child's: CLAIM_OWNER=16154 /
MY_PID=16154.

The one design decision the card asked to be made deliberately: one registry or two

The card asked whether the two helpers should share a registry. They now run the same
protocol against two directories, and this PR chose not to converge them here:

  • The ranges are disjoint[3210, 3410) vs [5180, 5380) — so they cannot hand
    each other the same port. Sharing buys nothing measurable today.
  • The probe-address difference above means a genuinely shared helper needs the address as
    a parameter, not a constant. That is a refactor, not a rename.
  • Converging meant editing scripts/gen-sdui-manifest.sh and its collision test — both
    outside this card's declared file surface, hours after fix(devx): reserve the port sdui_pick_free_port hands out, instead of probing and letting go #10217 landed there.

This is not a second incompatible registry: the on-disk format is identical (filename
is the port, contents are the owner pid), and the directory name was chosen neutral
precisely so convergence is a pure rename on the sdui side with no migration. Recorded as
#10261 with the design sketch. #10261 is not addressed here.

Tests

publish-smoke-port-collision.test.ts gains the race as executed assertions:

  • CONCURRENT_TOTAL / CONCURRENT_DISTINCT — eight subshells, one base, at once.
    CONCURRENT_TOTAL is the vacuity guard: without it a picker that failed outright
    would score a perfect distinct-count of zero.
  • TRIPLE_DISTINCT — three picks in one run, nothing bound in between, must differ.
  • STEAL_HELD / STEAL_PICK / STEAL_CLAIM_RELEASED, with STEAL_CLAIM_ON_PICK as
    its positive control
    .

⭐ The blind-spot check the card asked for — and it is a different blind spot

The card asked whether this file's collision test carries the hole that made #10167's
PICKED_WITH_BUSY stay green on the broken picker. It does not, and that was
measured rather than assumed: picks a per-run port and skips one that is already held
already curls its occupier (HOLDER_REACHABLE), which is exactly the BUSY_HELD
precondition guard #10217 had to add. Nothing needed adding there.

But it is still blind to this card's defect, for an unrelated reason: it draws its two
ports sequentially, and a TOCTOU race is about callers that overlap in time.
It stayed
green on the broken picker in both ablation rounds — correctly, because a second pick
taken after the first port is already held really does skip it. Sound, and silent on the
defect. That is why the new assertions use concurrency instead of strengthening it. Both
findings are written into the test file's header so the next reader does not have to
re-derive them.

Ablation (fix reverted on disk, tests kept), re-run at the merged head

No rebuild leg is involved, and that is a property of the tests rather than an
assumption: const SCRIPT = path.resolve(..., 'scripts', 'publish-smoke.sh') — the
harness reads the shell script from the worktree at run time, so there is no dist/ that
can go stale. Mutation confirmed on disk before reading any result, anchored on the text
each side owns: smoke_scan_and_reserve_port=0, It reserves nothing=1. Restoration
confirmed the same way (=3 / =0, clean git status against HEAD).

× hands concurrent callers scanning one base distinct ports expected '2' to be '8'
× gives each pick within one run its own port expected '1' to be '3'
× skips a port held from outside the registry ... expected 'no' to be 'yes'
Test Files 1 failed (1)
Tests 3 failed | 5 passed (8)

STEAL_CLAIM_RELEASED is vacuously green on the unfixed script — measured, not
argued.
The assertion at line 331 that fails is STEAL_CLAIM_ON_PICK, the positive
control; STEAL_CLAIM_RELEASED on line 332 never even runs. Driving the same harness
body directly against the unfixed script prints why:

STEAL_HELD=THIEF STEAL_BASE=3210 STEAL_PICK=3211
STEAL_CLAIM_RELEASED=yes ← green, and meaningless
STEAL_CLAIM_ON_PICK=no ← the control, red
REGISTRY_DIR_EXISTS=no ← the reason: no registry ⇒ no claim file either way

Note also that STEAL_PICK != STEAL_BASE passes on the unfixed script. It is a
regression guard, not a defect assertion, and is labelled as such.

Green, at 0983862a

All four port-drawing test files together at --maxWorkers=4, so the contention between
the two registries is real and not staged:

 Test Files 4 passed (4)
Tests 22 passed (22)
os-verify-lock: VERDICT command-exit 0 · held the lock 9s · waited 0s

The collision test alone was also run three times back to back — Tests 8 passed (8)
each time — because a concurrency assertion that passes once proves less than most.

One thing worth flagging that was NOT mine. Before merging main, running those four
files together reproduced #10167 live on this branch: gen-sdui-manifest-collision.test.ts
died with Error: Command failed: bash /tmp/sdui-collision-*/harness.sh /
Error: listen EADDRINUSE: address already in use 127.0.0.1:5180 under runHarness
the merge-queue signature verbatim — and gen-sdui-manifest-write-target.test.ts lost
5180 beside it. Confirmed pre-existing by running the three sdui files without this
PR's test file in the run (same failures), and gone after merging #10217.

Gates at 0983862a

Union derived by node scripts/pm/dispatch-gates.mjs with no path args, re-derived after
the merge (2 paths, merge base 318f96ae). Exit codes captured before any pipe; verdicts
quoted from what each gate printed, not from $?. All green:

check:cross-package-test-inputs · check-cross-package-test-inputs.mjs ·
spec check:empty-state · spec check:liveness · check:merge-driver ·
check:slot-lookup · spec check:strictness-ledger · check:type-source-resolution ·
spec check:variant-docs · docs-audit/check-affected-docs.mjs ·
check:query-options-erasure · check:type-check-coverage ·
check:engine-double-contract · check:where-matcher · check:nul-bytes

check-nul-bytes: OK (scanned 6088 text file(s) ... no raw ASCII control bytes).
check-engine-double-contract: OK — 331 pinned, 133 in the DEBT ledger, 2 exempt.
check-type-check-coverage: OK — 64/77 workspace packages type-checked (plus the root) ...
✓ where-matcher conformance holds: 264 matcher(s) discovered ...

Declared narrowing — two gates NOT MEASURED, and that is not a green.
check-dev-prereqs.mjs and the check:type-check-debt --re-measure ratchet both refuse
on an unbuilt worktree; check-dev-prereqs reports 67 of 67 workspace packages missing
their dist/ entry point, a property of the worktree, not of a diff whose two files are a
root shell script and one test file importing nothing from the workspace. Building the
full closure would have held the container's shared verify lock against the other agents
in this round — the same shared-resource harm this card is about. The risk the ratchet
covers for this diff is that the new TS does not typecheck or grows the debt, and that was
measured directly instead:

check:test-typecheck: OK — @objectstack/spec's test layer compiles under
packages/spec/tsconfig.test.json; 55 file(s) / 263 error(s) held in
test-typecheck-debt.json (shrink-only)

263 is unchanged from what #10217 measured on the same ledger, so the new test code added
none. CI runs the whole farm regardless; this is the cheap half, not a substitute for it.

Release fence

Untouched, and deliberately so. This PR changes only how a port is picked; it changes
nothing about what the smoke publishes. No changeset publish, no pnpm run release,
no tag push, no publish-capable workflow_dispatch. The diff is one shell function plus
its test.

No changeset

Root scripts/ plus one packages/spec/scripts/*.test.ts. Nothing published changes
behaviour, so this carries skip-changeset.


Generated by Claude Code

…f probing and letting go
`smoke_pick_free_port` bound a probe socket, closed it, and only then reported
the port free; `objectstack dev` bound it afterwards. The scan walks `base`
upward deterministically, so concurrent callers were handed the same port — the
first one, every time. Ports are now CLAIMED before they are probed, in a
host-shared registry, and the claim outlives the function.
Ports the same design PR #10217 landed for `sdui_pick_free_port` (#10167),
keeping this script's wildcard probe, which matches serve.ts isPortAvailable().
Part of #10212
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
@os-zhuangos-zhuang added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 20, 2026 — with Claude
@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.

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

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gateteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

smoke_pick_free_port in scripts/publish-smoke.sh is the same TOCTOU probe as #10167 — concurrent callers scanning from 3210 are all handed 3210

2 participants

@os-zhuang@claude