|
1 | 1 | """Tests for main_benchmark helpers.""" |
2 | 2 |
|
| 3 | +import asyncio |
| 4 | +import os |
| 5 | +import sys |
| 6 | +import time |
3 | 7 | from pathlib import Path |
4 | 8 | from unittest.mock import MagicMock |
5 | 9 |
|
6 | 10 | import pytest |
7 | 11 |
|
8 | | -from main_benchmark import CommandBuilder, Config, MCPServerManager, TaskManager |
| 12 | +from main_benchmark import CommandBuilder, Config, MCPServerManager, TaskExecutor, TaskManager |
9 | 13 |
|
10 | 14 |
|
11 | 15 | class TestConfig: |
@@ -101,3 +105,134 @@ def test_rejects_zero_concurrency(self): |
101 | 105 | with pytest.raises(ValueError, match="max_concurrent_tasks must be >= 1"): |
102 | 106 | if max_concurrent < 1: |
103 | 107 | 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