"""Cancellation regressions using disposable local process groups only.""" import asyncio import os import signal import subprocess import sys import threading import pytest import EvoScientist.native_sandbox as sandbox @pytest.mark.anyio @pytest.mark.skipif(os.name != "posix", reason="POSIX process groups") async def test_async_execute_cancellation_waits_for_worker_cleanup(tmp_path, monkeypatch): files = tmp_path / "files" runtime = tmp_path / "runtime" files.mkdir() runtime.mkdir() backend = sandbox.NativeWorkspaceBackend(files, runtime, timeout=3) started = threading.Event() cleaned = threading.Event() def execute(_command, *, timeout, cancel_event): process = subprocess.Popen( ["/bin/sh", "-c", "sleep 30 & wait"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True, ) started.set() try: sandbox._collect_process(process, timeout=3, output_limit=1024, cancel_event=cancel_event) assert process.poll() is not None cleaned.set() finally: try: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: pass process.wait(timeout=3) monkeypatch.setattr(backend._executor, "execute", execute) task = asyncio.create_task(backend.aexecute("sleep 30")) try: assert await asyncio.to_thread(started.wait, 3) task.cancel() with pytest.raises(asyncio.CancelledError): await task assert cleaned.is_set() finally: if not task.done(): task.cancel() await asyncio.gather(task, return_exceptions=True) @pytest.mark.skipif(os.name != "posix", reason="POSIX process groups") def test_collector_read_failure_reaps_process(monkeypatch): process = subprocess.Popen( [sys.executable, "-c", "import time; print('ready', flush=True); time.sleep(30)"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True, ) def failed_read(*_args): raise OSError("collector read failed") try: with monkeypatch.context() as patcher: patcher.setattr(sandbox.os, "read", failed_read) with pytest.raises(OSError, match="collector read failed"): sandbox._collect_process(process, timeout=3, output_limit=1024) assert process.poll() is not None, "collector failure left command running" finally: try: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: pass process.wait(timeout=3) @pytest.mark.skipif(os.name != "posix", reason="POSIX process groups") @pytest.mark.parametrize("cancel", [True, False]) def test_collector_cancel_and_timeout_reap_process_group(cancel): process = subprocess.Popen( ["/bin/sh", "-c", "sleep 30 & wait"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True, ) event = threading.Event() if cancel: event.set() try: _, code, _, timed_out, cancelled = sandbox._collect_process( process, timeout=1, output_limit=1024, cancel_event=event ) assert code == (130 if cancel else 124) assert (timed_out, cancelled) == (not cancel, cancel) assert process.poll() is not None with pytest.raises(ProcessLookupError): os.killpg(process.pid, 0) finally: try: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: pass process.wait(timeout=3)