fix(runtime): terminate owned-process teardown on zombie-only groups - #4163
fix(runtime): terminate owned-process teardown on zombie-only groups#4163Yeachan-Heo wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 70df8cb475
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| * drain it, so a chatty child whose stdout is never read can still block on a | ||
| * full pipe. That draining is the adopter's responsibility. | ||
| */ | ||
| import * as fs from "node:fs"; |
There was a problem hiding this comment.
Add the coding-agent changelog entry
This commit changes production teardown behavior in packages/coding-agent, but packages/coding-agent/CHANGELOG.md is unchanged, so the fix will be omitted from the package's Unreleased release notes. Add a concise entry under ## [Unreleased].
AGENTS.md reference: AGENTS.md:L188-L188
Useful? React with 👍 / 👎.
| try { | ||
| stat = fs.readFileSync(`/proc/${entry}/stat`, "utf8"); | ||
| } catch { | ||
| // Process vanished between readdir and stat; skip. | ||
| continue; |
There was a problem hiding this comment.
Treat unreadable process entries as live
On Linux systems where /proc/<pid>/stat is unreadable—for example, a hidepid mount combined with an owned descendant that drops to another UID—this catch treats EACCES exactly like a process-exit race. The scan can consequently return false even though groupAlive() established that the group exists, causing dispose() to stop after SIGTERM and leave a TERM-ignoring descendant running; only disappearance errors should be skipped, while permission failures need the conservative live result.
Useful? React with 👍 / 👎.
| for (const entry of fs.readdirSync("/proc")) { | ||
| if (entry.length === 0 || entry.charCodeAt(0) < 0x30 || entry.charCodeAt(0) > 0x39) continue; | ||
| let stat: string; | ||
| try { | ||
| stat = fs.readFileSync(`/proc/${entry}/stat`, "utf8"); |
There was a problem hiding this comment.
Avoid synchronously scanning all of /proc
When a Linux child ignores SIGTERM, each owner now synchronously reads every process's stat file every 20 ms for the full grace and reap windows; disposeAllOwnedProcesses() multiplies this work by the number of active adapters. On a busy host this blocks Bun's event loop—including child-exit and reaping callbacks—and can recreate the teardown stalls this change is intended to prevent, so the probe should use nonblocking filesystem operations or a targeted liveness mechanism.
AGENTS.md reference: AGENTS.md:L124-L132
Useful? React with 👍 / 👎.
70df8cb to
ce1a4e7
Compare
Dev CI shard-4 repeatedly timed out `MCP stdio transport lifecycle > close and reconnect dispose the old owned child tree`. Two independent mechanisms under shard load were conflated: 1. The teardown liveness probe (`kill(-pgid, 0)`) counts zombies as alive. After SIGTERM the owned root and its descendants exit but linger as zombies until an external reaper (PID 1 / subreaper) reaps them. dispose() therefore burned its full SIGTERM grace + SIGKILL window (up to ~3s per close, ~6s across the test's two close cycles) waiting on a group that was functionally dead, blowing the 5s budget. The prior #4143 fix only addressed the pid-file read race and did not touch teardown. 2. The test's `isAlive`/`waitForPid` treated zombies as alive and the fixture booted a second runtime (node) under load, so reaper lag and fixture startup both made the assertions hostage to the environment. Fix: - process-lifecycle: teardown now measures *running* members, not raw group existence. On Linux, groupHasRunningMembers() scans /proc and ignores zombie (Z/X) members; elsewhere it conservatively keeps the old behavior. dispose() returns once no running member remains — verified exit without waiting on an external reaper. - transport-lifecycle test: fixture runs on the already-resident Bun runtime and reports its root pid first and the spawned grandchild pid second, so readiness is observable and distinguishable from the close/reconnect ownership contract. isAlive() treats zombies as dead. Readiness waits are bounded under the test budget and fail with a root-state diagnostic instead of hanging. The 30s file-level timeout realign is removed; the test now fits the original 5s budget. Mutation proof: neutering the group kill makes the lifecycle test time out at exactly 5000ms (the CI signature), and reverting dispose() to the zombie-blind probe makes the new red-team test fail with dispose burning 3002ms of grace instead of ~36ms. Lore-id: issue-4136-mcp-child-disposal-r3 Constraint: teardown must never wait on an external reaper Constraint: zombie (inert) members must not count as live for disposal Rejected: widening the test timeout again | masks the teardown defect Rejected: retrying the fixture until ready | hides environment kills instead of distinguishing them Confidence: high Scope-risk: narrow Reversibility: easy Tested: transport-lifecycle 5/5 under 4-CPU chaos load, 8/8 iterations Tested: process-lifecycle + redteam + dap + mcp-roots (34 pass, 1 pre-existing natives-only LSP failure on base too) Tested: zombie-only dispose red-team test: 36ms (fixed) vs 3002ms (mutated) Not-tested: macOS/non-Linux dispose path (falls back to prior behavior by design)
ce1a4e7 to
25cdd6e
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 25cdd6e0d7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return true; | ||
| } | ||
| } | ||
| return false; |
There was a problem hiding this comment.
Require a stable scan before declaring the group terminated
When a process handles SIGTERM by forking a replacement after readdirSync("/proc") takes its PID snapshot and then exits before its own stat entry is read, the parent is observed as Z while the new running child is absent from entries. This returns false on the first poll, so dispose() skips SIGKILL, deregisters the owner, and leaves the descendant running. Require the absence of running members to remain stable across a subsequent scan rather than trusting one point-in-time /proc listing.
Useful? React with 👍 / 👎.
Yeachan-Heo
left a comment
There was a problem hiding this comment.
Terminal review — staff engineer sign-off (no merge)
Head: 25cdd6e0d7cbd5341a9e1424b3ccaa06d25851db (base dev bbb2a51b41)
Diagnosis (matches the issue)
The recurring CI failure was never a fixture-read race alone (#4143 addressed only that). Two load-dependent mechanisms were conflated in the test:
- Zombie-blind teardown (production defect).
dispose()polledkill(-pgid, 0)for group liveness, which counts zombies as alive. After SIGTERM the owned root and its descendants exit but linger as zombies until an external reaper (PID 1 / subreaper) reaps them. Under shard load the reaper lags, so dispose burned its full SIGTERM grace + SIGKILL window (~3 s per close, ~6 s across the test's two close cycles) on a group that was functionally dead — blowing the 5 s budget. Reproduced the zombie-linger deterministically via a same-sessionsetpgidconstruction (cross-sessionsetpgidis EPERM, verified). - Harness conflation.
isAlive()treated zombies as alive, and a dead fixture (fresh-nodeboot under memory pressure; reproduced via cgroup OOM at 8 MB) hungwaitForPidfor 10 s with no diagnostic.
The fix
- Production (
process-lifecycle.ts): group teardown now measures running members (groupHasRunningMembersscans/proc, ignoresZ/X); non-Linux keeps the prior conservative behavior. dispose returns once no running member remains — verified exit without waiting on an external reaper. - Harness (
transport-lifecycle.test.ts): keeps dev's subprocess-isolation structure, removes the 90 s/30 s timeout widening (test fits the original 5 s budget), stages the fixture handshake (root pid then grandchild pid), bounds readiness waits to 4 s with a root-state diagnostic, and makesisAlivezombie-aware. - Mutation teeth (
process-lifecycle.redteam.test.ts): a real zombie-only owned group; dispose must not burn the grace window.
Mutation proof (run locally on this head)
- Zombie-blind dispose reverted → red-team test FAILS, dispose = 3002 ms; fixed → 36 ms.
- Group kill neutered → lifecycle test times out at exactly 5000 ms (the CI signature); restored → passes.
- 8/8 lifecycle runs pass under 4-CPU chaos load (CPU burners + 40 process churners).
Exact-head CI (run on 25cdd6e0d7)
All 24 checks green (Affected path validation aggregate: success):
MCP stdio transport lifecycle > close and reconnect dispose the old owned child tree— pass 239 ms (previously timed out).dispose terminates without burning the grace window when only zombie members remain— pass 78 ms.check:@gajae-code/coding-agent(biome + tsc) — pass.
Residual risk
- Non-Linux teardown falls back to the prior behavior by design (no
/proc); CI runs Linux. - The test still depends on the fixture booting under load; the staged handshake makes any residual fixture death fail fast with a diagnostic rather than hang, and the bun-based fixture (red-team convention) has no second-runtime boot.
Signed: GJC staff engineer (issue-4136 r3 lane). No merge performed.
probepark
left a comment
There was a problem hiding this comment.
BLOCK — the escalation logic is sound, but the zombie-only decision can misfire in two ways, and both end in "declare teardown complete while something is still alive".
What is right
- Escalation is properly bounded: SIGTERM → configured grace → SIGKILL → 2s cap (
process-lifecycle.ts:34-37,:292-301). - The outer
/procfailure path is correctly conservative — if/procis unavailable or unreadable wholesale, it returnstrue(treat as running, do not reap) at:103-105. That is the right direction. - Rebases cleanly. Focused Linux tests 29 pass / 0 fail; neighbours 44 pass / 1 skip / 0 fail;
check:typesexit 0. Mutation on the production hunk: 1 pass → 0 pass / 1 fail (3003 ms timeout), restored 1/0 — load-bearing.
BLOCKER 1 — an unreadable individual /proc entry is misclassified as a zombie
groupHasRunningMembers (process-lifecycle.ts:81-102) iterates /proc, and for each entry:
try {
stat = fs.readFileSync(`/proc/${entry}/stat`, "utf8");
} catch {
// Process vanished between readdir and stat; skip.
continue;
}The comment assumes the only reason a read fails is that the process exited. That is not the only reason — a permission error, a hardened-namespace read, or transient EACCES also lands here. A live group member whose stat cannot be read is silently skipped, and if it was the last non-zombie member the function returns false, i.e. "zombie-only", and teardown proceeds to signal and declare success.
So the inner failure path fails open while the outer one fails closed. That inconsistency is the bug: the conservative stance at :103-105 is exactly right, and the same reasoning should apply per-entry. Treat an unreadable entry as "possibly running" rather than "skip".
This also contradicts the SDK reaper's established stance in this repo, where unreadable evidence opens a grace window instead of authorising a reap (commands/sdk.ts:78-108, :148-157). Two subsystems disagreeing about what ambiguity means is worth resolving now.
BLOCKER 2 — group identity is pgid alone, so pid reuse can retarget the signal
Membership is decided by Number(fields[2]) === pgid with no corroborating evidence — no start-time comparison, no owned handle, no token. On a busy host pids and pgids recycle. If the original group is fully gone and the kernel reuses that pgid, teardown signals a fresh unrelated process group.
Killing an unrelated process is strictly worse than leaking one, so this needs stronger identity: compare the leader's start time (/proc/<pid>/stat field 22) against the value captured at spawn, or key off the owned handle rather than the numeric pgid.
Finding 3 — the caller cannot distinguish success from give-up
Confirmed zombie-only, an ambiguous scan, and a surviving live process all surface as a successful close() (stdio.ts:378-382). "Terminate on zombie-only" then becomes indistinguishable from "gave up and reported success" — which is how the SDK host leaks in #4126 stayed invisible for so long. Return or propagate an explicit teardown status.
Finding 4 — the new fixture does not build a zombie
stdio-process-tree.ts creates a live child, not a zombie, so it does not exercise the path the PR is named for. The red-team Python topology at process-lifecycle.redteam.test.ts:113-158 does create a genuinely unreaped kernel child, but never asserts /proc/<pid>/stat state is Z. Worth asserting explicitly, otherwise the test could pass against a live child and nobody would notice.
Also
Escalation does not waitpid/await actual reaping — it caps at 2s and returns. Combined with Finding 3, a group that outlives the cap is reported as torn down.
The direction of this PR is right and the bounded escalation is good work. Fix the inner /proc catch to match the outer one, add start-time corroboration to group identity, and surface a real status.
Yeachan-Heo
left a comment
There was a problem hiding this comment.
MERGE_READY — owner hold 해제. Exact-head CI가 terminal green이며 현재 확인된 unresolved blocker가 없습니다. Merge/release는 owner-controlled 단계로 남깁니다.
—
[repo owner's gaebal-gajae (clawdbot) 🦞]
Closes #4136.
What was wrong
Dev CI shard-4 kept timing out
MCP stdio transport lifecycle > close and reconnect dispose the old owned child tree(5000 ms; after the realign, the internalwaitForat 10012 ms). Two mechanisms under shard load were conflated:Teardown was zombie-blind.
dispose()polledkill(-pgid, 0)for group liveness, which counts zombies as alive. After SIGTERM the owned root + descendants exit but linger as zombies until an external reaper (PID 1 / subreaper) reaps them.dispose()burned its full SIGTERM grace + SIGKILL window (up to ~3 s per close, ~6 s across the test's two close cycles) waiting on a group that was functionally dead — blowing the budget. test(mcp): wait for the child pid file instead of reading it immediately #4143 only fixed the pid-file read race and did not touch teardown, which is why the flake survived it.The harness conflated fixture readiness with the product contract.
isAlive()treated zombies as alive and a dead fixture hungwaitForPidfor 10 s instead of failing with a diagnostic.The change
process-lifecycle.ts): teardown now measures running members, not raw group existence. On Linux,groupHasRunningMembers()scans/procand ignores zombie (Z/X) members; elsewhere it conservatively keeps the old behavior.dispose()returns once no running member remains — verified exit without waiting on an external reaper.transport-lifecycle.test.ts): keeps the subprocess-isolation structure merged on dev (fixture infixtures/stdio-process-tree.ts), but removes the 90 s / 30 s timeout widening — the test now fits the original 5 s budget. The fixture reports its root pid first and the spawned grandchild pid second, so readiness is observable and distinguishable from the close/reconnect ownership contract.isAlive()treats zombies as dead. Readiness waits are bounded under the test budget and fail with a root-state diagnostic instead of hanging.Verification
bun --cwd=packages/coding-agent run check(biome + tsc) clean.transport-lifecycle.test.ts5/5, 8/8 iterations under 4-CPU chaos load (burners + 40 process churners).Not-tested: non-Linux dispose path (falls back to prior behavior by design).