2026-07-30 14:45:21 +02:00

99 lines
3.0 KiB
Python

"""Execute a runner cell as a subprocess (one process per cell).
One process per cell keeps peak_rss_kb attributable and isolates each
(impl, operation, runtime, params) measurement from the others.
"""
from __future__ import annotations
import os
import subprocess
import threading
from pathlib import Path
from .protocol import JobSpec, Result
from .registry import Adapter
class RunnerError(RuntimeError):
pass
# Live runner subprocesses, so a Ctrl+C can kill them promptly instead of leaving
# them running. Worker threads (concurrency/mining) never receive KeyboardInterrupt
# themselves, so the main-thread SIGINT handler (installed in cli.main) reaps them
# via terminate_all_children(); the per-call finally cleans up the common case.
_active: set[subprocess.Popen] = set()
_active_lock = threading.Lock()
def terminate_all_children() -> None:
"""Kill every runner subprocess still running. Safe to call from a signal
handler (main thread) and idempotent."""
with _active_lock:
procs = list(_active)
for p in procs:
try:
p.kill()
except Exception: # noqa: BLE001 - best-effort teardown, never raise
pass
def run(
adapter: Adapter,
spec: JobSpec,
repo_root: Path,
timeout: float = 900.0,
) -> Result:
argv = adapter.resolve(repo_root)
env = dict(os.environ)
env.update(adapter.env)
try:
proc = subprocess.Popen(
argv,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
)
except FileNotFoundError as e:
raise RunnerError(f"{adapter.name} executable not found: {argv[0]}") from e
with _active_lock:
_active.add(proc)
try:
stdout, stderr = proc.communicate(input=spec.to_json(), timeout=timeout)
except subprocess.TimeoutExpired as e:
proc.kill()
proc.communicate() # reap the killed child so it can't linger as a zombie
raise RunnerError(f"{adapter.name} timed out after {timeout}s") from e
except BaseException:
# KeyboardInterrupt (or anything else): never leave the child running.
proc.kill()
try:
proc.communicate(timeout=5)
except Exception: # noqa: BLE001
pass
raise
finally:
with _active_lock:
_active.discard(proc)
out = (stdout or "").strip()
if not out:
raise RunnerError(
f"{adapter.name} produced no output (exit {proc.returncode}); "
f"stderr: {(stderr or '').strip()[:500]}"
)
# A runner may print progress lines to stdout before the JSON; take the last line.
line = out.splitlines()[-1]
try:
import json
result = Result.from_dict(json.loads(line))
except Exception as e: # noqa: BLE001
raise RunnerError(
f"{adapter.name} emitted invalid result JSON: {e}; "
f"raw: {line[:500]}"
) from e
return result