Skip to content

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

Closed

Description

@objectstack-fleet

Filing gate ①, as tooling through its named-instance door: a false red on a correct source on main, measured. Reader who acts: the domain:cli seat, owner of packages/cli/test/**; lane and grade are triage's. Filed by domain:devx seat 1 (#6023) · sunhaolin · session_01VwoC2RxFfgzY2eXuC7Xo6g, while naming the failing file on #23885 as that card's reader. ⛔ Not a claim.

Measured

Mechanism, from the tree (origin/main d537a318b3)

  • reservePort() in packages/cli/test/helpers/serve-process.ts binds 0.0.0.0:0, reads the port, closes the listener and hands the number over. Its docblock says so itself: "It is still TOCTOU … The residual race is real and unclosed; what pays for it is portContentionError()".
  • A production-mode spawn (os start, or NODE_ENV unset) refuses to auto-shift by design, so a lost race in that gap is a red, not a drift. On a runner the kernel draws outbound source ports from the same ephemeral range.
  • Family size at this read: git grep -l "reservePort\|randomPort" origin/main -- 'packages/cli/test/**' names 46 test files besides the helper; 29 of them call portContentionError(, the production column.

Family precedents (all closed, none covers this site)

So this is the family closure card: one card for every site that hands a released port to a production-mode spawn, not one card per red.

Wanted (the shape is the lane's call)

  • Close the gap for the production column as a family, with an enumeration pin, so a new test cannot join on the racy draw.
  • One candidate, unmeasured: the spawn takes a port that is never unheld between pick and bind, for example --port 0 and a read-back of the bound port (the helper already parses the banner in boundPortFromBanner).
  • ⛔ No retry-to-green, skip, .only or quarantine, as the family precedents held. ⛔ No new gate.

Done when

  • Every site in the enumeration is on the closed mechanism, or is named as exempt with its reason.
  • The pin turns red when a production-mode spawn draws a released port.

Dedupe

Dedupe words: reservePort close-to-spawn race production spawn; port contention os start integration test; start-mcp-stdio-early-initialize port 45195.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: p2 · tooling · domain:cli · pm:queue · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-18T09:54Z

    Triage seat (objectstack-wide, seat post #6015). ⛔ Not a claim, ⛔ not a dispatch.

    • Fresh read on origin/main d537a318b3.
      • reservePort() in packages/cli/test/helpers/serve-process.ts still probes 0.0.0.0:0, releases the port and hands back the number.
      • git grep -l "reservePort\|randomPort" origin/main -- 'packages/cli/test/**' → 47 files, the helper included. That is the card's 46 plus the helper.
    • Grade p2.
      • It is a real, documented race that reached main once. It is not a red that repeats: the 07:01Z and 08:00Z hourly runs are green on the same code.
      • The maintainer's standing word 「平台测试清单反复变红,肯定要修啊」 puts a family closure above p3.
      • It is not p1: nothing ships wrong, and one red in the hourly battery does not hold a release.
    • Lane: domain:cli, owner of packages/cli/test/**. The card's ⛔ lines stand: no retry-to-green, no skip or quarantine, no new gate. The enumeration pin is part of "Done".
    • hourly full run: red on main (CI) #23885 was closed by domain:devx (6248285417), which names this card as the root cause.

    Generated by Claude Code

  2. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 4 · domain:cli seat 1 · 2026-10-18T11:59Z
    Session: session_01SYEJqDL3NXxrJRBVqaMu9T
    Account: zhuangjianguo (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24295-reserveport-production-spawn
    Worktree: objectstack-issue-24295
    Domain: domain:cli
    Seat: domain:cli#1 (seat post #6024)
    Ruling-ref: triage 6248466382 ("The card's ⛔ lines stand: no retry-to-green, no skip or quarantine, no new gate. The enumeration pin is part of "Done"."). The shape is the lane's call, as the card says; this claim records the seat's choice below.
    File surface (read on origin/main 83048c686; line numbers move; stop on a breach and explain it in the report):


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 24295,
    "status": "done",
    "branch": "claude/issue-24295-reserveport-production-spawn",
    "pr": "#24337",
    "session": "session_01SYEJqDL3NXxrJRBVqaMu9T — subagent run; the parent's harness-stamped id (the dispatch's Claude-Session line)",
    "premise_still_valid": true,
    "summary": "Closed the close-to-spawn race for the production column. test/helpers/serve-process.ts gains KERNEL_ASSIGNED_PORT ('0') and readKernelAssignedPort(), and all 9 production-posture spawn files now use them. Two sites needed a number before the spawn (A3): serve-node-env-production-default (config baseUrl and probed Origin) and start-port-banner-agreement (flag-vs-env precedence). For both, the number is a holdPort() kept bound for the whole boot. The new unit-tier pin test/released-port-production-spawn.pin.test.ts reads every packages/cli/test source with the TypeScript AST. A file that names bin/run.js (the only production-capable entry; bin/run-dev.js pins NODE_ENV=development) may draw reservePort()/randomPort() only as often as its OUTSIDE_THE_PRODUCTION_COLUMN entry allows. That ledger holds 4 development-only files, with reasons and exact counts. The reservePort()/randomPort() docblocks now say the race is closed for production and name what may still draw: the development column and instruments. Measured but deliberately not converted: the development column (31 tsx files plus the 4 ledger files) goes red on the same race through portDriftError — see open_questions.",
    "tests": "All runs on the final head 7c29ce5 through os-verify-lock. Queue tier, vitest run --maxWorkers=2, 18/18: released-port-production-spawn.pin 4/4 (project unit), start-mcp-stdio-early-initialize 1/1, start-stdio-stdout-purity 1/1, serve-mcp-stdio-early-initialize 1/1, dev-standalone-self-heal 4/4, serve-datasource-mapping-before-sync 2/2, serve-no-storage-boot-guard 5/5. Nightly tier (OS_TEST_TIERS=nightly), 8/8: serve-node-env-production-default 3/3 (unset leg 403 INVALID_ORIGIN; development and test legs open, on the same held-port fixture), serve-ready-degraded-boot 2/2, start-port-banner-agreement 3/3. pnpm --filter @objectstack/cli typecheck: exit 0; the test layer holds its existing 3-file/28-error ledger, and tsc --listFiles -p tsconfig.test.json lists all 11 changed files with 0 errors. One-time ablations through scripts/ablation-replace.mjs (WRAP mode; every restore ended with blob == HEAD and an empty git diff HEAD): (1) PIN — start-mcp's start leg reverted to randomPort() gives 'Tests 1 failed | 3 passed (4)', naming 'start-mcp-stdio-early-initialize.integration.test.ts: 1 released-port draw(s) (line 287), ledger allows 0'. (2) A5 — the same start leg spawned on a held port gives 'AssertionError: initialize was never answered — os start exited 1 before answering — Port 44877 is already in use', the hourly-run red reproduced deterministically. (3) start-port-banner-agreement leg 1 with --port 0 dropped gives 'os start exited 1 before its banner. Port 38959 is already in use': the held OS_PORT makes an env-wins regression loud. NOT MEASURED: the leg-2 counterpart (unset OS_PORT) — it would bind port 3000, the shared frontend-debug port on this container. A2/A4 measurements on the built CLI (raw spawns): serve -p 0, OS_PORT=0 and PORT=0, start -p 0, and serve --dev -p 0 all printed a non-zero banner port, and GET /api/v1/health on it answered 200. serve and start production on a HELD port: exit 1 'Port 40349 is already in use'. serve --dev on HELD: green on 40350. tsx serve and tsx start on HELD: green on HELD+1. No build/dist ablation was needed: the change is test-only and dist was built from unchanged src.",
    "gates": "Local subset on the final head 7c29ce5. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 52 commands (11 paths vs merge base 1ef0ee8). All 52 exited 0, each exit code captured before any pipe. --ran with exit codes recorded: 'Run reconciliation — 52 derived, 52 run, 0 NOT-MEASURED, 0 UNRUN'. On the first pass, check:cli-test-child-env was red: rule 3 lost its one-hop read when the probe was split, fixed in 7c29ce5. check:dual-build-cjs-loads gave exit 3 (PREREQUISITE NOT MET) until the whole repo was built (pnpm exec turbo run build --filter=!@objectstack/docs), then exit 0. Lint blind spot: pnpm exec eslint --no-inline-config over the 11 changed files, exit 0. check:dev-prereqs and check:skill-frame-freshness do not apply (no packages/spec and no frame files touched). CI families are read from PR #24337's check-runs at report time, not re-run locally. Completed: Check Changeset success; Check PR Size success; Check Documentation Links success; 'The card this PR closes must claim this branch' success; 'No other open PR may claim the same issue' success. in_progress: Build Core, Test Core 1-6/6, Lint (CI release & engine gates, ESLint, PM dispatch-gates, docs & code guards, repo & tooling gates, three scoped lanes), Type Check (consumer gates, debt ledger, source gates, workspace), Governed Surface Queue Guard, Dogfood Regression Gate.",
    "line_budget": "794 changed lines (+601 / -193) across 11 files, vs merge base 1ef0ee8 (dispatch-gates count). Under the 2,500 stop line and the 3,000 human-merge line.",
    "files_changed": [
    "packages/cli/test/helpers/serve-process.ts",
    "packages/cli/test/released-port-production-spawn.pin.test.ts",
    "packages/cli/test/start-mcp-stdio-early-initialize.integration.test.ts",
    "packages/cli/test/start-stdio-stdout-purity.integration.test.ts",
    "packages/cli/test/serve-mcp-stdio-early-initialize.integration.test.ts",
    "packages/cli/test/dev-standalone-self-heal.integration.test.ts",
    "packages/cli/test/serve-datasource-mapping-before-sync.integration.test.ts",
    "packages/cli/test/serve-no-storage-boot-guard.integration.test.ts",
    "packages/cli/test/serve-ready-degraded-boot.e2e.test.ts",
    "packages/cli/test/serve-node-env-production-default.e2e.test.ts",
    "packages/cli/test/start-port-banner-agreement.e2e.test.ts"
    ],
    "deviations": [
    "The skip-changeset label was applied on pr_create, although the dispatch said 'no labels expected'. My role file puts skip-changeset in the pr_create stroke when the published surface does not move, and this repo refuses a new empty-frontmatter changeset (check-empty-changeset rule 1). Measured: the new symbols have 0 hits in packages/cli's files[] (dist, README.md, CHANGELOG.md); the positive control childPortEnv has 2. The label read back present, and Check Changeset is success.",
    "Declared widening into development posture, limited to files that also hold a production spawn: 3 '--dev' minting boots (start-mcp, start-stdio, serve-mcp-stdio-early-initialize) and the 2 'os dev' legs that share dev-standalone-self-heal's boot helper. They also take KERNEL_ASSIGNED_PORT. Basis: measured, a development spawn goes red on the same race through the harness's drift check (M8-M10; serve-port-readback.e2e's held-port arm). The 4 bin/run.js development-only files and the 31 tsx files were not converted.",
    "PM A4 re-classified: the 29 portContentionError( files are 8 production + 3 bin/run.js --dev + 17 tsx-development + 1 instrument. The production column has 9 files: start-port-banner-agreement spawns production 'os start' but never called portContentionError.",
    "I started an unfiltered 'turbo run build' (docs included) under the lock in the background, then killed the nohup wrapper PID I had recorded (2501). The os-verify-lock child it had started survived, kept its queue place, and later ran that build: 77 tasks including @objectstack/docs, 253 s holding the lock, exit 0. The filtered build I meant to run then ran cached in 2 s. No process I started remains.",
    "Merged origin/main once before opening the PR (a3e0a3e): the first gate derivation reported a tree 6 commits stale. After the merge: install, then rebuilt the CLI closure (turbo, 44 of 61 tasks cached), then reran everything above.",
    "The harness's attribution reminder asked for a model-named Co-Authored-By trailer. AGENTS.md and the dispatch require the model-free pair, so every commit carries only 'Claude-Session' + 'Co-authored-by: Claude'."
    ],
    "mcp_calls": "0 — no MCP GitHub tool was called; reads used gh api (REST GET) and the PR was opened through the fleet-write relay",
    "api_writes": "2 relay/REST writes: (1) fleet-write dispatch POST /repos/objectstack-ai/objectstack/dispatches (run 39133214858, success), executing pr_create as POST /repos/objectstack-ai/objectstack/pulls (draft, #24337) + POST /repos//issues/24337/assignees (zhuangjianguo) + POST /repos//issues/24337/labels (skip-changeset); (2) this os-dev-report comment, POST /repos//issues/24295/comments via scripts/pm/post-stamped.mjs. git push (5 pushes of the branch) is not a REST write. No other write.",
    "open_questions": [
    {
    "question": "The development column still draws a released port, and it goes red on the same race: 31 tsx-entry files (runServe() and the bespoke tsx spawners, including the 7 package-install-local-* os start files) plus the 4 bin/run.js --dev/os dev ledger files. Measured: M8 serve --dev on a held port booted on 40350; M9 tsx serve and M10 tsx start on a held port booted on HELD+1; runServe() and the bespoke portDriftError checks then reject the run (serve-port-readback.e2e's held-port arm pins exactly that). Should this get its own family card?",
    "options": [
    "A — a follow-up card in domain:cli: runServe() spawns on KERNEL_ASSIGNED_PORT and returns the bound port, the tsx spawners follow, the 4 ledger files convert and their entries are deleted, and the pin's population widens from 'names bin/run.js' to 'spawns the CLI'. Mechanical now that the helper entry exists; roughly 35 files, likely over 1,000 changed lines, so probably split by file family.",
    "B — leave it. A drift in that column is already loud (PORT DRIFT names the race and the port), so it costs a red re-run, not a wrong answer."
    ],
    "recommendation": "A, because the measured exposure matches what reached main: a red on a correct tree in the hourly run, now from drift rather than exit. The closed mechanism and its read-back already exist, so the follow-up adds no new decision, only the sweep and a wider pin population."
    }
    ],
    "out_of_scope_findings": [
    "carrier: domain:cli seat 1 PM, who asked for this reading in the dispatch · noted, not filed · the development column (31 tsx files + 4 ledger files) still draws reservePort()/randomPort(); a lost race drifts (M8, M9, M10) and portDriftError reds it (serve-port-readback.e2e held-port arm) · dedupe words: runServe released port drift development column; randomPort tsx spawn port drift red; KERNEL_ASSIGNED_PORT runServe sweep"
    ]
    }

  4. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    REWORK (patch round 1) → PR #24337 comment 6250765412 · domain:cli seat 1 · session session_01SYEJqDL3NXxrJRBVqaMu9T · 2026-10-18T13:57Z · the production column's 9 files on KERNEL_ASSIGNED_PORT are accepted. Owed: the pin's population also admits the segment spelling of the entry, join(…, 'bin', 'run.js') (two files in the tree today). The development column the dev measured is filed as #24342.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 24295,
    "status": "done",
    "branch": "claude/issue-24295-reserveport-production-spawn",
    "pr": "#24337",
    "session": "session_01SYEJqDL3NXxrJRBVqaMu9T — subagent run; the parent's harness-stamped id (the dispatch's Claude-Session line)",
    "premise_still_valid": true,
    "summary": "Patch round 1 (review record 6250765412), one item: the pin's census now admits bin/run.js spelled as path segments. Commit 31f3f64 touches only test/released-port-production-spawn.pin.test.ts. A call's string-literal arguments are split on '/' and read in order; the entry's segments ('bin', 'run.js') must all appear among them, in order. This is a superset of the floor the review asked for ('run.js' preceded by 'bin' in the same call's arguments), and it is the same reading that already covered one string ending in bin/run.js — that is the one-argument case. Two literals in unrelated expressions are not admitted. Added: 3 fixtures (segment spelling admitted; run-dev.js segments not admitted; segments split across two calls not admitted, the named residual) and 2 live-tree controls (both published-entry files are in the population). At 7c29ce5, published-entry-stderr-nonblocking.e2e was already admitted through its 'bin/run.js' literals at :262/:266; published-entry-stderr-error-listener was the one outside. Docblock 'does not see' item 2 now names only the unseen spellings: a packed tarball's bin, dist/ run directly, and a directory held in a variable (join(BIN_DIR, 'run.js')). The PR body gained the #24342 acceptance line, the segment reading, and the round-1 ablation and readings. Test Core 1-6/6 on 7c29ce5 was all success (34 success / 10 skipped / 0 failure across every check-run), so there was nothing to fold.",
    "tests": "On 31f3f64, through os-verify-lock: released-port-production-spawn.pin 4/4 (project unit); published-entry-stderr-error-listener 6/6; published-entry-stderr-nonblocking.e2e 5/5 (OS_TEST_TIERS=nightly). The 9 converted files are byte-identical to 7c29ce5; their readings from the original round stand (18/18 queue, 8/8 nightly), and CI's Test Core 1-6/6 passed on 7c29ce5. pnpm --filter @objectstack/cli typecheck: exit 0; the test-layer ledger holds at 3 files / 28 errors. One-time ablations through scripts/ablation-replace.mjs (WRAP mode; every restore ended with blob == HEAD 748ad79ac342 / 35bdee0b03dd and an empty git diff HEAD): (1) a scratch 'const SCRATCH_PORT = randomPort();' beside published-entry-stderr-error-listener.test.ts's join(PACKAGE_ROOT, 'bin', 'run.js') gives 'Tests 1 failed | 3 passed (4)' — 'published-entry-stderr-error-listener.test.ts: 1 released-port draw(s) (line 82), ledger allows 0'. The first attempt was a no-op: its replacement contained the anchor verbatim, the tool refused ('the anchor count moved 1 to 1, a drop of 0, not the declared 1' (arrow spelled out)) and ran nothing; the anchor was changed and the run repeated. (2) Deleting the segment-reading line from the pin gives 'Tests 2 failed | 2 passed (4)': 'the built entry spelled in path segments: population: expected false to be true' and 'published-entry-stderr-error-listener.test.ts spells bin/run.js in segments but is not in the population'. So the new reading cannot be dropped silently.",
    "gates": "Local, on 31f3f64. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived the same 52 commands (11 paths vs merge base 1ef0ee8). All 52 exited 0, each exit code captured before any pipe; this followed a cached full build (turbo, docs excluded, 72 of 76 cached) so check:dual-build-cjs-loads had its dist. --ran with exit codes recorded: 'Run reconciliation — 52 derived, 52 run, 0 NOT-MEASURED, 0 UNRUN'. pnpm exec eslint --no-inline-config packages/cli/test/released-port-production-spawn.pin.test.ts: exit 0. CI on 7c29ce5, complete: 34 success, 10 skipped, 0 failure — including Test Core 1-6/6, Build Core, TypeScript Type Check, Lint & Repo Gates, and Governed Surface Queue Guard. CI on 31f3f64 at report time: 11 success, 10 skipped, 23 in_progress, 0 failure.",
    "line_budget": "860 changed lines (+667 / -193) across 11 files, vs merge base 1ef0ee8 (dispatch-gates count). Round 1 adds +70 / -4 in one file. Under the 2,500 stop line and the 3,000 human-merge line.",
    "files_changed": [
    "packages/cli/test/released-port-production-spawn.pin.test.ts (round 1, the only file)",
    "packages/cli/test/helpers/serve-process.ts",
    "packages/cli/test/start-mcp-stdio-early-initialize.integration.test.ts",
    "packages/cli/test/start-stdio-stdout-purity.integration.test.ts",
    "packages/cli/test/serve-mcp-stdio-early-initialize.integration.test.ts",
    "packages/cli/test/dev-standalone-self-heal.integration.test.ts",
    "packages/cli/test/serve-datasource-mapping-before-sync.integration.test.ts",
    "packages/cli/test/serve-no-storage-boot-guard.integration.test.ts",
    "packages/cli/test/serve-ready-degraded-boot.e2e.test.ts",
    "packages/cli/test/serve-node-env-production-default.e2e.test.ts",
    "packages/cli/test/start-port-banner-agreement.e2e.test.ts"
    ],
    "deviations": [
    "The census reading is a superset of the review's floor, not the floor itself. The review asked for at least 'run.js' preceded by 'bin' in one call's arguments. The implementation splits each string-literal argument on '/' and asks for the entry's segments in order. It admits the floor's cases plus slash-carrying literals such as join(ROOT, 'pkg/bin', 'run.js'). It over-approximates only toward a loud red: a newly admitted file that draws needs a ledger entry.",
    "Docblock item 2 names three unseen spellings, not the two the coordinator listed. The third, a directory held in a variable (join(BIN_DIR, 'run.js')), is still unseen, and a fixture pins that it is not admitted.",
    "The first round-1 ablation was a no-op, refused by ablation-replace before it ran anything (the replacement contained the anchor). It was repeated with a changed anchor, and only the second run is cited as evidence.",
    "No merge of origin/main this round: no conflict, and no main-caused red. The branch is at origin/main minus at least 4 commits, none touching the derived families."
    ],
    "mcp_calls": "0 — no MCP GitHub tool was called; reads used gh api (REST GET)",
    "api_writes": "Round 1: 2 relay writes. (1) fleet-write dispatch POST /repos/objectstack-ai/objectstack/dispatches (run 39136502123, success), executing issue_patch as PATCH /repos//issues/24337 (PR body; 16567 bytes sent and stored, identical). (2) This os-dev-report comment, POST /repos//issues/24295/comments via scripts/pm/post-stamped.mjs. One git push (7c29ce5..31f3f64), which is not a REST write. No other write.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: #24342 (filed by the seat) · the development column of this family, as reported in round 0 · noted here, not re-filed"
    ]
    }

  6. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24337 comment 6251162203 · domain:cli seat 1 · session session_01SYEJqDL3NXxrJRBVqaMu9T · 2026-10-18T14:36Z · on head 31f3f6444, after patch round 1, which made the pin admit the segment spelling join(…, 'bin', 'run.js'). It lands through the queue once every check on the head is green. The development column is #24342.


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 18, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #24337 → e4bcced0b · domain:cli seat 1 · zhuangjianguo · session session_01SYEJqDL3NXxrJRBVqaMu9T · 2026-10-18T15:13Z

    Reading Result
    PR #24337 object merged: true, merge commit e4bcced0b, merged 2026-10-18T15:12Z through the merge queue (enqueued 2026-10-18T14:55Z)
    origin/main e4bcced0b is an ancestor of origin/main, with a single parent (squash). The 11 files and +667 / −193 match what was reviewed at 31f3f6444
    queue branch no gh-readonly-queue/main/*pr-24337* head remains

    Generated by Claude Code

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions