Repository navigation
Description
Filed unassigned by the dev seat working the publish-smoke.sh port card (#9647), from the sweep that card asked for. Not fixed there — different file, different blast radius, and a workflow change wants its own review.
The shape
.github/workflows/scaffold-e2e.yml has three boot-and-probe blocks on fixed ports:
| step | port | shape |
|---|---|---|
Boot from the artifact and probe health (~line 154) |
npx os start --port 8080 |
wait loop on http://localhost:8080/api/v1/health, then asserts /api/v1/ready |
docker run -d --name e2e -p 18080:8080 (~line 371) |
host 18080, container name e2e |
wait loop on http://localhost:18080/api/v1/health |
Boot and probe health (blank only) in registry-canary (~line 424) |
npx os start --port 8080 |
same as the first |
Each wait loop asks one question — does the port answer 200? — and nothing more. SERVER_PID is captured but never checked for liveness inside the loop; it is only used for the closing kill.
Why that is the same defect as #9647, and why it is also NOT the same
Different from #9647: these use os start, not os dev. packages/cli/src/commands/serve.ts gates the port auto-shift on flags.dev, so the non-dev branch takes the loud refusal instead — run B's own server prints ✗ Port 8080 is already in use. and exits 1 into its own server.log.
Same as #9647: that refusal does not help, and this is exactly the half that surprised on #9578. Run B's server is gone, but run A keeps answering on 8080, so run B's loop still gets its 200, still sets ok=1, and still asserts /api/v1/ready against run A's app. Then kill "$SERVER_PID" kills a pid that already exited. The run reports green about an app it never booted.
The docker leg has a second shared name on top of the port: --name e2e is fixed too, so a second concurrent run's docker run fails on the name — loudly, which is better, but the host port 18080 is still shared with whatever the first container published.
Severity — deliberately not graded here
Unmeasured, and the mitigating fact is real: GitHub-hosted runners give every job its own VM, so two concurrent CI runs of this workflow never share a network namespace. The exposure is self-hosted runners and agent dispatch containers, where several jobs or several agents do share one namespace. Whether this repo runs scaffold-e2e anywhere like that is the question that grades it, and I did not establish it.
What is verified: the three literals above, the absence of any liveness check inside the three wait loops, and the non-dev refusal branch in serve.ts (read, and consistent with the measured dev-path behaviour recorded on #9647).
Sweep context
scripts/ was re-swept on current main while working #9647 and is clean: publish-smoke.sh was the only remaining fixed port, and every /tmp use under scripts/ is mktemp-qualified (gen-sdui-manifest.sh, publish-smoke.sh, downstream-smoke.sh, release-spec-changes.sh, scripts/pm/release-rehearsal-clone.mjs); the two other /tmp hits are documentation examples. .github/workflows/** had never been swept — this is what that extension found. The database service ports in ci.yml (5432, 3306) are GitHub service containers, which the runner allocates per job; they are not this shape.
Activity
os-steve commentedon Aug 18, 2026
Claiming this card.
session_01XqDQYVU5smx29ts9pAErjaclaude/issue-9779-scaffold-e2e-portsPlan: measure H1 first (what
os start --port Nactually does when N is held) before choosing between a liveness-only fix and an #9647-shaped bound-port read. Files expected in scope:.github/workflows/scaffold-e2e.yml(plus a test pinning whatever contract lands).packages/cli/src/commands/serve.tsread-only.Generated by Claude Code
fix(ci): make scaffold-e2e's three boot-and-probe blocks assert on th…
os-steve commentedon Aug 19, 2026
{ "issue": 9779, "status": "done", "branch": "claude/issue-9779-scaffold-e2e-ports", "pr": "https://github.com/objectstack-ai/objectstack/pull/9808", "premise_still_valid": true, "summary": "H1 measured: `os start --port N` on a held port takes the LOUD non-dev refusal branch and exits 1 (`Port N is already in use`), never auto-shifting; control on a free port boots healthy. So this is a THIRD failure mode, not either sibling's: no shifted port to read back (#9647) and no `--strictPort` to ask for (#9578), because the CLI already behaves as if it had one. But the liveness check the card asked for is measurably NOT sufficient on its own: replaying the pre-fix block with a neighbour up, the loop took its 200 on iteration 1 after 0s, asserted /api/v1/ready against the NEIGHBOUR, and `kill $SERVER_PID` succeeded because our own process was still booting and alive. So each block got TWO guards: a pre-flight refusing to boot when the exact URL the loop accepts already answers, plus `kill -0 $SERVER_PID` per iteration. Ports stay fixed (H3: all 26 workflows are `ubuntu-latest`, one VM per job); the docker leg keeps its fixed name and host port because both collide loudly at `docker run` and it has no wrong-answer mode, and gains only the container-liveness check it was missing.", "tests": "All re-run at the FINAL commit e1a2514e2 (main moved mid-verification and conflicted in scripts/check-cross-package-test-inputs.mjs; merged, not rebased, then everything re-run including the full rebuild the ratchet needs). NEW TEST packages/create-objectstack/src/scaffold-e2e-boot-probe.test.ts extracts the three real `run:` scripts out of the workflow and EXECUTES them under `bash -e` with npx/docker stubs encoding the measured CLI behaviour: 8 passed (8). REVERSE VERIFICATION from the committed fix, workflow alone restored to origin/main: `Tests 5 failed | 3 passed (8)` — direction red, as predicted; the numbers ARE the card: `refuses a neighbour ... 316ms / AssertionError: expected +0 not to be +0` (the pre-fix block exits 0 against the neighbour) and `fails fast ... 60280ms / expected '::error::server never became healthy' to contain 'exited before becoming healthy'` (the full 60s ceiling). The 3 that stayed green are the vacuity guards (each block still accepts the server it booted itself and prints \"iam\":\"OURS\" from /api/v1/ready). H1 raw: `os start --port 38200` (neighbour held) -> `✗ Port 38200 is already in use.` exit 1; `os start --port 38500` (free) -> `✓ Server is ready` + real health body. H2 raw: LOOP_OK=1 LOOP_ITERATIONS=1 LOOP_SECONDS=0 READY_ANSWERED_BY=NEIGHBOUR-RUN-A OUR_PID_ALIVE=yes KILL_RC=0. PACKAGE: create-objectstack test 7 files/81 tests passed; typecheck (tsc --noEmit) clean. GATES (derived at runtime with `node scripts/pm/dispatch-gates.mjs` over the four changed paths, not recalled; the dispatch prompt named none): check:cross-package-test-inputs OK (12 packages, all declared, turbo.json hashes every glob); check:nul-bytes, check:node-version, check:required-contexts, check:shard-attestation, check:workflow-status-functions, check:query-options-erasure, check:type-check-coverage, check:engine-double-contract, check:where-matcher all rc=0; check:affected-docs 242 self-test cases pass; check:type-check-debt OK — 33 ledger entries re-measured in 218.7s, 1926 raw tsc errors, none above its recorded number, on a fully built workspace (70/70 turbo tasks). No ablation of built artifacts was involved (nothing here runs against dist/), so no rebuild claim applies to one. NOT MEASURED, stated as such in the PR and the workflow comment: docker's refusal of a duplicate --name and a taken -p host port — this container ships the docker CLI with no daemon (`dial unix /var/run/docker.sock: no such file or directory`), and asserting it against our own stub would only assert the stub. `skip-changeset` label POSTed and READ BACK: ['ci/cd', 'size/l', 'tests', 'skip-changeset'].", "open_questions": [], "out_of_scope_findings": [] }H3 and H4, as the dispatch asked
H3 — the fact the card did not check. Every
runs-on:in the repo, all 26 workflows, isubuntu-latest. No self-hosted labels, no job-levelcontainer:, and the single reusable-workflow call (release.ymltodocker-publish.yml) lands onubuntu-latesttoo. One fresh VM per job ⇒ two jobs of this workflow, or this workflow and any other, can never share 8080 or 18080 on CI. That is what sized the fix: guards, not per-run ports. The exposure that remains is the shared-namespace replay — a self-hosted runner, or a dev running the block by hand in an agent container.H4 —
.github/workflows/**swept whole.runs-onscaffold-e2e.yml--port 8080x2,-p 18080:8080ubuntu-latestci.yml5432:5432,3306:3306, and the two127.0.0.1URLs naming themubuntu-latestUnqualified
/tmppaths used as per-run state: zero, across all 26 workflows — every one uses$RUNNER_TEMPor$GITHUB_WORKSPACE. No other workflow boots a server and probes it; the only other boot-and-probe shapes in the repo are inscripts/, both closed by #9670 and #9782. Nothing new to file — the sweep the card asked for is finished and clean.One residual, deliberately left open and documented in the file
A neighbour arriving after the pre-flight and before our own bind is still not caught. Closing it needs an affirmative "our server bound" signal — the runtime state file
serve.tspublishes underOS_HOME, i.e. #9647's mechanism used for identity rather than for the port. Atubuntu-latest-only it would be machinery pinning a race that cannot occur; the workflow comment names it as the thing to reach for if scaffold-e2e ever moves onto a shared runner.Generated by Claude Code
fix(ci): make scaffold-e2e's three boot-and-probe blocks assert on th…
fix(create-objectstack): the boot-probe neighbour reports WHY it did …
docs(create-objectstack): re-anchor the dead tracker citations in pac…