GitNexus/eval/tests/test_process_control.py
Gergo Magyar 5a017f2722 feat(eval): report evolution progress while the generation is still running
Run 29907431284 printed its whole 14h45m of output at one timestamp
(00:02:59.32) as the process exited: stdout is a pipe, so CPython
block-buffered it, and there was no way to tell a live run from a wedged
one. Three changes make the lane observable in the Actions log:

- PYTHONUNBUFFERED for the driver (workflow step) and for the benchmark
  subprocess (its env is an explicit minimal dict and inherits nothing),
  so lines reach the log when they are written.
- run_managed grows `echo_stdout`, a passthrough that streams a child's
  stdout to stderr as it arrives while leaving the bounded tail intact.
  evolve.py enables it for the benchmark sweep — the multi-hour phase,
  whose per-run lines previously surfaced only as a tail, and only on
  failure. It stays off everywhere else: a Claude session's stdout is the
  evidence stream and is written out only after redaction.
- The sweep now announces each cell as it starts (`3/18, 47m elapsed`)
  and reports `took=` and `error_kind=` when it finishes, so an excluded
  run — the thing that actually blocks promotion — is visible live
  instead of only in results.jsonl. evolve.py also reports the proposer's
  duration, turns, and cost once the proposal lands.
2026-08-01 17:01:03 +00:00

471 lines
14 KiB
Python

