From 4da4a50682bd44e0656625d450bd80a79876753e Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 13 Sep 2026 16:09:28 +0900 Subject: [PATCH] test(issue-19): run the sandbox reaper through an import-time Popen The reaper executes in fixture teardown, before monkeypatch restores a test's patches; G5 forbids subprocess.run/Popen for the test's duration and subprocess.run resolves Popen through the module at call time, so the teardown tripped the spy. Bind the Popen class at import instead. Full suite at 38d8ccb: 870 passed, 1 error (this teardown), 0 leaked daemons. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff --- tests/conftest.py | 46 ++++++++++++++++++++++------------------------ 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 6b1798c..51f5d55 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,9 +4,27 @@ import shutil import json import uuid import sqlite3 +import subprocess as _subprocess from pathlib import Path import pytest +# Issue #19: the sandbox reaper runs in fixture teardown, before `monkeypatch` +# undoes a test's patches. Bind the real Popen class once at import so a test +# that spies on or forbids subprocess.run/Popen (G5) cannot break the reaper. +# (subprocess.run resolves `Popen` through the module at call time, so binding +# `run` alone is not enough.) +_REAL_POPEN = _subprocess.Popen + + +def _run_real(cmd): + """stdout of `cmd` via the import-time Popen; '' on any OS error.""" + try: + proc = _REAL_POPEN(cmd, stdout=_subprocess.PIPE, stderr=_subprocess.DEVNULL, text=True) + out, _ = proc.communicate(timeout=10) + return out + except (OSError, _subprocess.SubprocessError): + return "" + SKILLS_DIR = str(Path(__file__).resolve().parent.parent / ".agents" / "skills") if SKILLS_DIR not in sys.path: sys.path.insert(0, SKILLS_DIR) @@ -19,29 +37,14 @@ def _subscriber_daemon_pids(root): detached into its own session with `--idle-timeout 0`. A sandbox that ran it must reap it, or every suite run leaves ~30 daemons holding wildcard subscriptions on the default public broker behind.""" - import subprocess as _sp root = str(root) - try: - out = _sp.run(["pgrep", "-f", "reconcile.sh --subscribe"], - capture_output=True, text=True).stdout.split() - except OSError: - return [] found = [] - for pid in out: - try: - args = _sp.run(["ps", "-o", "args=", "-p", pid], - capture_output=True, text=True).stdout - except OSError: - continue - if root in args: + for pid in _run_real(["pgrep", "-f", "reconcile.sh --subscribe"]).split(): + if root in _run_real(["ps", "-o", "args=", "-p", pid]): found.append(int(pid)) kids = [] for pid in found: - try: - kids += [int(k) for k in _sp.run(["pgrep", "-P", str(pid)], - capture_output=True, text=True).stdout.split()] - except (OSError, ValueError): - pass + kids += [int(k) for k in _run_real(["pgrep", "-P", str(pid)]).split() if k.isdigit()] return found + kids @@ -73,12 +76,7 @@ def _pid_alive(pid): except PermissionError: return True # a zombie still answers kill(0); ps state Z means it is gone for our purposes - import subprocess as _sp - try: - state = _sp.run(["ps", "-o", "stat=", "-p", str(pid)], - capture_output=True, text=True).stdout.strip() - except OSError: - return True + state = _run_real(["ps", "-o", "stat=", "-p", str(pid)]).strip() return bool(state) and not state.startswith("Z")