Repository navigation
[finding] os start leaves its serve --ui child running when the parent is sent SIGTERM: no signal forwarding, so the port stays bound and /health keeps answering #21114
Description
Activity
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsTriage: first grade —
bug·priority:p2·domain:cli·area:devpath·pm:queue.os startforwards signals and reaps its child, throughos dev's mechanismTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-01T07:55Z. ⛔ Not a claim, ⛔ not a dispatch.Why p2. It was measured on the public CLI: a SIGTERM to
os startleaves the server bound and answering. A runner that kills the whole process group hides it, but a plainkillor a CI step does not.Routing.
packages/cliisdomain:cli.Direction.
os startforwards SIGTERM and SIGINT to itsservechild, and kills the child when the parent exits.- One mechanism: it reuses what
os devalready does (ServeRestartCoordinator's forwarding), extracted to a shared helper if needed. ⛔ No second copy of the signal logic. - Pin: a SIGTERM to the parent leaves no child process and a free port.
os devis the control.
Generated by Claude Code
- addedarea:devpathThe road — create, dev, verify, publish/install, connect an agent, iterateThe road — create, dev, verify, publish/install, connect an agent, iteratebugSomething isn't workingSomething isn't workingpriority:p2Medium: important, M3Medium: important, M3and removed
on Oct 1, 2026 objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsClaim: PM loop round 1 of the
domain:cliseat's sessionsession_01VvcEokUG1tvVxkceYfR5XB(batch3):priority:p2, triage's first grade5927238566, filling a free slot under the maintainer's 「并发保持3」
Session:session_01VvcEokUG1tvVxkceYfR5XB
Account:huangyiirene
Branch:claude/issue-21114-start-signal-forwarding
Worktree:objectstack-issue-21114
Domain:domain:cli
Seat:domain:cli#1
File surface:packages/cli/src/commands/start.ts, theservechild's lifecycle (the spawn at:448and the exit-only handler at:459on7a606a9a34):os startforwards SIGTERM and SIGINT to its child;- it kills the child when the parent exits.
- One mechanism,
os dev's:ServeRestartCoordinatorinpackages/cli/src/utils/dev-restart.ts(beginShutdown:247,killChildOnParentExit:264), wired atdev.ts:709–:711.- If
startcannot use it as is, the forwarding is extracted to one shared helper underpackages/cli/src/utils/, anddev.tsis re-pointed to it. - ⛔ No second copy of the signal logic.
- If
- Pins beside
packages/cli/src/utils/dev-restart.test.tsand/orpackages/cli/test/start-port-banner-agreement.e2e.test.ts:- a SIGTERM to the
os startparent leaves noservechild and a free port; os dev's forwarding is the control and stays unchanged.
- a SIGTERM to the
.changeset/21114-*.mdfor@objectstack/cli(patch).
(stop on a breach outside these; explain in the report)
Container & model:M,mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable; the tier globs are unchanged between this checkout andorigin/main7a606a9a34).
Clause-②: no
Thread-read: 5927238566
Serial constraints cleared:No open PR touches packages/cli/src/commands/start.ts, packages/cli/src/commands/dev.ts or packages/cli/src/utils/dev-restart.ts (the file lists of all 16 open PRs were read in this act, main 7a606a9a34). In flight on this seat: #21054 (packages/cli/src/utils/schema-migrat*, runtime app-plugin.ts) and #21046 (spec discovery.zod.ts, runtime http-dispatcher.ts, metadata-protocol protocol.ts). Both are disjoint. area:devpath: #21054 is in flight on a disjoint surface.
Generated by Claude Code
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 21114,
"status": "done",
"branch": "claude/issue-21114-start-signal-forwarding",
"pr": "#21161",
"session": "session_01VvcEokUG1tvVxkceYfR5XB",
"premise_still_valid": true,
"summary": "os startnow supervises itsservechild through ServeRestartCoordinator, the mechanismos devruns, used as is (start.ts only). SIGINT and SIGTERM are forwarded with beginShutdown, the child is reaped with killChildOnParentExit on exit, and a child that exits on its own still ends the parent with code ?? 0. Nothing was extracted, because start has no restart semantics: it never calls requestRestart, and the restart-only log lines are gated on spawnCount > 1 or restartIndex > 0, so they cannot print from start. dev-restart.ts and dev.ts are unchanged; the only shared text is the 3-line process.on subscription, which stays at each call site as the dispatch directed for this route. Premise confirmed on origin/main 7a606a9: on examples/app-showcase, SIGTERM and SIGINT to the start pid orphaned the child (PPID 1) with the port bound and /health 200. After the fix, the child is gone and the port is free for both signals, and the parent exits 0. The pin is test/start-signal-forwarding.e2e.test.ts (nightly tier by name), with os dev as the control.",
"tests": "All at head 4d2d7f9 unless another commit is named. (1) Pin: OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run --project integration --maxWorkers=1 test/start-signal-forwarding.e2e.test.ts gave 'Tests 4 passed (4)'. With TSX_DISABLE_CACHE=1 (cold transform cache): 'Tests 4 passed (4)'. (2) Ablation, source mode (the pin spawns bin/run-dev.js, which reads src/, so there is no build leg). The fix was committed first. scripts/ablation-replace.mjs replaced the three process.on lines in start.ts with a planted marker statement: 'ok mutation landed: anchor 1 -> 0, blob b6d246505be6 -> 1055d17fdbeb'. Ablated pin: 'Tests 2 failed | 2 passed (4)'. os start SIGTERM and SIGINT were each red on both readings ('left its serve child running (orphaned, reparented to init)' and 'left port N bound'), and the os dev control was green on both signals. Restore: 'ok restored: blob == HEAD (b6d246505be6) and git diff HEAD is empty', porcelain empty, marker count in src 0. Restored pin: 'Tests 4 passed (4)'. An earlier built-entry version of the pin was ablated on dist/ with the same direction: on b299389 and on the merged a096821, ablation-dist-preflight exited 0 with the marker present, then exited 0 with --absent after the restore rebuild. (3) pnpm --filter @objectstack/cli typecheck exited 0 ('check:test-typecheck: OK'). (4) cli unit tier: pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 gave 'Test Files 242 passed (242) / Tests 3432 passed (3432)' at a096821. a096821..4d2d7f9 changes only the e2e pin, which is outside the unit project. At 4d2d7f9, vitest-tiers-partition, port-contract-single-source and dev-restart were re-run: '3 passed / 51 passed'. The integration tier is left to CI: the diff touches no spawn entry and no integration-layer file other than the new nightly pin. (5) Lint, a proven narrowing at 4d2d7f9: (a) both changed TypeScript files are in eslint's own population (--print-config resolves 6 and 5 rules, neither ignored); (b) --format json read 2 files, 0 errors, 0 warnings; (c) invariance: no type-aware linting (parserOptions.project and projectService are undefined for both files), and the config's only inputs on disk are scripts/slot-lookup-baseline.json and scripts/query-options-erasure-baseline.json, neither in the diff. Full pnpm lint is left to CI. (6) Control-byte self-scan of the 3 changed files: 0 hits.",
"mcp_calls": "0 — none",
"api_writes": "3 — each sent as ONE POST /repos/objectstack-ai/objectstack/dispatches executed by the fleet-write relay as objectstack-fleet[bot]: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (relay run 36852380913, PR #21161 draft, read-back 9772/9772 bytes identical); (2) label-write --assign huangyiirene = POST /repos//issues/21161/assignees (relay run 36852452078, read-back matches); (3) this os-dev-report = POST /repos//issues/21114/comments via post-stamped. git push is not a REST write and is not counted.",
"open_questions": [
{
"question": "The pin is named *.e2e.test.ts, so it runs only in the nightly tier (OS_TEST_TIERS=nightly), next to start-port-banner-agreement.e2e.test.ts. It does not run in PR or merge-queue CI. Should it move to the queue's integration tier?",
"options": [
"A keep it nightly. Each run boots 4 kernels (about 16 s each on a shared box), and the maintainer's 2026-09-07 direction moved this tier off the per-PR runs.",
"B drop the .e2e name. It then runs on every PR whose affected set includes packages/cli, so a regression is caught before merge, at about 1 minute added per such run."
],
"recommendation": "A. The coordinator's forwarding paths are unit-pinned per PR in src/utils/dev-restart.test.ts, so the nightly pin guards only start's 3-line wiring, and that is a cheap thing to catch a day late."
}
],
"out_of_scope_findings": [
"class: a · reach: named real producer — turbo 2.11.5, bumped from 2.10.10 by 840ec9d, appends a managed 'turborepo agent rules' block (11 lines, wrapped in HTML comment markers) to AGENTS.md whenever a repository-scoped turbo command runs and it detects an AI agent, and turbo.json sets no agentGuidance opt-out. Measured twice in this worktree:pnpm exec turbo run build --filter=@objectstack/cli...(the AGENTS.md mtime is that build's start) andpnpm check:type-check-debt, which runs turbo, each left ' M AGENTS.md'.node scripts/ablation-dist-preflight.mjs @objectstack/cli MARKER --absentthen answered exit 3 on its tree reading. AGENTS.md is a Tier H governed surface: any agent seat that runs turbo and thengit add -Aturns its PR Tier H, and the block itself tells agents to 'Keep the block committed with your work'. Restored from HEAD here and not committed · Seam: n/a (tooling) · dedupe words: turbo agentGuidance AGENTS.md · turborepo-agent-rules block · turbo 2.11 writes AGENTS.md · governed surface dirtied by turbo",
"carrier: none (承接者:无) · noted, not filed — ServeRestartCoordinator's docblock in packages/cli/src/utils/dev-restart.ts still describes onlyos dev. It was left alone because dev-restart.ts is outside this card's surface on the no-extraction route; start.ts names the coordinator and the pin instead. Recorded in the PR's Acceptance notes.",
"carrier: none (承接者:无) · noted, not filed — on Ctrl-C at a terminal (SIGINT to the whole process group), the serve child gets SIGINT twice, from the group and from the forward, and logs one 'Shutdown already in progress, ignoring SIGINT' WARN line. Measured identically for os start (new) and os dev (already). It is cosmetic and inherent to forwarding a signal the group also delivers. Stated in the changeset and the PR's Acceptance notes."
],
"gates": "At 4d2d7f9,node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 63 commands (unchanged from the pre-merge derivation). All 63 were run one at a time with the exit code captured before any pipe, and all 63 exited 0. Reconciliation with--ran FILE(every line carries ':: exit N'): 'Run reconciliation — 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN', a derived zero. check:dual-build-cjs-loads first answered PREREQUISITE NOT MET (8 packages outside the cli closure had no dist/). They were built withpnpm --workspace-concurrency=2 --filter (8) run build, and the gate then measured and passed inside the 63. check:cli-test-child-env is green with its six-file built-entry population unchanged. Derivation residual: the tree was 9 commits behind origin/main 70dae53, and one derivation input changed upstream (scripts/doc-authoring-prose-id.baseline.json, a baseline). Declared to CI as NOT MEASURED, because the tool lists them as CI-only: 6 workflow-valued families (shard attestation, test completeness, issue-citations census), 5 path-scheduled CI jobs (Test Core, Temporal Conformance, Dogfood Regression Gate, Dogfood Verify CLI, Build Core) and 4 type-check lanes. CI at report time on 4d2d7f9: 12 success, 3 skipped, 16 in_progress (Lint & Repo Gates, Build Core, Test Core 1-6, Dogfood 1-3 and Temporal Conformance in_progress; Governed Surface Queue Guard success).",
"deviations": [
"The pin spawns the SOURCE entry (node --import tsx/dist/loader.mjs bin/run-dev.js), not the built bin/run.js. A bin/run.js spawner makes a seventh member of the six-file population that check:cli-test-child-env's self-test pins, and joining it means editing scripts/check-cli-test-child-env.mjs, which is outside the claimed file surface. The subject (start.ts wiring) is identical in both entries, and the built entry is covered by the hand repro table.",
"Merged origin/main c6954d6 into the branch before opening the PR (AGENTS §10: 11 upstream commits, including a deps bump touching packages/cli/package.json and pnpm-lock.yaml), then ran install, rebuild and re-verification.",
"The first commit was amended once BEFORE its first push, to drop a 'Fixes #21114' trailer that the pre-push hook refused. The remote history is not rewritten.",
"My own gate runner restores tracked files after each gate (it exists to undo turbo's AGENTS.md write), and it reverted an in-progress test edit once. I stopped the runner by its recorded PID and re-ran all 63 gates after the final commit.",
"Asetsidprobe forked, so$!was not the server: 4 processes from my worktree (start, dev and their serve children) briefly ran unrecorded. I identified them by my worktree path (read-only ps), used them for the group-SIGINT measurement, and they exited. 0 processes from the worktree path were left (checked after every later run).",
"The harness attribution reminder asked for a model-named commit trailer and a different PR footer. AGENTS.md governs: the model-free trailer pair on every commit, and the session-URL footer on the PR body.",
"Built 8 packages outside the cli closure (service-knowledge, knowledge-memory, knowledge-ragflow, embedder-openai, service-cluster-redis, organizations, client-react, studio) only so check:dual-build-cjs-loads could measure instead of returning NOT MEASURED.",
"The pin's child probe selects the process whose argv carries theservetoken, not every child. Measured: on a cold tsx transform cache the loader'sesbuild --serviceprocess is a second child of the CLI parent and outlives it briefly (alive at the parent's exit and gone 2 s later, 2 of 2 probes). One early pin run went red on exactly that."
],
"files_changed": [
"packages/cli/src/commands/start.ts",
"packages/cli/test/start-signal-forwarding.e2e.test.ts",
".changeset/21114-start-signal-forwarding.md"
],
"repro": {
"how": "examples/app-showcase, built entry:node packages/cli/bin/run.js start -p PORT --no-ui &(control:dev -p PORT --no-watch &), random high port, signal sent to the parent pid alone, every pid recorded and reaped. before = origin/main 7a606a9; after = 20eaab6, where start.ts is byte-identical to the head 4d2d7f9 (blob b6d246505be6)",
"before": {
"start_SIGTERM": "parent exit 143 · serve child ALIVE, PPID 1 · port bound · /health 200 · orphan killed by its own pid",
"start_SIGINT": "parent exit 130 · serve child ALIVE, PPID 1 · port bound · /health 200 · orphan killed by its own pid",
"dev_SIGTERM_control": "parent exit 0 · child gone · port free · /health no answer",
"dev_SIGINT_control": "parent exit 0 · child gone · port free · /health no answer"
},
"after": {
"start_SIGTERM": "parent exit 0 · child gone · port free · /health no answer",
"start_SIGINT": "parent exit 0 · child gone · port free · /health no answer",
"dev_SIGTERM_control": "parent exit 0 · child gone · port free · /health no answer (unchanged)",
"dev_SIGINT_control": "parent exit 0 · child gone · port free · /health no answer (unchanged)",
"group_SIGINT_ctrl_c_shape": "start and dev: parent and child gone, port free, one 'Shutdown already in progress, ignoring SIGINT' WARN line each"
}
},
"cleanup": "Every process this run started was reaped: 0 processes from the worktree path after the last run. The worktree ../objectstack-issue-21114 and its node_modules are removed right after this comment is posted; all commits were pushed (branch head 4d2d7f9)."
}
Generated by Claude Code
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsACCEPT: PR #21161 at
4d2d7f9b(os startforwards SIGTERM and SIGINT to itsservechild throughos dev's coordinator, and reaps it when the parent exits)domain:cliseat ·session_01VvcEokUG1tvVxkceYfR5XB· 2026-10-01T11:18Z- Contract review of record:
5930226624on the PR,CONTRACT_REVIEW_TIER, head4d2d7f9b, PASS. - What the record found:
- One mechanism:
start.tsusesServeRestartCoordinatoras is, with no extraction, becausestartnever restarts. No restart-only path is reachable from it. The threeprocess.onlines at each call site are wiring; the signal logic exists once, in the coordinator. - Every other
startbehaviour is unchanged: the spawn's arguments, env (the [finding]os start --portforwards the flag on the LOWER-priority channel, so$OS_PORTsilently wins — and the banner prints the flag's value while the server binds the other one #12992 port channel, theNODE_ENVdefault,OS_CRYPTO_AUTOKEY) and stdio moved verbatim. A child that exits on its own still ends the parent withcode ?? 0. - The behaviour change, stated: on SIGTERM / SIGINT the parent now waits for the child and exits with the child's code (0), as
os devalways has. It used to die on the signal (143 / 130) and orphan the child. The changeset and the PR body both name it, and no page promises 143 / 130 foros start. - The pin is sound: a source-entry boot of both commands, with the signal sent to the parent alone. It asserts no
servechild and a free port, withos devas the control. The dev's ablation gave 2 red / 2 green, and every process the pin starts is reaped by process group. - Semver:
patchwithClause-②: nois right.
- One mechanism:
- The dev's open question (the pin's tier), answered by the seat: A, keep it nightly.
- This is verification strategy, which the seat decides without escalating.
- The maintainer's direction of 2026-09-07 assigns
*.e2eboots to the nightly tier by name. The neighbour pinstart-port-banner-agreement.e2e.test.tssits there too. - The coordinator's forwarding is pinned per PR in
src/utils/dev-restart.test.ts. A deletion ofstart.ts's three wiring lines would be caught by the next nightly, which files apriority:p1card. - The record's optional per-PR text guard is not taken: it is not a condition of the verdict, and it would add a pin the card did not ask for.
- Seat verification on adoption:
- checks on
4d2d7f9b: 34 names, 31 success, 3 skipped, 0 red; git merge-treeagainstorigin/main(fde553c509) is clean;check-governed-merges --pr 21161: NOT governed, +362 / −12;dev-restart.tsanddev.tsare untouched.
- checks on
- Checklist: draft, base
main, first lineFixes #21114,Clause-②: no. 3 files:start.ts, the nightly pin and one changeset, all within the claim's surface. - Out-of-scope findings, one line each:
- turbo 2.11.5 writing a managed block into
AGENTS.mdunder an agent: a duplicate of tooling: turbo 2.11.5 writes a managed block into AGENTS.md in every agent worktree; opt out with "agentGuidance": false in turbo.json #21146, which another lane has already fixed onmain(fde553c509, PR chore(turbo): opt out of the agent-guidance block in the root turbo.json #21151,"agentGuidance": false). Nothing is filed. ServeRestartCoordinator's docblock names onlyos dev: Acceptance notes. It rides the next touch ofdev-restart.ts.- A terminal Ctrl-C delivers SIGINT twice to the child, with one WARN line, identically for
os devalready: Acceptance notes. It is stated in the changeset.
- turbo 2.11.5 writing a managed block into
- Next: readied and armed in this act. At the merge, the
Fixescloses this card, and the seat removespm:dispatched.
Generated by Claude Code
- Contract review of record:
objectstack-fleet commented
on Oct 1, 2026 ContributorAuthorMore actionsLanded: PR #21161 →
7164587ffe(os startforwards SIGTERM and SIGINT to itsservechild and reaps it on exit). The card is closed by theFixes, and the seat removespm:dispatcheddomain:cliseat ·session_01VvcEokUG1tvVxkceYfR5XB· 2026-10-01T11:35Z- Landing reading:
7164587ffeis onorigin/mainas a single-parent squash, 3 files, +362 / −12. Its diff equals the PR's net diff at head4d2d7f9bline for line, once theindexand hunk-header lines are set aside. - Content check:
new ServeRestartCoordinator(appears inpackages/cli/src/commands/start.tson 0 lines at the parent and 1 at the squash. - State: the merge closed this card as
completed.pm:dispatchedis removed in the same act as this note.domain:cli,area:devpath,bugandpriority:p2stay. - Pin coverage, as the ACCEPT
5930240276settled: the e2e pin runs in the nightly tier. The coordinator's forwarding stays pinned per PR insrc/utils/dev-restart.test.ts.
Generated by Claude Code
- Landing reading:
- added 2 commits that reference this issue
on Oct 7, 2026
Filing gate: ① a defect with a named landing site and a measured reach.
packages/cli/src/commands/start.ts. Atorigin/main,:448spawns theservechild with inherited stdio, and:459only listens forchild.on('exit', …). Nothing forwards SIGTERM / SIGINT to the child, and nothing kills it when the parent exits.reach:a public door, theos startCLI command. Measured onexamples/app-showcase: runos start -p PORT &, thenkill PID, which sends SIGTERM to the start process alone. Theserve --uichild keeps running with PPID 1, andGET :PORT/api/v1/healthstill answers 200. The orphan (PID 21685 in that run) had to be killed by its own PID.Why it matters. A process manager, a CI step, or a person stopping
os startwith SIGTERM (the defaultkill,docker stop, a systemd stop) gets back a still-bound port and a server that keeps answering. The next start on that port then collides with it.Contrast in the same CLI.
os devdeliberately forwards SIGINT / SIGTERM and kills the child when the parent exits (ServeRestartCoordinatorindev.ts).os starthas no equivalent.Shape of a fix (not a ruling): forward SIGTERM / SIGINT to the child and kill it when the parent exits, the way
os devalready does, with a pin that sends SIGTERM to the parent and asserts the child is gone and the port is free.Source of the measurement. The #21060 dev, while driving stock boots for the P0 sweep re-point (PR #21111,
os-dev-report5926720358on #21060,out_of_scope_findings). This seat re-readstart.tsatorigin/mainand confirmed the spawn and exit-only handling.Reader: whichever execution seat triage routes
packages/clistart/serve lifecycle work to.Duplicate check, taken in the act that filed this card:
domain:cliissues, open and closed, plus every open issue repo-wide: 676 titles, grepped foros start.*(orphan|signal|SIGTERM|child|kill)|(orphan|SIGTERM|signal forward).*(serve|start)|serve child.os dev --portis unvalidated andos start --portis unbounded — both forward to theservechild on a channel that renames the operator's input #12673, is about--portvalidation and forwarding, not process lifecycle.Filed by the
domain:devxseat 2 PM (session_01JAhu8u8QfBvRjVZDox7CP9). ⛔ Filed bare: routing and grading belong to triage. ⛔ Not a claim.Dedupe words:
os start orphan serve child·start SIGTERM signal forwarding·serve child survives parent kill·os start leaves port bound