Skip to content

fix(e2e/live): start-backend.sh records the server's pid, not a subshell's (objectui#12676) - #12737

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-12676-stop-backend-pid
Oct 16, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-12676-stop-backend-pid

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #12676

Clause-②: no

e2e/live/ci/start-backend.sh now writes the PID of the objectstack dev process itself to backend.pid. So e2e/live/ci/stop-backend.sh stops the server it started, and the port goes quiet. Before, the script backgrounded the whole cd … && nohup … list, and $! was the PID of a forked bash subshell that waited on the server. The stop script killed that subshell, and the server, reparented to PID 1, kept the port.

The fix

One hunk in start(). The cd "$APP_DIR" now runs first inside the parenthesised subshell, and only the nohup … command is backgrounded. A backgrounded simple command forks once and execs. nohup and the bin's env node shebang both exec in place, so $! is the server's node process.

Everything else is unchanged:

  • the env (OS_CLOUD_URL=off), the arguments and the log redirection;
  • the readiness loop, which still runs kill -0 on the recorded PID at each poll;
  • the ready line and the exit codes;
  • stop-backend.sh, which still kills only the recorded PID. Nothing kills by port or by process name.

A comment above the hunk says why the cd must stay out of the backgrounded list.

Reproduction (before: the base script at 96fa4b9bc)

Setup for every run in this PR:

  • this container: bash 5.2.21, Node 22.22.0, npm 10.9.4;
  • @objectstack/cli@18.0.0-next.2;
  • a fresh LIVE_BACKEND_DIR in the session scratchpad;
  • port 4776, with nothing else on 4776 or 4777.

The base-script runs used git show 96fa4b9bc:e2e/live/ci/start-backend.sh byte for byte (blob 547ec18ab).

  1. Start (cold install, 137 s, exit 0) printed ready: seeded sign-in answered on :4776 (pid 1092). The process tree from the recorded PID:
    • 1092 (PPID 1): bash …/start-backend.sh. This is the subshell.
    • 1093 (PPID 1092): node ./node_modules/.bin/objectstack dev --seed-admin --fresh -p 4776.
    • 1243 (PPID 1093): node …/objectstack serve --dev --port 4776. It holds the port.
    • 1285 (esbuild --service) and 1309 (node ./scripts/mcp-fixture.mjs): children of 1243.
  2. Stop: stop-backend.sh printed stopped pid 1092 and exited 0. GET :4776/api/v1/health still answered 200. 1092 was gone. 1093, 1243, 1285 and 1309 were all alive, and 1093's PPID was now 1.
  3. Start twice, no stop:
    • The first run recorded 1433, a subshell over the server 1434.
    • The second run printed starting objectstack dev on :4776, not already running. The guard greps the recorded PID's cmdline for objectstack, and the subshell's cmdline is the script's own.
    • The second run recorded 1552, a subshell over a second server, 1553. That server logged Port 4776 is in use — serving on 4777 instead. and bound 4777.
    • The second run still printed ready … on :4776 (pid 1552) after 3 s. The FIRST server answered that check.

Each leftover server was then stopped by the PID read above: kill 1093, then kill 1434 1553. Each tree was gone within seconds, and both ports then refused connections.

Proof (after: this branch at 3a0843dcf)

The same LIVE_BACKEND_DIR (its stamp was reused) and the same port.

  1. Start (19 s, exit 0) printed ready: seeded sign-in answered on :4776 (pid 2201). /proc/2201/cmdline is node ./node_modules/.bin/objectstack dev --seed-admin --fresh -p 4776. The tree: 2201 (PPID 1), then 2221 (objectstack serve --dev --port 4776, the listener), then 2241 (esbuild) and 2252 (mcp-fixture.mjs).
  2. Start again (1 s, exit 0) printed [live-backend] already running (pid 2201). The PID file still read 2201, and only 2221 listened on 4776.
  3. Stop: before it, GET :4776/api/v1/health answered 200. stop-backend.sh printed stopped pid 2201 and exited 0. Straight after, health got curl exit 7 (connection refused), and the same 3 s later. 2201, 2221, 2241 and 2252 were all gone. No process listened on any 4xxx port. The SIGTERM to the dev process takes its serve child and the grandchildren down with it, so the recorded PID is enough.
  4. A boot that dies (LIVE_BACKEND_PORT=70000): the CLI refused with Invalid port: --port "70000" and exited. The script printed backend process died — last 100 log lines: and exited 1 after 7 s. The base script on the same input gave the same line, exit 1, in 7 s. The readiness loop checks the recorded PID with kill -0. Before, that PID was a subshell, which lived exactly as long as its one child, the server. Now it is the server. So the fail-fast path answers the same on both sides.

CI is unchanged

  • .github/workflows/live-e2e.yml calls only start-backend.sh. stop-backend.sh appears there only in the comment Ephemeral runner — no teardown needed.
  • start-backend.sh exits the same way and prints the same ready line on both sides: exit 0, and [live-backend] ready: seeded sign-in answered on :PORT (pid N). Only N changes, from the subshell's PID to the server's.
  • One visible difference, which does not change any verdict. The job's post-run orphan cleanup used to list a bash wrapper and then node (read on the objectui#12649 run). It should now list only the node processes. This was not measured on a runner. This PR's own Live E2E (informational) run will show it.

Gates (local, at 3a0843dcf, each exit captured before any pipe)

  • bash -n e2e/live/ci/start-backend.sh: exit 0.
  • node scripts/check-bash32-floor.mjs: exit 0, 13 tracked shell file(s) … name no bash 4+ construct.
  • node scripts/check-control-bytes.mjs: exit 0. A grep -naP self-scan of the changed file found no control byte.
  • node scripts/check-new-cross-file-line-citations.mjs: exit 0, 0 new citation(s).
  • node scripts/check-governed-queue-guard.mjs --test e2e/live/ci/start-backend.sh: exit 0, NOT GOVERNED.
  • node scripts/check-changeset-presence.mjs: exit 0, no changeset is owed. No .changeset file was added.
  • pnpm exec vitest run scripts/__tests__/bash32-floor-wiring.test.ts scripts/__tests__/ci-cd-pipeline-doc.test.ts: Test Files 2 passed (2), Tests 97 passed (97), lock VERDICT command-exit 0. These are the only test files that name start-backend.sh or e2e/live/ci (git grep -l).
  • The doc-link check was not run, because no doc changed. content/docs/guide/ci-cd-pipeline.md does not describe stopping the backend.

Acceptance notes

  • No permanent test. The behaviour needs a real backend. The before and after runs above are the proof: a one-off reading at 18.0.0-next.2, which nothing in the tree re-derives (the comment says so too). No ablation, because no test pins this behaviour.
  • Boundary, not measured. stop-backend.sh falls back to kill -9 after 10 s, and that hits only the recorded dev process. If dev ever ignored SIGTERM, its serve child would be orphaned. This was not observed: in every run here, SIGTERM ended the whole tree.
  • Observation, not filed (governed file, not touched). AGENTS.md's per-agent test-stack section tells you to stop only by your own recorded PID, but the command it gives in the same parenthesis is kill $(lsof -ti tcp:PORT), which kills by port. Its service-discipline section says never to kill by port. Carrier: none.
  • Observation, not filed. In this container, PID 1 reaps orphans late, and a zombie still answers kill -0. Liveness above was read from ps state, not from kill -0.

Generated by Claude Code

…ell's

Only the `nohup …` command is backgrounded now, after the `cd`, so `$!` is
the `objectstack dev` process itself. Backgrounding `cd … && nohup …` as one
list recorded the forked subshell's pid: stop-backend.sh killed the subshell
and left the server on its port, and the `already running` guard never
matched, because the subshell's cmdline is the script's own.

Claude-Session: https://claude.ai/code/session_016PVjjuVPte5XEJvaQx1E4t
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · objectui#12737 (draft, head 3a0843dcf, Fixes #12676) · domain:devx @ objectui seat 1 · session_016PVjjuVPte5XEJvaQx1E4t · 2026-10-16T16:23Z

Checked on GitHub and against the sources, not on the report's say-so. The dispatch's first run was lost to a container restart; its commit survived, and the resumed run verified it and opened this PR.

  • Shape:
    • 1 file, e2e/live/ci/start-backend.sh, +11 / −2. That is two code lines plus a comment, inside the claim's surface (6219308713).
    • stop-backend.sh is unchanged. No doc or workflow is touched.
    • NOT GOVERNED (check-governed-queue-guard --test). No changeset is owed (check-changeset-presence).
    • The body opens Fixes #12676, then Clause-②: no.
  • The fix, read by this seat on the diff:
    • In start(), cd "$APP_DIR" now runs first inside the subshell. Only OS_CLOUD_URL=off nohup ./node_modules/.bin/objectstack dev … & is backgrounded, so $! is the objectstack dev process itself.
    • Log redirection, env, flags and the echo $! > backend.pid line are unchanged.
  • Measured by the dev, base script against branch, real scripts end to end, its own port and dir:
    • Base, stop: "stopped pid 1092", but health still answered 200, and the server tree (1093 → 1243 serve) was alive, reparented to PID 1.
    • Base, second start: it launched a second server, which fell back to 4777. Its "ready" line was answered by the first server. This is a second symptom of the same cause.
    • Branch, start: the recorded pid 2201 is objectstack dev itself.
    • Branch, second start: it prints "already running (pid 2201)".
    • Branch, stop: the port refuses at once and 3 s later, and the whole tree is gone.
    • Broken boot (port 70000): both base and branch print "backend process died" and exit 1 in 7 s.
    • CI is unaffected: the workflow never calls stop-backend.sh, and start-backend.sh's exit and ready line are unchanged.
  • Gates:
    • bash -n, check-bash32-floor, check-control-bytes and check-new-cross-file-line-citations all exit 0.
    • The two tests that name these scripts pass: 97 of 97.
  • Noted, not filed:
    • AGENTS.md:618 says to stop only by your own recorded PID, but its example is kill $(lsof -ti tcp:<your port>). That stays harmless while the port is your own.
    • The kill -9 fallback reaches only the recorded process. It was not observed to matter.
  • Landing: once every check on this head reads green, the seat readies the PR, arms auto-merge, and confirms the merge on objectui origin/main.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 16, 2026 16:34
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 16, 2026 16:34
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit c220689 Oct 16, 2026
34 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-12676-stop-backend-pid branch October 16, 2026 16:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

e2e/live/ci: stop-backend.sh kills the subshell start-backend.sh recorded, not the server, so a local stop leaves the backend running on its port

2 participants