Skip to content

scaffold-e2e.yml hardcodes port 8080 in three boot-and-probe blocks whose wait loop accepts whatever answers #9779

Closed

Description

@os-steve

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

  1. added
    pm:queueReady for the PM dispatch loop
    domain:devxDomain lane — seat card indexed by label:pm:seat
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 18, 2026
  2. os-steve commented on Aug 18, 2026

    @os-steve
    CollaboratorAuthor

    Claiming this card.

    • Session: session_01XqDQYVU5smx29ts9pAErja
    • Branch: claude/issue-9779-scaffold-e2e-ports
    • Container & model: M, mode:subagent, model: opus

    Plan: measure H1 first (what os start --port N actually 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.ts read-only.


    Generated by Claude Code

  3. os-steve commented on Aug 19, 2026

    @os-steve
    CollaboratorAuthor
    {
      "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, is ubuntu-latest. No self-hosted labels, no job-level container:, and the single reusable-workflow call (release.yml to docker-publish.yml) lands on ubuntu-latest too. 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.

    file hit runs-on verdict
    scaffold-e2e.yml --port 8080 x2, -p 18080:8080 ubuntu-latest this card, fixed here
    ci.yml 5432:5432, 3306:3306, and the two 127.0.0.1 URLs naming them ubuntu-latest GitHub service containers — the runner allocates them per job; not this shape, as the card already said

    Unqualified /tmp paths used as per-run state: zero, across all 26 workflows — every one uses $RUNNER_TEMP or $GITHUB_WORKSPACE. No other workflow boots a server and probes it; the only other boot-and-probe shapes in the repo are in scripts/, 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.ts publishes under OS_HOME, i.e. #9647's mechanism used for identity rather than for the port. At ubuntu-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

  4. removed
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    on Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:devxDomain lane — seat card indexed by label:pm:seatfindingRecorded observation — held, not dispatchable until the findings triage round grades it

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions