fix(sweep): reap the whole MCP-launcher orphan tree, not just its root - #8304
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause fix with a correct, deliberately ordered kill sequence; the fail-closed identity discipline is proportionate to the SIGKILL-a-live-process harm it prevents. Watch
Suggestions
[DESIGN-REVIEWED] ecf1358 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All verification complete. The shipped walk reuses the existing First-Principles-Verdict: CONCERNS Every item traces to the counted 112-process/15.2 GB leak, but the description narrates an implementation that did not ship, and the same recycle hole stays open in the work-class sibling. What this change shipsIntent: stop orphaned MCP launcher trees from leaking forever behind an unmarked intermediate. FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] ecf1358 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
5014ac9 to
6c621ec
Compare
6c621ec to
23020ba
Compare
|
Recycled descendant PID can kill a live worker — span=99fb501fe250 —
Legitimate and reachable. Fixed as suggested, and then one step further, because pinning the token at enumeration alone still left a window.
The window your fix names is not the only one: Pinned by |
|
Correct, and worse than advisory in this context: an incomplete child set means silently dropped descendants, which is the exact bug class this PR exists to close. On a kernel without
Pinned by |
|
Taken in full, including all three subtractions. Every one of them removed code:
Consequence: all three Two further subtractions of the same kind, from your "unreachable production branches" note: the helper's The pid-safety guards ( One thing your review did not reach, found while fixing this: the once-per-sweep map is a snapshot reused across candidate roots, so an edge can be stale when the walk reads it. Each child's live PPid is now re-verified against the parent it was traversed from, taken from the same |
23020ba to
553d577
Compare
|
Tree scan makes the root PID check stale — span=99fb501fe250 —
Legitimate, reachable, and mine: the cmdline re-read sat adjacent to the signal while the branch only called This span has now been raised three times, so this round is a restructure rather than a third point-fix. Each instance named a different PID this sweep can signal after its identity went stale — descendants, then the descendants' parent edges, now the root. Patching a third instance invites a fourth, so the rule is stated once and applied to every signal site:
The marked-MCP branch has exactly three signal sites, and all three now satisfy it:
I took token and PGID as you asked, and deliberately not the cmdline re-read: once the start token matches it is provably the same process, so its argv cannot have changed, and Pinned by Five existing |
553d577 to
6062270
Compare
6062270 to
85f3aa2
Compare
|
Root identity is captured after its stale cmdline — span=99fb501fe250 —
Taken in full. Before the root signal the full evidence set is revalidated, not just the token: The residual is now revalidate → Pinned by |
|
Root death before the subtree budget is exhausted strands survivors — span=99fb501fe250 — (Second of the two blocking findings on that head. They share a span id, so this is a separate record carrying the separate rationale.)
This is the best finding on the PR: my ordering re-created the exact leak the PR exists to close. The root was signalled first, the descendants got whatever budget remained, and once the tree exceeded Both halves applied. Descendants are reaped first, so they get the whole remaining budget instead of That reframes the group signal as belt-and-braces for anything still sharing the root's group, which is the honest description — a Pinned by |
85f3aa2 to
120670a
Compare
The orphan sweep reclaimed the marked launcher at the top of an orphaned MCP
tree and relied on surviving children reparenting to init to become candidates
themselves on a later pass. That fallback breaks on an UNMARKED intermediate:
it is a candidate but not sweepable, so it lives forever AND hides its own
marked children behind a ppid that is not init, where `_our_orphan_pids` never
enumerates them. A `setsid`-ing launcher escapes the `killpg` fast path the
same way -- its payload lands in a new process group.
Observed shape, produced by any launcher wrapper that resolves a package and
then execs the resolved binary:
<wrapper> mcp start-server <pkg> <- marked, swept
-> <wrapper> mcp start-server ... <- marked, swept
-> node .../bin/<pkg>-server <- UNMARKED, leaked
-> npm exec <pkg>@latest <- marked but unreachable
One host accumulated 112 such processes holding 15.2 GB RSS over 23 days of
sweeps that were running the whole time, enough to pin the agents slice at its
memory.high ceiling and make fresh sessions miss their 30s `initialize`
deadline.
`kill_orphan_mcps` now enumerates the subtree and reaps it leaf-first, the same
treatment `_kill_orphan_work_tree` already gives the work class.
Enumeration walks `_build_child_map` -- the authoritative `stat` PPid scan this
module already uses -- not `/proc/<pid>/task/*/children`. That map's docstring
records why: the `children` file needs
`CONFIG_CHECKPOINT_RESTORE`/`CONFIG_PROC_CHILDREN` and is reliable only for
frozen/stopped tasks, so on a live task it can return an incomplete child set
and silently drop whole subtrees -- precisely the leak being fixed, so reaping
through it could no-op with no signal. The map is built at most once per sweep,
and only when a marked orphan is actually confirmed. The walk is ITERATIVE: a
chain deeper than Python's recursion limit would raise `RecursionError`, which
the caller's `except` clause does not name and which fires before any signal --
aborting the whole sweep, every cycle, and preserving the tree being reclaimed.
The root is the HANDLE on the tree -- it is marked and sweepable, so while it
lives the whole tree stays re-enumerable on a later sweep. So the subtree is
reaped first, and when it spends the whole `_ORPHAN_SWEEP_MAX_KILLS` budget the
root is deliberately left ALIVE and unsignalled.
Killing the root first is what loses the handle: with the tree over the cap the
survivors can include the UNMARKED intermediate, which reparents to init, is not
sweepable, and hides its marked children behind a non-init ppid -- re-creating
the exact leak this function exists to close. The ordering is load-bearing, not
stylistic.
Reaping a tree means signalling several PIDs, and any of them can be recycled
between validation and signal. The rule is stated once and applied to every
signal site:
No signal reaches a PID whose start identity was not captured BEFORE any
other read about that PID, and re-confirmed IMMEDIATELY before the signal.
"Before any other read" is the load-bearing half. The cmdline, the eligibility
verdict and the pgid are all evidence about whichever process held the PID when
each was read, so a capture placed after any of them leaves a window where the
orphan exits, the PID is reused, and that stale evidence licenses signalling the
replacement -- with both token reads agreeing, because both saw the replacement.
The capture is therefore the first thing this loop does with a PID.
The three signal sites, and how each satisfies it:
1. `os.killpg(pgid)` -- the root's group. Token captured first; before the
signal the FULL evidence set is revalidated: token, eligibility
(`_is_sweepable_orphan_mcp` re-run on a fresh argv, since the token proves
the process but not that its argv still qualifies), and the pgid (a process
can leave the group this `killpg` targets).
2. `os.kill(pid)` -- the root PID. Same guard, same placement.
3. `platform_compat.kill_pid(target)` -- each descendant. Token pinned at
enumeration and re-confirmed at the kill, AND the snapshot is not trusted
as ground truth: `child_map` is reused across candidate roots, so an edge
can already be stale when the walk reads it. Each child's live PPid must
still equal the parent it was traversed from, with the PPid and the token
taken from ONE `stat` read -- reading them separately would pair a dead
process's parent edge with a recycled PID's fresh token.
A token missing on either side is unproven identity, so the PID is skipped
rather than signalled -- never treated as a mismatch, matching
`_pid_start_token`'s documented contract -- and is re-reaped next sweep, logged
at debug so a host where identity never resolves is diagnosable rather than a
silent no-op.
The walk PRUNES a gateway/CLI entrypoint together with its whole subtree.
Excluding only the entrypoint's own PID would not be enough -- the walk is flat,
so a peer gateway's live workers would still be enumerated, and each carries
`KIROCREW_SPAWNED` with no gateway marker in its own argv, so each would pass
the per-member gate and be SIGKILLed, crashing that pod's active sessions.
Each member additionally needs `KIROCREW_SPAWNED` in its exec-time environ, must
not be self / the group leader / pid <= 1, and an unreadable cmdline is skipped
rather than killed. Every kill is SEL-audited.
`_pid_cmdline` is Linux-only with no `ps` branch: every consumer of its argv
feeds a decision that also requires `_env_has_kirocrew_marker`, which is
fail-closed off Linux, so a subprocess there would only supply evidence for a
verdict that is already "refuse". The subtree reap is therefore a no-op off
Linux, matching the existing work-class floor, and this adds no BENIGN_SPAWNS
entry.
55 in test/test_orphan_mcp_subtree.py, mutation-verified in eighteen
directions. Removing the reap fails 5; deferring the descendants past the root
fails 8; moving the token capture back after the cmdline read fails 25; the
gateway-subtree prune fails 2; the descendants' live parent-edge check fails 3;
the root's recycle guard fails 3; and dropping the budget-spares-root rule, the
root's eligibility revalidation, the root's pgid re-check, the descendant token
comparison, either unproven-identity branch, the wrong `stat` field, the prune's
fail-closed branch, the preorder push order, the leaf-first order, or the
build-once map each fail 1 -- while removing the cycle guard hangs the walk.
Capture ordering is additionally pinned by asserting the sequence directly.
Five existing `kill_orphan_mcps` tests gain an autouse fixture supplying a
stable start token: they drive synthetic PIDs with no `/proc` entry, so the new
root guard correctly refuses to signal them, and a stable token states the
precondition they already assumed. `test_pid_lifecycle.py` is now black-clean,
so its baseline entry is pruned.
120670a to
ecf1358
Compare
Problem / Motivation
The orphan sweep reclaims the marked MCP launcher at the top of an orphaned tree, then relies on surviving children reparenting to init so they become candidates themselves on a later pass. That fallback breaks on an unmarked intermediate: it is a candidate but not sweepable, so it lives forever and hides its own marked children behind a ppid that is not init, where
_our_orphan_pidsnever enumerates them. Asetsid-ing launcher escapes thekillpgfast path the same way — its payload lands in a new process group.Observed shape, produced by any launcher wrapper that resolves a package and then execs the resolved binary:
One host accumulated 112 such processes holding 15.2 GB RSS over 23 days, with the sweep running the whole time.
Why it matters
That backlog was enough to pin
kirocrew-agents.sliceat itsmemory.highceiling. The kernel then throttled the whole subtree and every freshly-spawned adapter missed its 30sinitializedeadline, so the leak surfaced as repeatedRequest initialize timed out after 30son unrelated sessions — a symptom with no visible connection to its cause. Any host whose MCP launcher nests more than two levels deep accumulates the same debt silently.Worth stating what is not broken: the probe's own teardown is correct.
mcp_discoverykillpg's the whole probe group on timeout, verified live — orphans killed by hand respawned and were reaped within two minutes. The leak comes from paths that skip thatfinallyentirely: the gateway'sos._exit(0)force exits and any SIGKILL, including a cgroup OOM-kill. Orphan ages cluster in groups of 2–6, matchingPROBE_MAX_CONCURRENCY = 5— each death stranded one in-flight probe fan-out.What changed (motivation → approach → change)
Symptom: a marked orphan that
_our_orphan_pidscan never enumerate. Root cause: the sweep treats a launcher tree as a single PID, so reclaiming the root is where it stops; the documented "reclaimed on a subsequent sweep" fallback silently assumes every surviving descendant reparents to init, which an unmarked intermediate prevents.kill_orphan_mcpsnow enumerates the subtree before signalling the root (once it dies the/procparent links this walk needs are gone) and kills leftover members leaf-first — the same treatment_kill_orphan_work_treealready gives the work class, so this is an existing pattern extended to a second class rather than a new mechanism.The root passing the sweep gate does not license killing arbitrary descendants, so identity is established twice over:
KIROCREW_SPAWNEDwith no gateway marker in its own argv — so each would pass the per-member gate and be SIGKILLed, crashing that pod's active sessions. An unreadable argv prunes too: a process whose identity cannot be established is not a case for descending.KIROCREW_SPAWNEDin its exec-time environ, not a_GATEWAY_MARKERSentrypoint, never self / group leader / pid <= 1 — and an unreadable cmdline is skipped rather than killed.Members count against the same
_ORPHAN_SWEEP_MAX_KILLSbudget as roots, so a large backlog drains over several cycles instead of one unbounded burst, and the kill is SEL-audited (orphan_mcp_sweep/mcp_subtree).The descendant walk is a local
/procread (_direct_child_pids) rather than an import of the ACP layer's equivalent:check_agent_sdk_boundary.pyoffers no opt-out marker and its baseline only shrinks, so a sweep here must not grow an ACP-layer edge._kill_orphan_work_treekeeps its own baselined import untouched.Linux-only in effect, since
_env_has_kirocrew_markeris fail-closed elsewhere — matching the existing work-class floor. Windows is an explicit no-op: the tree kill after a session ends already went throughtaskkill /T.Tests
26 in
test/test_orphan_mcp_subtree.py:KIROCREW_SPAWNEDis spared; a_GATEWAY_MARKERSentrypoint is spared._ORPHAN_SWEEP_MAX_KILLScap with roots.kill_pidfailure not counted, SEL audit emitted only when something dies.Mutation-verified in five directions: removing the reap fails 5, removing the gateway-subtree prune fails 2, flipping the prune's fail-closed branch fails 1, removing the cycle guard fails 1, reversing the leaf-first order fails 1.
_direct_child_pids,_pid_cmdlineand_kill_orphan_mcp_descendantsare added toBENIGN_SPAWNSalongside their three sibling sweep functions (_our_orphan_pids,find_orphan_mcp_candidates,kill_orphan_mcps) — same justification: the argv is a fixedps/pgrepprobe with a PID as its only variable, never agent-influenced.Manual verification
Verified against the live leak on the reporting host: 112 orphans reclaimed by exact PID (~14.8 GB freed, load average 66 → 33), and the probe teardown confirmed still working — two trees respawned within 30s and were gone within two minutes, which is what ruled the timeout path out as the cause.
Local gates: all 34 backend gates green. Full backend suite 84,540 passed / 103 failed, none attributable —
AF_UNIX path too longfrom the worktree's long path, the 4-worker xdist budget, and the known pre-existingtest_host_isolation_floorset. Neithertest_pid_lifecyclenor the new file appears among them.Related Issues
no linked issue: found while diagnosing a live host, not filed first.
Pattern harvest
Rule candidate: review-prompt
Pattern: a reaper that excludes a process by matching its OWN argv, while iterating a flat descendant list, still reaps that process's children — the exclusion has to prune the subtree during traversal, not filter at the kill step.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)