"""Process-tree ownership contracts for the workflow benchmark harness."""
from __future__ import annotations
import io
import os
import signal
import sys
import time
from pathlib import Path
import pytest
from workflow_bench import process_control
from workflow_bench.process_control import (
MAX_TAIL_BYTES,
ManagedProcessResult,
mark_cleanup_failure,
run_managed,
)
PYTHON = sys.executable
def test_managed_process_captures_normal_exit() -> None:
result = run_managed(
[PYTHON, "-c", "import sys; print('out'); print('err', file=sys.stderr)"],
timeout=5,
)
assert result.state == "exited"
assert result.returncode == 0
assert result.stdout_tail.strip() == "out"
assert result.stderr_tail.strip() == "err"
assert not result.timed_out
@pytest.mark.skipif(os.name == "nt", reason="POSIX process-group canary")
def test_timeout_kills_term_ignoring_descendants_before_they_write(tmp_path: Path) -> None:
sentinel = tmp_path / "late-write"
script = """
import os, signal, subprocess, sys, time
signal.signal(signal.SIGTERM, signal.SIG_IGN)
subprocess.Popen([
sys.executable, '-c',
"import signal,time,pathlib; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1); pathlib.Path(%r).write_text('escaped')"
])
while True:
print('still-running', flush=True)
time.sleep(0.01)
""" % str(sentinel)
result = run_managed(
[PYTHON, "-c", script],
timeout=0.15,
terminate_grace=0.1,
)
time.sleep(1.1)
assert result.state == "forced-kill"
assert result.timed_out
assert result.forced_kill
assert not sentinel.exists()
@pytest.mark.skipif(os.name == "nt", reason="POSIX cooperative-TERM canary")
def test_timeout_reports_cooperative_term_without_false_forced_kill() -> None:
started = time.monotonic()
result = run_managed(
[PYTHON, "-c", "import time; time.sleep(10)"],
timeout=0.15,
terminate_grace=0.8,
)
assert result.state == "timeout"
assert result.timed_out
assert not result.forced_kill
assert result.returncode == -15
assert time.monotonic() - started < 0.6
def test_stdout_and_stderr_are_bounded_while_the_process_runs() -> None:
script = """\
import os
for _ in range(40):
os.write(1, b'o' * 8192)
os.write(2, b'e' * 8192)
os.write(1, b'OUT-END')
os.write(2, b'ERR-END')
"""
result = run_managed([PYTHON, "-c", script], timeout=5)
assert result.state == "exited"
assert len(result.stdout_tail.encode()) <= MAX_TAIL_BYTES
assert len(result.stderr_tail.encode()) <= MAX_TAIL_BYTES
assert result.stdout_tail.endswith("OUT-END")
assert result.stderr_tail.endswith("ERR-END")
def test_parent_can_capture_one_complete_bounded_stdout_stream() -> None:
payload = b"event-one\nevent-two\n"
result = run_managed(
[PYTHON, "-c", f"import os; os.write(1, {payload!r})"],
timeout=5,
capture_stdout_bytes=len(payload),
)
assert result.ok
assert result.stdout_capture == payload
assert result.stdout_capture_overflow is False
def test_parent_stdout_capture_reports_overflow_without_stopping_drain() -> None:
result = run_managed(
[PYTHON, "-c", "import os; os.write(1, b'x' * 1024); os.write(1, b'END')"],
timeout=5,
capture_stdout_bytes=64,
)
assert result.ok
assert result.stdout_capture == b"x" * 64
assert result.stdout_capture_overflow is True
assert result.stdout_tail.endswith("END")
def test_echo_stdout_streams_child_progress_and_stays_off_by_default(capfd) -> None:
command = [PYTHON, "-c", "import os; os.write(1, b'[task][arm][run 0] starting\\n')"]
quiet = run_managed(command, timeout=5)
assert quiet.ok
assert "starting" not in capfd.readouterr().err
echoed = run_managed(command, timeout=5, echo_stdout=True)
assert echoed.ok
captured = capfd.readouterr()
assert "[task][arm][run 0] starting" in captured.err
# Echoing is a passthrough, not a redirect: the tail stays intact for the
# caller that reports it after the process ends.
assert "starting" in echoed.stdout_tail
def test_incomplete_stdin_delivery_cannot_report_success() -> None:
result = run_managed(
[PYTHON, "-c", "import os,time; os.close(0); time.sleep(0.05)"],
timeout=5,
stdin_data=b"x" * (4 * 1024 * 1024),
)
assert result.returncode == 0
assert result.state == "input-failure"
assert not result.ok
assert "stdin write failed" in (result.detail or "")
@pytest.mark.skipif(os.name == "nt", reason="POSIX inherited-pipe canary")
def test_exited_parent_cannot_leave_an_inherited_pipe_descendant(tmp_path: Path) -> None:
sentinel = tmp_path / "orphan-write"
child = (
"import subprocess,sys; "
f"subprocess.Popen([sys.executable,'-c',\"import time,pathlib;time.sleep(2);pathlib.Path({str(sentinel)!r}).touch()\"]); "
"print('parent-done')"
)
result = run_managed([PYTHON, "-c", child], timeout=5, terminate_grace=0.1)
time.sleep(2.1)
assert result.state == "forced-kill"
assert result.forced_kill
assert not sentinel.exists()
@pytest.mark.skipif(os.name == "nt", reason="POSIX process-group canary")
def test_exited_parent_cannot_leave_a_quiet_descendant(tmp_path: Path) -> None:
sentinel = tmp_path / "quiet-orphan-write"
child = (
"import subprocess,sys; "
f"subprocess.Popen([sys.executable,'-c',\"import time,pathlib;time.sleep(1);pathlib.Path({str(sentinel)!r}).touch()\"], "
"stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL); "
"print('parent-done')"
)
result = run_managed([PYTHON, "-c", child], timeout=5, terminate_grace=0.1)
time.sleep(1.1)
assert result.state == "forced-kill"
assert result.forced_kill
assert not sentinel.exists()
def test_required_pid_namespace_fails_before_plain_command_starts(tmp_path: Path) -> None:
sentinel = tmp_path / "started"
result = run_managed(
[PYTHON, "-c", f"from pathlib import Path; Path({str(sentinel)!r}).touch()"],
timeout=5,
require_pid_namespace=True,
)
assert result.state == "ownership-failure"
assert result.returncode is None
assert not sentinel.exists()
def test_cleanup_failure_preserves_the_primary_process_state() -> None:
primary = ManagedProcessResult(
state="forced-kill",
returncode=-9,
stdout_tail="out",
stderr_tail="err",
duration_s=1.0,
timed_out=True,
forced_kill=True,
)
combined = mark_cleanup_failure(primary, OSError("clone busy"))
assert combined.state == "cleanup-failure"
assert combined.primary_state == "forced-kill"
assert "clone busy" in (combined.detail or "")
assert combined.stdout_tail == "out"
def test_keyboard_interrupt_reaps_owned_process_and_propagates(monkeypatch) -> None:
class FakeJob:
def __init__(self) -> None:
self.terminated = False
self.closed = False
def terminate(self) -> None:
self.terminated = True
def close(self) -> None:
self.closed = True
class InterruptingProcess:
pid = 424242
returncode = None
stdin = None
stdout = io.BytesIO()
stderr = io.BytesIO()
def __init__(self) -> None:
self.waits = 0
self.killed = False
def wait(self, timeout=None):
self.waits += 1
if self.waits == 1:
raise KeyboardInterrupt
self.returncode = -9
return self.returncode
def kill(self) -> None:
self.killed = True
process = InterruptingProcess()
job = FakeJob() if os.name == "nt" else None
killed_groups: list[tuple[int, int]] = []
monkeypatch.setattr(
"workflow_bench.process_control._spawn",
lambda *_args, **_kwargs: (
process,
job,
"windows-job" if os.name == "nt" else "posix-process-group",
),
)
if os.name != "nt":
monkeypatch.setattr(
"workflow_bench.process_control.os.killpg",
lambda pgid, sig: killed_groups.append((pgid, sig)),
)
with pytest.raises(KeyboardInterrupt):
run_managed([PYTHON, "-c", "pass"], timeout=5)
if job is not None:
assert job.terminated
assert job.closed
else:
assert killed_groups == [(process.pid, 9)]
assert process.waits == 2
def test_pre_wait_keyboard_interrupt_reaps_owned_process_and_propagates(monkeypatch) -> None:
class FakeJob:
def __init__(self) -> None:
self.terminated = False
self.closed = False
def terminate(self) -> None:
self.terminated = True
def close(self) -> None:
self.closed = True
class SpawnedProcess:
pid = 434343
returncode = None
stdin = None
stdout = io.BytesIO()
stderr = io.BytesIO()
def __init__(self) -> None:
self.waits = 0
self.killed = False
def wait(self, timeout=None):
self.waits += 1
self.returncode = -9
return self.returncode
def kill(self) -> None:
self.killed = True
process = SpawnedProcess()
job = FakeJob() if os.name == "nt" else None
killed_groups: list[tuple[int, int]] = []
monkeypatch.setattr(
"workflow_bench.process_control._spawn",
lambda *_args, **_kwargs: (
process,
job,
"windows-job" if os.name == "nt" else "posix-process-group",
),
)
monkeypatch.setattr(
"workflow_bench.process_control.threading.Thread",
lambda *_args, **_kwargs: (_ for _ in ()).throw(KeyboardInterrupt()),
)
if os.name != "nt":
monkeypatch.setattr(
"workflow_bench.process_control.os.killpg",
lambda pgid, sig: killed_groups.append((pgid, sig)),
)
with pytest.raises(KeyboardInterrupt):
run_managed([PYTHON, "-c", "pass"], timeout=5)
if job is not None:
assert job.terminated
assert job.closed
else:
assert killed_groups == [(process.pid, signal.SIGKILL)]
assert process.waits == 1
assert process.stdout.closed
assert process.stderr.closed
@pytest.mark.skipif(os.name == "nt", reason="POSIX Popen registration path")
def test_interrupt_after_spawn_return_uses_internal_ownership_registration(monkeypatch) -> None:
class SpawnedProcess:
pid = 444444
returncode = None
stdin = None
stdout = io.BytesIO()
stderr = io.BytesIO()
def __init__(self) -> None:
self.waits = 0
def wait(self, timeout=None):
self.waits += 1
self.returncode = -9
return self.returncode
def kill(self) -> None:
self.returncode = -9
process = SpawnedProcess()
killed_groups: list[tuple[int, int]] = []
real_spawn = process_control._spawn
def interrupt_after_registered_spawn(*args, **kwargs):
real_spawn(*args, **kwargs)
raise KeyboardInterrupt
monkeypatch.setattr(process_control.subprocess, "Popen", lambda *_args, **_kwargs: process)
monkeypatch.setattr(process_control, "_spawn", interrupt_after_registered_spawn)
monkeypatch.setattr(process_control.os, "killpg", lambda pgid, sig: killed_groups.append((pgid, sig)))
with pytest.raises(KeyboardInterrupt):
run_managed([PYTHON, "-c", "pass"], timeout=5)
assert killed_groups == [(process.pid, signal.SIGKILL)]
assert process.waits == 1
assert process.stdout.closed
assert process.stderr.closed
def test_post_wait_keyboard_interrupt_reaps_job_and_propagates(monkeypatch) -> None:
class CompletedProcess:
pid = 515151
returncode = None
stdin = None
stdout = io.BytesIO()
stderr = io.BytesIO()
def __init__(self) -> None:
self.waits = 0
def wait(self, timeout=None):
self.waits += 1
self.returncode = 0 if self.waits == 1 else -9
return self.returncode
def kill(self) -> None:
self.returncode = -9
class InterruptingJob:
def __init__(self) -> None:
self.terminated = False
self.closed = False
def active_processes(self) -> int:
raise KeyboardInterrupt
def terminate(self) -> None:
self.terminated = True
def close(self) -> None:
self.closed = True
process = CompletedProcess()
job = InterruptingJob()
monkeypatch.setattr(
"workflow_bench.process_control._spawn",
lambda *_args, **_kwargs: (process, job, "windows-job"),
)
with pytest.raises(KeyboardInterrupt):
run_managed([PYTHON, "-c", "pass"], timeout=5)
assert job.terminated
assert job.closed
assert process.waits == 2
@pytest.mark.skipif(os.name != "nt", reason="native Windows Job Object canary")
def test_windows_job_kills_grandchild_before_delayed_write(tmp_path: Path) -> None:
sentinel = tmp_path / "late-write"
child = (
"import subprocess,sys,time; "
f"subprocess.Popen([sys.executable,'-c',\"import time,pathlib;time.sleep(1);pathlib.Path({str(sentinel)!r}).touch()\"]); "
"time.sleep(10)"
)
result = run_managed([PYTHON, "-c", child], timeout=0.15, terminate_grace=0.1)
time.sleep(1.1)
assert result.state == "forced-kill"
assert result.ownership == "windows-job"
assert not sentinel.exists()
@pytest.mark.skipif(os.name != "nt", reason="native Windows Job Object canary")
def test_windows_normal_parent_with_grandchild_is_not_successful_evidence(tmp_path: Path) -> None:
sentinel = tmp_path / "quiet-late-write"
parent = (
"import subprocess,sys; "
f"subprocess.Popen([sys.executable,'-c',\"import time,pathlib;time.sleep(1);pathlib.Path({str(sentinel)!r}).touch()\"], "
"stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)"
)
result = run_managed([PYTHON, "-c", parent], timeout=5, terminate_grace=0.1)
time.sleep(1.1)
assert result.state == "forced-kill"
assert result.forced_kill
assert not result.ok
assert not sentinel.exists()