fix(detection): kill leaked subprocess on task timeout, remove dead scan - #20
Conversation
_execute_command ran the claude CLI via asyncio.create_subprocess_exec +
asyncio.wait_for(process.communicate(), timeout=...). wait_for only
cancels the await, not the child process, so on timeout the claude CLI
(and any MCP servers it spawned) kept running unkilled. The synchronous
equivalent in guardrail/adr_agent/adr_baseline.py already gets this
right via subprocess.run(..., timeout=...), which kills the child on
TimeoutExpired - this mirrors that same kill-and-reap behavior for the
async path.
Also removes a dead computation in execute_ads_task: existing_sessions
was built via a full recursive rglob("*.jsonl") over the host-wide,
ever-growing ~/.claude/projects/ directory on every single task, then
passed into _process_results, which never referenced it. Confirmed via
grep - pure wasted I/O, safe to delete along with the now-unused
parameter.
Added TestTaskExecutorExecuteCommand covering the timeout-kill behavior
(verified by temporarily reverting the fix and confirming the new test
fails against the original code - the leaked process's returncode was
still None after the call), the success path, and non-zero exit
handling.
Fixes uber#18
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
pengyuzhang
left a comment
There was a problem hiding this comment.
Thanks for this — and for issue #18, which was accurate on both counts. I verified the PR beyond the included unit tests, and the results support the fix but surfaced one behavior that I think needs a small change before merge.
Verified locally (macOS, Python 3.12): 56 tests pass (incl. the 3 new ones); the dead existing_sessions scan is fully removed (no dangling references, single _process_results call site updated); clean merge against current main.
Differential harness. I ran the same scenario against main and this branch: TaskExecutor._execute_command with a 2s timeout, where the child spawns a grandchild subprocess (stand-in for an MCP server), then probing both PIDs after the call returns.
| variant | returns after | child alive | grandchild alive |
|---|---|---|---|
main |
2.0s | yes (leak — issue #18 confirmed) | yes |
| PR #20, grandchild inherits stdio | 120s | no ✅ | no (died naturally at 120s) |
| PR #20, grandchild stdio → devnull | 2.0s | no ✅ | yes (leaked) |
Two findings:
1. The kill works, but doesn't reach grandchildren. process.kill() signals the claude CLI only; anything it spawned survives (devnull row). That's the "and any MCP servers it spawned" half of issue #18 — still open after this PR. Not a regression (the subprocess.run(timeout=) pattern in adr_baseline.py has the same limitation), but worth fixing while we're here.
2. await process.wait() can block far past the timeout — potentially forever. asyncio's subprocess transport doesn't report completion until the stdio pipes close, and a grandchild that inherited the child's stdout/stderr holds them open. In the harness this blocked _execute_command for the grandchild's full 120s lifetime; a long-lived orphan would block indefinitely, wedging one of the max_concurrent slots. main never had this failure mode (it leaked, but returned promptly). The new unit tests can't catch it because the test child spawns no grandchild.
Severity caveat on (2): MCP servers spawned over stdio transport get their own pipes and typically don't inherit the CLI's stdout, so the hang needs a descendant with inherited stdio (e.g. a backgrounded shell command run by the agent). Plausible in a benchmark executing arbitrary agent actions, not guaranteed.
Suggested change — bound the wait, and sweep the whole process group so both findings are closed at once:
process = await asyncio.create_subprocess_exec(
*cmd, ..., start_new_session=True)
...
except asyncio.TimeoutError:
if process is not None and process.returncode is None:
try:
os.killpg(process.pid, signal.SIGTERM) # reaches MCP servers too
except ProcessLookupError:
pass
try:
await asyncio.wait_for(process.wait(), timeout=5)
except asyncio.TimeoutError:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
return False, "Task execution timed out", Nonestart_new_session=True puts the CLI and all its descendants in one process group; SIGTERM first gives the CLI a chance to shut its MCP servers down cleanly, with SIGKILL escalation after 5s. Killing the group also closes the inherited pipe FDs, which makes the indefinite wait() impossible. A test for the grandchild case would be a good addition — same spying pattern as test_kills_process_on_timeout, with the test child spawning its own sleep subprocess.
Happy to re-verify with the harness once updated — it's quick to re-run. The existing_sessions removal half of the PR is ready as-is.
…ect child Addresses review feedback on PR uber#20 from @pengyuzhang, who ran a differential harness (child spawning a grandchild subprocess, probing both PIDs after _execute_command returns) and found two real gaps in the previous kill-on-timeout fix: 1. process.kill() only signals the claude CLI itself - any descendant it spawned (e.g. an MCP server) survives. 2. await process.wait() can block far past the configured timeout - potentially indefinitely - if a descendant inherited the CLI's stdout/stderr pipes and holds them open, since asyncio's subprocess transport only reports completion once those pipes close. Measured at 120s in the review's harness for a 2s timeout; an unbounded orphan would wedge one of the max_concurrent execution slots forever. Fix, per the review's suggested approach: start_new_session=True puts the CLI and everything it spawns in one process group. On timeout, signal the whole group - SIGTERM first for a clean shutdown, bounded wait, SIGKILL escalation if it hasn't exited within the grace period. Falls back to killing just the direct child on platforms without process-group support (os.killpg), since start_new_session and os.killpg are POSIX-only - this benchmark harness has no other Windows-specific handling, but the fallback keeps the code from raising if it's ever imported there. Added test_kills_grandchild_process_holding_inherited_stdio, reproducing the review's exact scenario: a child that spawns its own child without redirecting stdout/stderr, so the grandchild inherits the same pipes asyncio set up for the direct child. Asserts the call returns promptly (not hanging toward the grandchild's full sleep duration) and that both processes are actually gone afterward, polling via os.kill(pid, 0) rather than a single check to avoid a false negative against a not-yet-reaped zombie. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-code' into fix/benchmark-subprocess-leak-dead-code
|
Thanks for the thorough review and for actually running a differential harness, that's exactly the kind of verification I should've done myself for the descendant-process case. Implemented your suggested fix as-is ( Added Pushed as a new commit ( Ready for re-verification against your harness whenever you get a chance. |
pengyuzhang
left a comment
There was a problem hiding this comment.
Approving — re-verified c1a08f3 with the same differential harness from the previous review. Both findings are closed:
| scenario | main | previous rev | this rev |
|---|---|---|---|
| pipe-inheriting grandchild — return time | 2s | 120s (blocked) | 2.0s ✅ |
| pipe-inheriting grandchild — leaked | child+grandchild | none | none ✅ |
| devnull grandchild — return time | 2s | 2s | 2.0s ✅ |
| devnull grandchild — leaked | child+grandchild | grandchild | none ✅ |
Prompt return at the timeout and a fully swept process tree, in both stdio configurations.
The implementation covers the details I'd have asked about: platform-gated start_new_session, SIGTERM → bounded 5s wait → SIGKILL escalation, both ProcessLookupError races handled, Windows fallback documented, and the comment block accurately explains the pipe-hold/transport mechanism for future readers. uv run pytest tests/ -q → 57 passed; the new grandchild-stdio test pins exactly the scenario the earlier revision couldn't see.
Known residual (non-blocking, same as main): a descendant that double-forks into its own session escapes killpg; nothing at this layer can catch that, and return remains prompt either way. Windows fallback path is untested here (macOS/Linux only) but is identical to the previous revision's behavior.
Thanks for the fast, complete turnaround — including the regression test.
What type of PR is this? (check all applicable)
Related issue: Closes #18
What changed?
Two related fixes in
Detection/main_benchmark.py,TaskExecutor:_execute_commandnow kills and reaps (process.kill()+await process.wait()) theclaudeCLI subprocess whenasyncio.wait_fortimes out, instead of leaving it running.asyncio.wait_foronly cancels the await, not the child — this mirrors the kill-on-timeout behaviorsubprocess.run(..., timeout=...)already provides for the synchronous CLI invocation inguardrail/adr_agent/adr_baseline.py.existing_sessionscomputation inexecute_ads_task— a full recursiverglob("*.jsonl")over the host-wide, ever-growing~/.claude/projects/directory, run once per task, whose result was passed into_process_resultsbut never referenced there (confirmed via grep). Removed the now-unused parameter too.Why?
At benchmark scale (hundreds of tasks per the README's 303 tasks / 133 MCP servers), every timeout leaked a process tree, and the dead scan added wasted I/O to every single task regardless of whether it timed out.
How did you test it?
Added
TestTaskExecutorExecuteCommandtotests/test_main_benchmark.py(previously no coverage for this method): timeout-kill behavior, the success path, and non-zero-exit handling. To confirm the timeout test actually catches the original bug (not just passing trivially), I temporarily reverted only the fix, kept the new test, and reran — it failed with the leaked process'sreturncodestillNoneafter the call, then passed again once the fix was restored.Ran
pytest tests/test_main_benchmark.py -v:13 passed — click to expand
Potential risks
Low.
process.kill()is only reached in the timeout branch, which previously did nothing to the process — this can only reduce leaked processes, not change any success-path behavior._process_results' signature change has a single call site (verified by grep), updated in the same commit.