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.
162 lines
6.0 KiB
Python
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
|