Files
hermes-agent/tests/e2e/core/dashboard/_reaper.py
teknium1 3094b5d0aa test(e2e): dashboard suite passes the windows-footgun and encoding checks
SIGKILL is no longer an import-time default argument, /proc and .env reads name
utf-8, the two files without a Linux gate get one (pty stdin, /proc reaper), and
the remaining POSIX calls carry a windows-footgun marker with the reason.
2026-09-24 04:41:24 -07:00

162 lines
6.0 KiB
Python

"""The ONE place the dashboard lane cleans up processes a test left behind.
Two problems, one mechanism:
* Detached children escape every handle the test holds. ``gateway/shutdown_forensics.py``
runs a ``timeout 5 bash -c ...`` diagnostic in its own session on every gateway shutdown,
``ptyprocess`` makes each TUI a session leader, MCP stdio servers ``setsid``. When their parent
dies they are reparented, by default to init.
* A process reparented to init is outside the pytest process subtree, so the live-system guards
(``tests/conftest.py`` and the developer plugin) refuse to signal it, and
``@pytest.mark.live_system_guard_bypass`` cannot help: the plugin re-arms in
``pytest_runtest_teardown`` before fixture finalizers run, so a bypass never covers teardown.
``adopt_orphans()`` makes the pytest process the child subreaper (``PR_SET_CHILD_SUBREAPER``):
an orphaned descendant reparents to pytest instead of init and stays inside the subtree the
guards allow. Nothing is exempted from the guard; a process that did not descend from this pytest
process can never be adopted, so it stays refused.
``reap()`` then waits (with a deadline) for everything a test spawned to exit on its own. Only
processes still alive at the deadline are SIGKILLed, each checked against the (pid, kernel start
time) recorded when it was first seen, so a recycled pid is never hit. The killed ones come back as
the leak report.
"""
from __future__ import annotations
import ctypes
import os
import signal
import sys
import time
from pathlib import Path
from typing import Callable
Identity = tuple[int, int] # (pid, kernel start time in clock ticks)
Finder = Callable[[], dict[Identity, str]]
REAP_TIMEOUT = 30.0 # the gateway's shutdown diagnostic self-terminates within ``timeout 5``
_PR_SET_CHILD_SUBREAPER = 36
_adopters = 0
def _set_subreaper(on: bool) -> None:
libc = ctypes.CDLL(None, use_errno=True)
if libc.prctl(_PR_SET_CHILD_SUBREAPER, int(on), 0, 0, 0) != 0:
raise OSError(ctypes.get_errno(), "prctl(PR_SET_CHILD_SUBREAPER) failed")
def adopt_orphans() -> None:
"""Start adopting orphaned descendants (refcounted; pair with ``release_orphans``)."""
global _adopters
if sys.platform.startswith("linux") and _adopters == 0:
_set_subreaper(True)
_adopters += 1
def release_orphans() -> None:
"""Stop adopting once no sandbox needs it. Processes already adopted stay our children."""
global _adopters
_adopters = max(0, _adopters - 1)
if sys.platform.startswith("linux") and _adopters == 0:
_set_subreaper(False)
def stat(pid: int) -> list[str] | None:
"""``/proc/<pid>/stat`` fields after ``comm`` (state is index 0, ppid 1, sid 3, start 19)."""
try:
return Path(f"/proc/{pid}/stat").read_text(encoding="utf-8", errors="replace").rsplit(")", 1)[1].split()
except (OSError, IndexError):
return None
def start_time(pid: int) -> int | None:
"""Kernel start time of a live, non-zombie process; None when it is gone or a zombie."""
fields = stat(pid)
return int(fields[19]) if fields and fields[0] not in ("Z", "X") else None
def cmdline(pid: int) -> str:
try:
return Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ").decode(errors="replace").strip()
except OSError:
return "?"
def all_stats() -> dict[int, list[str]]:
"""Every live, non-zombie process."""
out = {}
for entry in os.listdir("/proc"):
if entry.isdigit() and (fields := stat(int(entry))) is not None and fields[0] not in ("Z", "X"):
out[int(entry)] = fields
return out
def with_home(home: Path) -> Finder:
"""Every live process whose environment points HOME into ``home`` (the sandbox), except us."""
needle = f"HOME={home}".encode()
def find() -> dict[Identity, str]:
found = {}
for pid in all_stats():
if pid == os.getpid():
continue
try:
if needle not in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0"):
continue
except OSError:
continue
if (started := start_time(pid)) is not None:
found[(pid, started)] = cmdline(pid)
return found
return find
def _collect_zombies(idents: dict[Identity, str]) -> None:
"""``waitpid`` the adopted ones that already exited, so no zombie outlives the test."""
for pid, _ in idents:
fields = stat(pid)
if fields and fields[0] == "Z" and int(fields[1]) == os.getpid():
try:
os.waitpid(pid, os.WNOHANG)
except ChildProcessError:
pass
def kill_identified(idents: dict[Identity, str]) -> list[str]:
"""SIGKILL exactly the recorded processes that are still the SAME process (pid and start time)."""
killed = []
for (pid, started), cmd in idents.items():
if start_time(pid) != started:
continue
try:
os.kill(pid, signal.SIGKILL) # windows-footgun: ok — Linux-only suite, never reached on Windows
except ProcessLookupError:
continue
killed.append(f"{pid}: {cmd[:160]}")
return killed
def reap(*finders: Finder, timeout: float = REAP_TIMEOUT) -> list[str]:
"""Wait up to ``timeout`` s for every process the finders report to exit by itself, then kill
the survivors (identity-checked). Returns the killed ones: an empty list means nothing leaked."""
seen: dict[Identity, str] = {}
deadline = time.monotonic() + timeout
while True:
for find in finders:
seen.update(find())
_collect_zombies(seen)
live = {ident: cmd for ident, cmd in seen.items() if start_time(ident[0]) == ident[1]}
if not live:
return []
if time.monotonic() >= deadline:
break
time.sleep(0.1)
killed = kill_identified(live)
settle = time.monotonic() + 10
while time.monotonic() < settle and any(start_time(pid) == started for pid, started in live):
_collect_zombies(live)
time.sleep(0.05)
_collect_zombies(live)
return killed