Repository navigation
core: ObjectKernel.shutdown() never removes the SIGINT/SIGTERM/SIGQUIT listeners its constructor registers, so every kernel built in a process leaves three handlers that each run shutdown and process.exit on a later signal #22286
Description
Activity
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsTriage: first grade,
priority:p2·domain:engine·pm:queue. Direction:shutdown()removes the listeners its constructor addedTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-08T13:01Z. ⛔ Not a claim, ⛔ not a dispatch.Triage: lands in
packages/core/src/kernel.ts(registerShutdownSignals,shutdown()) ⇒domain:engine; rationale:packages/coreis that lane's (lanes/engine.md:7).- Why p2: any process that builds more than one kernel with the default accumulates signal handlers. One signal then runs several
process.exitcalls, racing the host's drain. Cloud opted out (objectstack-ai/cloud PR fix(mcp): SKILL.md documents the business-action tools (#2714 Phase 0) #2715); every other host still has it. - Direction: keep the handles, remove them in
shutdown(), and a stopped kernel's handler never exits the process. - Pins: five kernels built and stopped leave the baseline listener count. One signal produces one exit. Control: a running kernel still shuts down on a signal.
- Why p2: any process that builds more than one kernel with the default accumulates signal handlers. One signal then runs several
- addedpriority:p2Medium: important, M3Medium: important, M3and removed
on Oct 8, 2026 objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsClaim: PM loop round 64 · 2026-10-08T16:42Z
Session:session_01EUBvqtauTDmHi2ZgY759p2
Account:os-litant(the seat's linked user, asGET /useranswers it; always the card's assignee)
Branch:claude/issue-22286-kernel-signal-listeners
Worktree:objectstack-issue-22286
Domain:domain:engine
Seat:domain:engine#1
Provenance:- Filed by the
repo:cloudseat from objectstack-ai/cloud#2712's dev; triage graded it p2bug(6060458435). - Batch 3: metadata-protocol: on a host-config kernel the object save door runs the authoring gate before the package door, so a packaged object's publish save can answer 422 INVALID_METADATA where an environment kernel answers 403 NOT_OVERRIDABLE (#8184's sibling) #22220 and objectql: a formula field in a
fieldsprojection widens it to every column and the rows are never trimmed back — get_record sends owner, org and audit columns to an external endpoint #22300 hold the other two dev slots. feat(objectql,plugin-auth): the Default Organization is load-bearing undersingle; an unstamped write is derived there and refused everywhere else (ADR-0131 D3/D9/D11) #15195 (PR feat(plugin-auth,objectql,metadata-protocol,runtime)!: undersinglethe Default Organization exists before the seeds and the listener; an unowned seed row or system write is derived there or refused (ADR-0131 C1) #22186) and feat(objectql,metadata,runtime)!: refuse a package whose position, permission set or capability name is already held by an installed package, the environment catalog or a built-in (ruling Q4 = A on #15196; narrows ADR-0048 §3.4) #22135 (PR feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a second holder is refused at registration, naming both #22197) are in the merge queue with no dev.
File surface (atorigin/main59d993c973+, per triage 6060458435): - Measure first: reproduce the leak. Build N kernels with the default
gracefulShutdown: trueand shut each down:process.listenerCountper signal grows by one per kernel. Measure how manyprocess.exitcalls one signal produces with several kernels built. - The fix:
packages/core/src/kernel.ts.registerShutdownSignalskeeps the bound handlers.shutdown()removes them.- A stopped kernel's handler never exits the process.
- Card's family item:
shutdown()'sprocess.exit(1)on a teardown timeout (near:581). Fold it in under the same rule (a kernel that does not own the process does not exit it), or record in the PR why not.
- Pins:
- Five kernels built and stopped leave the baseline listener count for SIGINT, SIGTERM and SIGQUIT.
- One signal produces one exit.
- Control: a running kernel still shuts down on a signal.
- Reverse-verify the removal.
.changeset/22286-*.md(@objectstack/corepatch).
Container & model:S,mode:subagent,model: default(dispatch-gates --tier: no path-derived mandate).
Clause-②: no- No option, export or accept set changes: a kernel stops leaking its own process handlers. If the family item needs a new option to say who owns the process, that widens the public surface; the line then becomes
yes, and a contract-tier review is owed. The dev reports it inopen_questionsbefore adding one.
Responsibility:ObjectKernel's constructor registers anonymous signal handlers thatshutdown()never removes |registerShutdownSignalskeeps no handle | any process that builds more than one kernel with the default: cloud's per-environment kernels (measured; cloud opted out in its PR fix(mcp): SKILL.md documents the business-action tools (#2714 Phase 0) #2715), tests, and CLIs that rebuild
Thread-read: 6060458435
Serial constraints cleared: at 2026-10-08T16:42Z, no open PR touchespackages/core/src/kernel.ts(file lists read byfilename). This lane's ObjectKernel: a public option to bootstrap without dispatching the boot-phase hooks (kernel:ready / kernel:bootstrapped / kernel:listening), so a host can build a definitions-only repair kernel without patching context.hook #22272 (p2,pm:queue: a public bootstrap option onObjectKernel) also lands inkernel.tsand stays queued until this card lands. feat(spec,services): deployment-level state has no organization column — settings global rung, plumbing objects, the audit ledger, #12699 made total (ADR-0131 D7) #15207 item (4)'s declaration namescore/src/plugin-order.ts, notkernel.ts.
- Filed by the
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 22286,
"status": "done",
"branch": "claude/issue-22286-kernel-signal-listeners",
"pr": "#22334",
"session": "session_01EUBvqtauTDmHi2ZgY759p2 (mode:subagent, the dispatching PM session; identity is the branch)",
"premise_still_valid": true,
"summary": "Premise confirmed on main f2626c7: registerShutdownSignals() added anonymous SIGINT/SIGTERM/SIGQUIT listeners with no handle and shutdown() never removed them (probe: 5 built-and-stopped kernels leave +5 per signal; one SIGTERM with 5 stopped + 1 live kernel gives 6 x exit(0); 11 kernels trip MaxListenersExceededWarning). ObjectKernel now keeps its listeners in a private signalListeners map and releases them at every transition into 'stopped' (shutdown complete, shutdown timeout, failed bootstrap); only the handler whose signal started the drain exits, once; a kernel already stopping/stopped when a signal arrives logs and does not exit. Listeners are removed at 'stopped' rather than at drain start (dispatch route deviation, measured reason: keeps the existing duplicate-signal absorption so a repeated Ctrl-C mid-drain does not hit Node's default action). Family item (teardown-timeout exit(1)) NOT folded: no rule in code ties gracefulShutdown:false to process ownership and kernel.test.ts pins exit(1) under gracefulShutdown:false; raised in open_questions with four-axis analysis. PR assignee NOT set: the label-write was refused by the local permission classifier (see deviations).",
"tests": "New packages/core/src/kernel-signal-listeners.test.ts (9 tests: N=5 build-and-stop baseline count with positive control; one signal -> one exit with 5 stopped + 1 live; control per signal: running kernel drains then exits once; repeated signal during signal-driven drain absorbed with listener still installed; signal during host-driven shutdown does not exit; failed bootstrap releases listeners; gracefulShutdown:false installs none). Reverse verification + ablations at 74a027b (source-resolved: test imports ./kernel relatively, no dist, no alias; each leg restored with git checkout HEAD -- and proven by blob 2307a7b5 == HEAD and empty git diff HEAD): Leg A kernel.ts at base f2626c7 -> 8 failed / 1 passed, one-signal pin 'expected [ +0, +0, +0, +0, +0, +0 ] to deeply equal [ +0 ]'; Leg B removal dropped via scripts/ablation-replace.mjs (anchor 1 -> 0, blob 2307a7b5 -> efc9b4e6) -> 8 failed / 1 passed, 'expected { SIGINT: 6, SIGTERM: 6, SIGQUIT: 6 } to deeply equal { SIGINT: 1, SIGTERM: 1, SIGQUIT: 1 }'; Leg C stopping/stopped guard dropped (blob -> 923bb1da) -> exactly 1 failed (host-stopping pin, 'expected [ +0 ] to deeply equal []'). Union at final head 87a1313 (origin/main 28bff18 merged; kernel.ts/test blobs unchanged 2307a7b5/f7c6469d), closure rebuilt via turbo (30 tasks ok): @objectstack/core local 80 files / 2208 passed, repo 3 files / 48 passed; @objectstack/runtime local 340 files / 4773 passed 19 skipped, repo 3 files / 751 passed; pnpm --filter @objectstack/core typecheck exit 0 (check:test-typecheck OK, ledger unchanged at 4). Pre-merge at 74a027b the same suites were green (core 80/2208 + 3/48, runtime 338/4760+19 skipped + 3/751). CJS smoke of core dist: ObjectKernel loads, listeners 0/0/0 -> 1/1/1 live -> 0/0/0 after stop. Probe before/after table is in the PR body.",
"mcp_calls": "0",
"api_writes": "2 REST writes: (1) fleet-write relay POST /repos/objectstack-ai/objectstack/dispatches (HTTP 204) -> run 37823686010 -> pr_create POST /repos/objectstack-ai/objectstack/pulls (draft) = PR #22334, body read back byte-identical (9159 bytes); (2) this os-dev-report comment, POST /repos//issues/22286/comments via scripts/pm/post-stamped.mjs. 1 write attempted and refused before any request by the local permission classifier: node scripts/pm/label-write.mjs --issue 22334 --assign os-litant (0 requests). git push (not REST): 5 pushes to the feature branch.",
"open_questions": [
{
"question": "Family item from the card: shutdown() calls process.exit(1) on a genuine teardown timeout (kernel.ts, the shutdownTimeoutError branch) whatever gracefulShutdown says. Measured reach (probe R7, stubbed exit): a host holding two gracefulShutdown:false kernels (cloud's per-environment shape) stops one; its teardown hangs past shutdownTimeout and exit(1) fires while the sibling kernel is still running. Should a kernel that does not own the process exit it on a teardown timeout, and if not, what says who owns the process?",
"options": [
"A. Keep the hard exit for every kernel; correct content/docs/protocol/kernel/lifecycle.mdx, which today says gracefulShutdown:false 'stays out of the process-management business'. Business need: none served; cloud's measured multi-kernel host keeps a path where one hung environment teardown ends the whole host after 60 s (default). Long-term: keeps a known hazard, docs become true. AI-error: removes the docs/code contradiction (today the docs mislead). Startup focus: zero surface, zero code.",
"B. Fold under the existing option: gracefulShutdown:false means 'not the process owner' for exits too; on timeout such a kernel logs at error, marks itself stopped and returns without exiting (the host decides). Business need: serves the one measured producer (cloud env kernels already set gracefulShutdown:false to stay out of the process) and matches how in-repo fixtures read the flag ('a fixture kernel must not hook the test process'). Long-term: one meaning, already the documented one; cost: the #5274 timeout pin's fixture moves to gracefulShutdown:true (its subject, timeout-vs-throw discrimination, is unchanged) and a new pin asserts no exit under false; hosts with false that relied on exit(1) after a hung teardown must exit themselves (they already own the signals). AI-error: docs, JSDoc and behaviour say the same thing; the flag's name under-describes it, so its JSDoc must state it. Startup focus: no new surface.",
"C. A new explicit ownership option separating 'install signal listeners' from 'may exit the process'. Business need: no measured producer needs the two apart (cloud wants both off, the CLI both on). Long-term: most explicit. AI-error: one more flag to mis-set. Startup focus: widens the public surface (Clause-② yes, minor, contract-tier review) without pull - default no.",
"D. Only the kernel's own signal handler exits on a timeout; a host-called shutdown() reports the timeout to its caller (a typed rejection) instead. Business need: precise. Long-term: principled (exit only from the owner's code path). Cost: breaks shutdown()'s documented 'never rejects' behaviour for every host, and the CLI migrate-and-exit path would exit 0 after a hang unless updated. Startup focus: contract churn."
],
"recommendation": "B, because it serves the only measured producer, makes the documented contract true instead of adding surface, and costs one fixture flip on an existing pin; A if the maintainer prefers zero behaviour change, in which case the docs sentence must be corrected. Not built in this round (dispatch: no new public option without asking; folding reverses a landed pin)."
}
],
"out_of_scope_findings": [
"carrier: open_questions[0] (the process-ownership decision) · noted, not filed — two LIVE gracefulShutdown:true kernels each drain and exit on one signal (probe R8: 2 x exit(0) before and after this PR); not a stopped-kernel case, so outside the ruling; same ownership family",
"carrier: open_questions[0] · noted, not filed — content/docs/protocol/kernel/lifecycle.mdx says gracefulShutdown:false 'stays out of the process-management business' while the teardown-timeout exit(1) fires regardless (probe R7); option A makes it a docs fix, option B a code fix",
"carrier: 承接者:无 · noted, not filed — a kernel constructed and never bootstrapped keeps its listeners (shutdown() on an idle kernel throws 'Kernel not running', unchanged); in PR Acceptance notes"
],
"pr_body_full": {
"body": "Fixes #22286\nClause-②: no\n\n## What was wrong\n\nWithgracefulShutdown: true(the default), theObjectKernelconstructor calledregisterShutdownSignals(). That added one anonymousprocess.on(signal, ...)listener each for SIGINT, SIGTERM and SIGQUIT and kept no handle, soshutdown()could never remove them. A stopped kernel's handler still ranshutdown(), which warned and returned, and then calledsafeExit(0). Every kernel a process built therefore left three handlers, and each one exited the process on the next signal.\n\n## What changes (packages/core/src/kernel.ts)\n\n-registerShutdownSignals()stores each listener in a privatesignalListenersmap.\n-releaseShutdownSignals()removes exactly those listeners and is idempotent. It runs at every transition intostopped:shutdown()completing, the shutdown-timeout branch, and a failedbootstrap(). A stopped kernel holds no listener.\n-handleShutdownSignal()exits the process only from the handler whose signal started the drain, and only once. If the kernel is alreadystoppingorstoppedwhen a signal arrives, the handler logs a warning and does not exit, because whoever is stopping the kernel owns what follows.\n- ThegracefulShutdownJSDoc states the listener lifetime. There is no new option, export or accept-set change.\n\nWhy the listeners are removed atstopped, not when the drain begins. The dispatch's suggested route removed them at the start ofshutdown()and on the first signal. That would regress the existing duplicate-signal guard (Shutdown already in progress, ignoring SIGINT). While a kernel drains, its listener absorbs a repeated signal, for example a terminal Ctrl-C that reaches both the process group and a forwarding parent. With no listener left, that second signal gets Node's default action and kills the process mid-drain. So the listener stays installed throughstoppingand is removed atstopped. Pin 6 below covers this.\n\n## Before / after\n\nMeasured with a probe that stubsprocess.exitand invokes the listeners the kernels added, as a delivered signal would. Before =packages/core/src/kernel.tsatf2626c71db(blob57d2340c); after = this branch (blob2307a7b5). Counts are listeners added per signal (SIGINT / SIGTERM / SIGQUIT, always equal).\n\n| Scenario | Before | After |\n|---|---|---|\n| 5 kernels built, booted and stopped: listeners left | +5 | +0 |\n| One SIGTERM with those 5 stopped kernels plus 1 live kernel:process.exitcalls | 6 ×exit(0)| 1 ×exit(0)|\n| Listeners left after that SIGTERM | +6 | +0 |\n| SIGTERM while the host's ownshutdown()is draining: exits during the drain | 1 ×exit(0)| 0 |\n| SIGINT, then SIGINT and SIGTERM again during that signal's drain: exits | 1 ×exit(0), after the drain | 1 ×exit(0), after the drain |\n| Listeners installed during that drain / after it | +1 / +1 | +1 / +0 |\n| Failedbootstrap(): listeners left | +1 | +0 |\n| 11 kernels built and stopped:MaxListenersExceededWarning| 3 (one per signal) | 0 |\n| Two live default kernels, one SIGTERM: exits | 2 ×exit(0)| 2 ×exit(0)(unchanged, see Acceptance notes) |\n| Family item: the host stops one of twogracefulShutdown: falsekernels and its teardown hangs pastshutdownTimeout|exit(1), other kernel stillrunning| unchanged |\n\n## The family item: the teardown-timeoutprocess.exit(1)is not folded in\n\n- Reach, measured in process with a stubbed exit. A host holding twogracefulShutdown: falsekernels (cloud's per-environment shape) stops one of them. That kernel's teardown hangs pastshutdownTimeout, andshutdown()callsprocess.exit(1)while the other kernel is stillrunning.\n- Why it is not folded. No rule in the code makesgracefulShutdown: falsemean "this kernel does not own the process". The only place that says so is the docs pagecontent/docs/protocol/kernel/lifecycle.mdx: "ObjectStack ships the listeners only whengracefulShutdown: true; otherwise it stays out of the process-management business". The code pins the opposite. Inkernel.test.ts, the test "still logs the timeout and still forces exit(1) when shutdown genuinely times out" builds its kernel withgracefulShutdown: falseand assertsexit(1). Folding the exit undergracefulShutdownwould reverse that landed pin and give an existing option a second meaning. Any other fold needs a new option, which widens the public surface. So the question goes to the maintainer with a four-axis analysis (the os-dev report on the card), and this PR leaves the timeout exit unchanged.\n\n## Tests\n\nNew filepackages/core/src/kernel-signal-listeners.test.ts, 9 tests.process.exitis stubbed throughout, and listeners added during a test are removed inafterEach.\n\n1. Five kernels built and stopped leave the baseline listener count on SIGINT, SIGTERM and SIGQUIT. Positive control: a live kernel holds exactly one listener per signal.\n2. One signal produces one exit with five stopped kernels and one live kernel in the process.\n3. to 5. Control, one test per signal: a running kernel still drains (kernel:shutdown, thendestroy()) and then exits once with code 0.\n6. A repeated signal during a signal-driven drain is absorbed, and the listener is still installed while the kernel isstopping. It starts no second exit.\n7. A signal that arrives while the host is stopping the kernel does not exit the process.\n8. A kernel whose bootstrap failed releases its listeners.\n9.gracefulShutdown: falseinstalls no listener.\n\nReverse verification and ablations (one-off, run at74a027b369). The test imports./kernelrelatively, so it reads source: nodist/and no vitest alias are involved. Each leg restoreskernel.tswithgit checkout HEAD --and proves the restore by blob hash (2307a7b5equals HEAD) and an emptygit diff HEAD.\n\n- Leg A,kernel.tsrestored to the basef2626c71dband the test at HEAD: 8 failed, 1 passed (thegracefulShutdown: falsecontrol). The one-signal pin readsexpected [ +0, +0, +0, +0, +0, +0 ] to deeply equal [ +0 ].\n- Leg B, removal dropped: the anchorprocess.removeListener(signal, listener);is replaced with a no-op throughscripts/ablation-replace.mjs(anchor 1 → 0, blob2307a7b5→efc9b4e6). 8 failed, 1 passed. The one-signal pin readsexpected { SIGINT: 6, SIGTERM: 6, SIGQUIT: 6 } to deeply equal { SIGINT: 1, SIGTERM: 1, SIGQUIT: 1 }.\n- Leg C, thestopping/stoppedguard inhandleShutdownSignal()dropped (blob →923bb1da): exactly 1 failed, the host-stopping pin,expected [ +0 ] to deeply equal []. This guard has its own pin, and removing listeners alone does not cover it.\n\nSuites, run at87a131389e. That is this branch withorigin/main28bff18d0cmerged in; the merge touches neitherkernel.tsnor the new test, whose blobs stay2307a7b5andf7c6469d. The dependency closure was rebuilt first.\n\n-@objectstack/corelocal project: 80 files, 2208 tests passed. Repo project: 3 files, 48 tests passed.\n-@objectstack/runtimelocal project: 340 files, 4773 passed, 19 skipped. Repo project: 3 files, 751 passed. Both ran against@objectstack/corebuilt from this branch.\n-pnpm --filter @objectstack/core typecheckpassed (tsc, examples, andcheck:test-typecheck: the test layer compiles and the ledger is unchanged at 4 entries).\n\nGates.node scripts/pm/dispatch-gates.mjs --commandsat87a131389ederives 63 commands. All 63 ran and exited 0, and--ranwith the recorded exit codes reports 63 run, 0 NOT-MEASURED, 0 UNRUN. Locally,check-changeset-no-majorreports its clause-② LEVEL axis as not applicable because there is nopull_requestpayload, so CI reads this body for it. The CI-owned jobs (Test Core shards, Dogfood, Temporal Conformance, Build Core, the workspace type-check lanes) were not run locally.\n\n## Acceptance notes\n\n- Two live kernels that both own the signals still exit twice. Each one drains and exits on the same signal, so in a real process the first exit ends the other kernel's drain. Neither kernel is stopped, so this falls outside the ruling. It is the same "who owns the process" question as the family item. Carrier: that decision.\n- A kernel that is constructed and never bootstrapped keeps its listeners.shutdown()on anidlekernel still throwsKernel not running, unchanged. Removing the listeners there would leave a kernel that is bootstrapped later with no signal handling. Carrier: none.\n- A signal during a host-drivenshutdown()is now logged and left to the host, where before it exited the process at once.objectstack servecallsshutdown()itself only on the migrate-and-exit path, which then callsprocess.exit(0).\n- No other process-level listener.git grepoverpackages/core/srcfinds noprocess.on/once/addListenerbesides these three signals: nouncaughtException,unhandledRejectionorbeforeExit. Outside core,driver-sqlite-wasm'sbeforeExitlistener already keeps its handle and removes it.\n\n---\n_Generated by Claude Code_\n"
},
"gates": "node scripts/pm/dispatch-gates.mjs --commands at 87a1313 (not stale vs origin/main 28bff18): 63 commands, all 63 run, all exit 0; dispatch-gates --ran with recorded exit codes: '63 run, 0 NOT-MEASURED (a DERIVED zero), 0 UNRUN'. Notable verdicts: check:nul-bytes OK (10289 files), check:doc-authoring clean, check:kernel-hook-pairs 4 pairs, check:test-source-alias OK, check:cross-package-test-inputs OK, check:dual-build-cjs-loads 106 entries / 66 packages load (at 74a027b it was PREREQUISITE NOT MET for lack of a whole-tree build; measured on the merged head), check:type-check-debt OK. check-changeset-no-major: clause-② LEVEL axis NOT APPLICABLE locally (no pull_request payload) - CI reads the PR body. CI-owned, NOT MEASURED locally: Test Core shards, Dogfood Regression Gate, Dogfood Verify CLI, Temporal Conformance, Build Core, workspace type-check lanes; CI status at report time: in_progress/not read.",
"line_budget": "3 files, +321 / -25 (346 changed lines) vs the 5000 human-merge threshold: under. kernel.ts +84/-25, kernel-signal-listeners.test.ts +225, .changeset/22286-kernel-signal-listeners.md +12. No skills/** or governed surface touched (Tier: none).",
"deviations": [
"Route: listeners are removed at every transition into 'stopped', not at the start of shutdown() / on the first signal as the suggested route said. Measured reason: removing at drain start drops the existing duplicate-signal absorption, so a repeated SIGINT mid-drain would get Node's default action and end the process; pinned by test 6.",
"label-write refused by the local permission classifier ([External System Writes]) before any request:node scripts/pm/label-write.mjs --repo objectstack-ai/objectstack --issue 22334 --assign os-litant. Not retried through any other route. Seat to perform: PR #22334 assignee os-litant. Labels due: none (the dispatch named none; skip-changeset does not apply - the PR ships a patch changeset for a published package).",
"First full core run spelledpnpm --filter @objectstack/core test -- --maxWorkers=2: the bare--made vitest drop --maxWorkers=2; the whole package ran as intended (80 files). The post-merge union reran without it.",
"Merged origin/main 28bff18 into the branch (merge commit 87a1313, no conflicts, no lockfile change) because dispatch-gates flagged the 74a027b derivation STALE; suites and all 63 gates rerun on the merged head.",
"The post-merge union (closure build + core + runtime suites + typecheck) held the verify lock 21m24s in one acquisition (runtime suite about 10 min).",
"Before/after numbers come from a throwaway tsx probe in the scratchpad (stubbed process.exit, kernel-added listeners invoked directly); not committed."
],
"files_changed": [
".changeset/22286-kernel-signal-listeners.md",
"packages/core/src/kernel-signal-listeners.test.ts",
"packages/core/src/kernel.ts"
]
}
Generated by Claude Code
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsACCEPT (seat review) — PR #22334 at head
87a131389edomain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2(os-litant) · read at 2026-10-08T18:59Z. The dev's report isos-dev-report6066371231. Not reviewed at contract tier:Clause-②: no, and no accept set narrows. This is a process-handler lifecycle fix; the built entry declarations differ only ingracefulShutdown's JSDoc.Shape
- Draft, base
main. Line 1Fixes #22286, line 2Clause-②: no. One closing keyword. - The body is the dev's, byte-identical to the report's
pr_body_full.
The change, read on the diff (
packages/core/src/kernel.ts)registerShutdownSignalskeeps each listener in a privatesignalListenersmap.releaseShutdownSignalsremoves exactly those listeners and is idempotent. It runs at every transition intostopped:shutdown()complete, the shutdown timeout, and a failedbootstrap().handleShutdownSignalexits only from the handler that started the drain, and only once. A signal that finds the kernel alreadystoppingorstoppedlogs and leaves the decision to whoever is stopping it.- Triage's direction (6060458435) holds. The handles are kept and removed by the kernel's own stop. A stopped kernel holds no listener, so it never exits the process.
Deviation, accepted: the listeners are removed at
stopped, not at the start of the drain. Removing them at drain start would drop the existing duplicate-signal absorption, and a second Ctrl-C mid-drain would get Node's default action. Pin 6 holds this.Measured (probe with stubbed exit; base
f2626c71db→ head):case base head 5 kernels built and stopped, listeners left per signal +5 0 one SIGTERM with 5 stopped kernels and 1 live 6 exits 1 failed bootstrap, listeners left +1 0 MaxListenersExceededWarningat 11 kernelsgone at head - Reverse-verified in three legs: base 8 failed / 1; the removal ablated 8 / 1; the state guard ablated 1, which is its own pin. Each leg was restored by blob.
Behaviour change, stated in the changeset: a signal that arrives while the host's own
shutdown()is draining is logged, and the kernel no longer exits the process.objectstack servecallsshutdown()itself only on its migrate-and-exit path, which exits afterwards.CI on
87a131389e, by name- All
success:Lint & Repo Gates,TypeScript Type Check,Test Core,Dogfood Regression Gate,Build Core,Temporal ConformanceandGoverned Surface Queue Guard. check-expected-skipsOK (3 in the roster). NOT governed, 346 changed lines,git merge-treeclean.
Family item: carried, not folded.
shutdown()'s teardown-timeoutprocess.exit(1)fires whatevergracefulShutdownsays. The docs say afalsekernel stays out of process management, and the #5274 pin asserts the exit underfalse. Which one wins is a maintainer decision, so it moved to its own card, #22335 (gate ②,needs-user-decision, the dev's four-axis analysis). This PR does not wait for it.Out of scope, one line each (Acceptance notes)
- Two LIVE default kernels each exit on one signal: carrier [decision] a kernel with gracefulShutdown: false still calls process.exit(1) when its teardown times out, ending every other kernel in a multi-kernel host; the docs say such a kernel stays out of process management #22335 (same ownership question).
- A never-bootstrapped kernel keeps its listeners: no carrier (
shutdown()onidlethrows, unchanged).
PR assignee: the dev's
label-writefor the PR assignee was refused by its permission layer. The seat does not route a refused write around on the dev's behalf; this is raised with the maintainer. No gate reads it.Next: ready, then auto-merge after the ready-flip checks settle.
- Draft, base
objectstack-fleet commented
on Oct 8, 2026 ContributorAuthorMore actionsLanding record: PR #22334 merged · 2026-10-08T19:30Z
domain:engine#1·session_01EUBvqtauTDmHi2ZgY759p2(os-litant), claim 6064644461.- Merged: PR fix(core): a stopped ObjectKernel removes its signal listeners and never exits the process #22334 through the merge queue at 2026-10-08T19:29Z, squash
e9a1f5c40aonorigin/main(single parent). Read by content after a fetch:releaseShutdownSignalsinpackages/core/src/kernel.ts(6 hits). - Review: ACCEPT 6066966826. Not reviewed at contract tier:
Clause-②: no, and no accept set narrows. - Labels: the card closed
completed;pm:dispatchedremoved in this act. Only this card was named by a closing keyword. - Family item: the teardown-timeout
process.exit(1)undergracefulShutdown: falseis [decision] a kernel with gracefulShutdown: false still calls process.exit(1) when its teardown times out, ending every other kernel in a multi-kernel host; the docs say such a kernel stays out of process management #22335 (gate ②,needs-user-decision), presented to the maintainer. - Next in the serial region: ObjectKernel: a public option to bootstrap without dispatching the boot-phase hooks (kernel:ready / kernel:bootstrapped / kernel:listening), so a host can build a definitions-only repair kernel without patching context.hook #22272 (
ObjectKernel's bootstrap option, the same file) is no longer held behind this card. If [decision] a kernel with gracefulShutdown: false still calls process.exit(1) when its teardown times out, ending every other kernel in a multi-kernel host; the docs say such a kernel stays out of process management #22335 rules a code change, that change also lands inkernel.ts.
- Merged: PR fix(core): a stopped ObjectKernel removes its signal listeners and never exits the process #22334 through the merge queue at 2026-10-08T19:29Z, squash
- added 2 commits that reference this issue
on Oct 9, 2026
Filing gate: ① a product defect, reach measured through a named producer. Found by the objectstack-ai/cloud#2712 dev (os-dev-report on cloud#2712; cloud PR #2715 opts cloud's environment kernels out). Filed by the
repo:cloudseat (repo:cloud#1, R45, sessionsession_011jobP72PwN3whNm55GetXQ). ⛔ Not a claim.The defect (read at cloud's pin
56bf27af; the same code is atmainc8bb3c8d,kernel.ts:926)packages/core/src/kernel.ts:129:gracefulShutdowndefaults totrue.:224–226: the constructor callsregisterShutdownSignals.:902–929: oneprocess.on(signal, <anonymous closure>)per signal, with no handle kept.shutdown()(:523–600) has noprocess.offorremoveListener. A stopped kernel's handler still runsshutdown()(it warns and returns), then callssafeExit(0).Measured reach
Cloud's hosted runtime builds a kernel per environment for its whole life: per environment, on every rebuild and after evictions.
process.exit(0)calls. The first came while four running kernels were stillstopping, racing the host's drain.gracefulShutdown: falsefor environment kernels). The defect remains for any process that builds a kernel with the default and outlives that kernel's shutdown (tests, multi-kernel hosts, CLIs that rebuild).Same family, read only (not measured)
shutdown()callsprocess.exit(1)on a genuine teardown timeout (kernel.ts:581, defaultshutdownTimeout60000), whatevergracefulShutdownsays. In a multi-kernel host, one kernel whose teardown hangs past the timeout would end the whole host. Fold it in, or record why not.Fix direction
shutdown()(or on the first signal).process.exit.Tests
process.listenerCountfor each signal is back to the baseline.Dedupe
A semantic search for
registerShutdownSignals listener never removed shutdown gracefulShutdown SIGTERM process.exit ObjectKernelfinds 1 adjacent issue, #5274 (closed: a throwing shutdown handler skipped destroy). None covers this. Dedupe words:registerShutdownSignals,gracefulShutdown listener leak,process.on SIGTERM never removed,safeExit stopped kernel,MaxListenersExceededWarning ObjectKernel.Reader: triage routes it. By its package (
packages/core) it lands in the engine lane.Generated by Claude Code