Skip to content

Commit 5f5a74a

Browse files
Rahul-s-007claudepengyuzhang
authored
fix(detection): kill leaked subprocess on task timeout, remove dead scan (#20)
* fix(detection): kill leaked subprocess on task timeout, remove dead scan _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 #18 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(detection): kill whole process group on timeout, not just the direct child Addresses review feedback on PR #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> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Pengyu Zhang <zhangelsu@gmail.com>
1 parent 8f83e9a commit 5f5a74a

2 files changed

Lines changed: 183 additions & 11 deletions

File tree

Detection/main_benchmark.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
import asyncio
1010
import json
1111
import logging
12+
import os
1213
import random
1314
import re
1415
import shutil
16+
import signal
1517
import sys
1618
import time
1719
from datetime import datetime
@@ -675,11 +677,6 @@ async def execute_ads_task(self, task: Dict[str, Any], workspace_dir: Path,
675677
capabilities = config.get("capabilities", [])
676678
mcp_tools.extend([f"mcp__{server}__{tool}" for tool in capabilities])
677679

678-
claude_projects_dir = Path.home() / ".claude" / "projects"
679-
existing_sessions = set()
680-
if claude_projects_dir.exists():
681-
existing_sessions = {f.name for f in claude_projects_dir.rglob("*.jsonl")}
682-
683680
cmd = self.command_builder.build_claude_command(task, mcp_tools)
684681

685682
success, error_message, result = await self._execute_command(cmd, workspace_dir)
@@ -689,9 +686,7 @@ async def execute_ads_task(self, task: Dict[str, Any], workspace_dir: Path,
689686

690687
execution_time = time.time() - start_time
691688

692-
return await self._process_results(
693-
workspace_dir, existing_sessions, result, execution_time
694-
)
689+
return await self._process_results(workspace_dir, result, execution_time)
695690

696691
except Exception as e:
697692
print(f"❌ Error executing task: {e}")
@@ -781,12 +776,17 @@ async def execute_agentdojo_task(self, task: Dict[str, Any], output_dir: Path) -
781776

782777
async def _execute_command(self, cmd: List[str], workspace_dir: Path) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]:
783778
"""Execute Claude CLI command."""
779+
process = None
784780
try:
785781
process = await asyncio.create_subprocess_exec(
786782
*cmd,
787783
cwd=workspace_dir,
788784
stdout=asyncio.subprocess.PIPE,
789-
stderr=asyncio.subprocess.PIPE
785+
stderr=asyncio.subprocess.PIPE,
786+
# Puts the CLI and everything it spawns (MCP servers, etc.) in
787+
# one process group, so a timeout can signal all of them at
788+
# once instead of just the direct child. POSIX only.
789+
start_new_session=(os.name == "posix"),
790790
)
791791

792792
stdout, stderr = await asyncio.wait_for(
@@ -802,11 +802,48 @@ async def _execute_command(self, cmd: List[str], workspace_dir: Path) -> Tuple[b
802802
return True, None, result
803803

804804
except asyncio.TimeoutError:
805+
# asyncio.wait_for() only cancels the await - it does not touch the
806+
# child process. Killing just the direct child isn't enough either:
807+
# it doesn't reach any descendants the CLI spawned (e.g. MCP
808+
# servers), and if one of those descendants inherited the CLI's
809+
# stdout/stderr pipes, it can hold them open indefinitely -
810+
# asyncio's subprocess transport only reports completion once
811+
# those pipes close, so process.wait() could hang forever instead
812+
# of just leaking. Signal the whole process group instead: SIGTERM
813+
# first for a clean shutdown, then SIGKILL if it hasn't exited
814+
# within the grace period.
815+
if process is not None and process.returncode is None:
816+
await self._kill_process_tree(process)
805817
return False, "Task execution timed out", None
806818
except Exception as e:
807819
return False, str(e), None
808820

809-
async def _process_results(self, workspace_dir: Path, existing_sessions: set,
821+
async def _kill_process_tree(self, process: "asyncio.subprocess.Process", grace_period: float = 5.0) -> None:
822+
"""Terminate a process and everything it spawned.
823+
824+
Falls back to killing just the direct child on platforms without
825+
process-group support (e.g. Windows), matching create_subprocess_exec's
826+
start_new_session=(os.name == "posix") above.
827+
"""
828+
if not hasattr(os, "killpg"):
829+
process.kill()
830+
await process.wait()
831+
return
832+
833+
try:
834+
os.killpg(process.pid, signal.SIGTERM)
835+
except ProcessLookupError:
836+
return
837+
838+
try:
839+
await asyncio.wait_for(process.wait(), timeout=grace_period)
840+
except asyncio.TimeoutError:
841+
try:
842+
os.killpg(process.pid, signal.SIGKILL)
843+
except ProcessLookupError:
844+
pass
845+
846+
async def _process_results(self, workspace_dir: Path,
810847
result: Dict[str, Any], execution_time: float) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]:
811848
"""Process execution results using session ID from Claude CLI JSON output."""
812849
session_id = result.get("session_id")

Detection/tests/test_main_benchmark.py

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
"""Tests for main_benchmark helpers."""
22

3+
import asyncio
4+
import os
5+
import sys
6+
import time
37
from pathlib import Path
48
from unittest.mock import MagicMock
59

610
import pytest
711

8-
from main_benchmark import CommandBuilder, Config, MCPServerManager, TaskManager
12+
from main_benchmark import CommandBuilder, Config, MCPServerManager, TaskExecutor, TaskManager
913

1014

1115
class TestConfig:
@@ -101,3 +105,134 @@ def test_rejects_zero_concurrency(self):
101105
with pytest.raises(ValueError, match="max_concurrent_tasks must be >= 1"):
102106
if max_concurrent < 1:
103107
raise ValueError(f"max_concurrent_tasks must be >= 1, got {max_concurrent}")
108+
109+
110+
class TestTaskExecutorExecuteCommand:
111+
"""Covers _execute_command's process lifecycle, in particular that a timed-out
112+
claude CLI subprocess is actually killed rather than left running (previously
113+
asyncio.wait_for's timeout only cancelled the await, not the child process)."""
114+
115+
def _make_executor(self, tmp_path: Path, monkeypatch, max_execution_time: float) -> TaskExecutor:
116+
monkeypatch.chdir(tmp_path)
117+
config = Config()
118+
config._config["execution"]["max_execution_time"] = max_execution_time
119+
return TaskExecutor(config)
120+
121+
@pytest.mark.asyncio
122+
async def test_kills_process_on_timeout(self, tmp_path: Path, monkeypatch):
123+
executor = self._make_executor(tmp_path, monkeypatch, max_execution_time=0.05)
124+
125+
real_create_subprocess_exec = asyncio.create_subprocess_exec
126+
spawned = {}
127+
128+
async def spying_create_subprocess_exec(*args, **kwargs):
129+
process = await real_create_subprocess_exec(*args, **kwargs)
130+
spawned["process"] = process
131+
return process
132+
133+
monkeypatch.setattr(asyncio, "create_subprocess_exec", spying_create_subprocess_exec)
134+
135+
cmd = [sys.executable, "-c", "import time; time.sleep(5)"]
136+
success, error_message, result = await executor._execute_command(cmd, tmp_path)
137+
138+
assert success is False
139+
assert "timed out" in error_message.lower()
140+
assert result is None
141+
142+
# The process must have been killed and reaped, not left running in the
143+
# background after _execute_command returns.
144+
process = spawned["process"]
145+
assert process.returncode is not None
146+
147+
@staticmethod
148+
def _wait_until_process_gone(pid: int, timeout: float = 3.0) -> bool:
149+
"""Poll until a pid is fully reaped rather than asserting immediately -
150+
os.kill(pid, 0) on a not-yet-reaped zombie still succeeds, so a single
151+
check right after sending the kill signal can be a false negative."""
152+
deadline = time.monotonic() + timeout
153+
while time.monotonic() < deadline:
154+
try:
155+
os.kill(pid, 0)
156+
except ProcessLookupError:
157+
return True
158+
except PermissionError:
159+
return False
160+
time.sleep(0.1)
161+
return False
162+
163+
@pytest.mark.asyncio
164+
@pytest.mark.skipif(os.name != "posix", reason="process-group kill is POSIX-only")
165+
async def test_kills_grandchild_process_holding_inherited_stdio(self, tmp_path: Path, monkeypatch):
166+
"""A descendant that inherits the CLI's stdout/stderr pipes must also be
167+
killed on timeout - process.kill() alone only signals the direct child,
168+
and a surviving descendant holding those pipes open can block
169+
process.wait() indefinitely instead of just leaking (see PR #20 review)."""
170+
executor = self._make_executor(tmp_path, monkeypatch, max_execution_time=0.2)
171+
172+
real_create_subprocess_exec = asyncio.create_subprocess_exec
173+
spawned = {}
174+
175+
async def spying_create_subprocess_exec(*args, **kwargs):
176+
process = await real_create_subprocess_exec(*args, **kwargs)
177+
spawned["process"] = process
178+
return process
179+
180+
monkeypatch.setattr(asyncio, "create_subprocess_exec", spying_create_subprocess_exec)
181+
182+
pidfile = tmp_path / "grandchild.pid"
183+
# The direct child spawns its own child ("grandchild") without
184+
# redirecting its stdout/stderr, so it inherits the same pipes
185+
# asyncio set up for the direct child - reproducing the scenario
186+
# where a descendant keeps those pipes open past the timeout.
187+
script = (
188+
"import subprocess, sys, time\n"
189+
"gc = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)'])\n"
190+
"open(sys.argv[1], 'w').write(str(gc.pid))\n"
191+
"time.sleep(30)\n"
192+
)
193+
cmd = [sys.executable, "-c", script, str(pidfile)]
194+
195+
start = time.monotonic()
196+
success, error_message, result = await executor._execute_command(cmd, tmp_path)
197+
elapsed = time.monotonic() - start
198+
199+
assert success is False
200+
assert "timed out" in error_message.lower()
201+
202+
# Must return promptly, not hang for anywhere near the grandchild's
203+
# 30s sleep - proves process.wait() wasn't blocked on inherited pipes.
204+
assert elapsed < 10
205+
206+
process = spawned["process"]
207+
assert process.returncode is not None
208+
209+
for _ in range(30):
210+
if pidfile.exists():
211+
break
212+
await asyncio.sleep(0.1)
213+
assert pidfile.exists(), "grandchild never reported its pid"
214+
215+
grandchild_pid = int(pidfile.read_text())
216+
assert self._wait_until_process_gone(grandchild_pid), "grandchild process was left running"
217+
218+
@pytest.mark.asyncio
219+
async def test_returns_parsed_json_on_success(self, tmp_path: Path, monkeypatch):
220+
executor = self._make_executor(tmp_path, monkeypatch, max_execution_time=30)
221+
222+
cmd = [sys.executable, "-c", "import json, sys; sys.stdout.write(json.dumps({'session_id': 'abc123'}))"]
223+
success, error_message, result = await executor._execute_command(cmd, tmp_path)
224+
225+
assert success is True
226+
assert error_message is None
227+
assert result == {"session_id": "abc123"}
228+
229+
@pytest.mark.asyncio
230+
async def test_reports_nonzero_exit_without_leaving_error_message_empty(self, tmp_path: Path, monkeypatch):
231+
executor = self._make_executor(tmp_path, monkeypatch, max_execution_time=30)
232+
233+
cmd = [sys.executable, "-c", "import sys; sys.stderr.write('boom'); sys.exit(1)"]
234+
success, error_message, result = await executor._execute_command(cmd, tmp_path)
235+
236+
assert success is False
237+
assert "boom" in error_message
238+
assert result is None

0 commit comments

Comments
 (0)