|
1 | 1 | import asyncio |
2 | 2 | import logging |
3 | 3 | import os |
| 4 | +import signal |
4 | 5 | from datetime import datetime |
5 | 6 | from pathlib import Path |
6 | 7 | from typing import Tuple |
|
14 | 15 | from moatless.runtime.runtime import RuntimeEnvironment |
15 | 16 | from moatless.storage.base import BaseStorage |
16 | 17 | from moatless.testing.python.parser_registry import parse_log |
17 | | -from moatless.testing.schema import TestResult |
| 18 | +from moatless.testing.schema import TestResult, TestStatus |
18 | 19 | from moatless.context_data import current_node_id |
19 | 20 | from unidiff import PatchSet |
20 | 21 |
|
@@ -163,8 +164,6 @@ async def run_tests( |
163 | 164 |
|
164 | 165 | # If no results were parsed but we ran a test (even if it timed out), create a basic result |
165 | 166 | if not testbed_results: |
166 | | - from moatless.testing.schema import TestResult, TestStatus |
167 | | - |
168 | 167 | basic_result = TestResult( |
169 | 168 | status=TestStatus.ERROR if timed_out else TestStatus.UNKNOWN, |
170 | 169 | file_path=test_file, |
@@ -307,27 +306,50 @@ async def _execute_command( |
307 | 306 | env=os.environ.copy(), |
308 | 307 | stdout=asyncio.subprocess.PIPE, |
309 | 308 | stderr=asyncio.subprocess.STDOUT, # redirect stderr to stdout |
| 309 | + start_new_session=True # Create new process group for proper cleanup |
310 | 310 | ) |
311 | 311 |
|
312 | 312 | try: |
313 | 313 | stdout, _ = await asyncio.wait_for(process.communicate(), timeout=timeout) |
314 | 314 | return stdout.decode(), process.returncode or 0 |
315 | 315 | except asyncio.TimeoutError: |
316 | | - # Kill the process if it times out |
317 | | - process.kill() |
| 316 | + # Kill the entire process group to ensure all child processes are terminated |
318 | 317 | try: |
319 | | - await process.wait() |
320 | | - except: |
| 318 | + # Use process group ID (negative PID) to kill all processes in the group |
| 319 | + os.killpg(os.getpgid(process.pid), signal.SIGKILL) |
| 320 | + except ProcessLookupError: |
| 321 | + # Process already dead |
321 | 322 | pass |
| 323 | + except AttributeError: |
| 324 | + # Fallback for systems without killpg |
| 325 | + process.kill() |
| 326 | + |
| 327 | + try: |
| 328 | + await asyncio.wait_for(process.wait(), timeout=1.0) |
| 329 | + except asyncio.TimeoutError: |
| 330 | + # Force kill if still not dead |
| 331 | + try: |
| 332 | + process.kill() |
| 333 | + except Exception: |
| 334 | + pass |
| 335 | + except Exception: |
| 336 | + pass |
| 337 | + |
322 | 338 | # Return partial output if any was captured before timeout |
323 | 339 | partial_output = "" |
324 | 340 | if process.stdout: |
325 | 341 | try: |
326 | 342 | partial_data = await asyncio.wait_for(process.stdout.read(), timeout=1.0) |
327 | 343 | partial_output = partial_data.decode() |
328 | | - except: |
| 344 | + except Exception: |
329 | 345 | pass |
330 | | - return partial_output, -1 # Use -1 to indicate timeout |
| 346 | + |
| 347 | + # Add timeout message to the output |
| 348 | + timeout_msg = f"\n\n[ERROR] Command execution timed out after {timeout} seconds\n" |
| 349 | + combined_output = partial_output + timeout_msg |
| 350 | + |
| 351 | + logger.warning(f"Command timed out after {timeout} seconds: {command}") |
| 352 | + return combined_output, -1 # Use -1 to indicate timeout |
331 | 353 |
|
332 | 354 | async def _apply_patch(self, patch: str) -> bool: |
333 | 355 | """Apply a git patch to the repository.""" |
@@ -517,8 +539,8 @@ def make_eval_script_list_py(self) -> list: |
517 | 539 | eval_commands += [ |
518 | 540 | f"git config --global --add safe.directory {repo_directory}", # for nonroot user |
519 | 541 | f"cd {repo_directory}", |
520 | | - f"which python", |
521 | | - f"python --version", |
| 542 | + "which python", |
| 543 | + "python --version", |
522 | 544 | # This is just informational, so we have a record |
523 | 545 | "git status", |
524 | 546 | "git show", |
@@ -563,7 +585,7 @@ async def _save_execution_log(self, command: str, patch: str | None, output: str |
563 | 585 | log_content = f"Command: {command}\n" |
564 | 586 | log_content += f"Return Code: {return_code}\n" |
565 | 587 | if patch: |
566 | | - log_content += f"Patch Applied: Yes\n" |
| 588 | + log_content += "Patch Applied: Yes\n" |
567 | 589 | log_content += f"Patch Content:\n{patch}\n" |
568 | 590 | log_content += "=" * 80 + "\n" |
569 | 591 | else: |
|
0 commit comments