Files
mlx-knife/tests/support/process_guard.py
Local Test 5045f9e1bd Release: 1.1.1‑b2 — Issue #31, stable server tests
- Lenient MLX detection via README/tokenizer (Issue #31)
- CLI: `show` type, strict `list` (chat), `run` accepts private MLX
- Server tests: RAM‑aware gating with `mlxk show`, MoE parsing fix (8x7B), server‑manager process guard, thread‑based timeout
- Multi‑Python script hardened; no ANSI; log tail on errors
- Docs: CHANGELOG, TESTING, CLAUDE updated; 166/166 green (Py 3.9–3.13), 32 server tests green
2025-09-07 02:24:08 +02:00

165 lines
4.7 KiB
Python

"""
Process guard for pytest integration tests.
Tracks spawned server subprocesses and ensures they are terminated on
Ctrl-C (SIGINT), SIGTERM, normal test teardown, and at interpreter exit.
Usage:
- Call `register_popen(proc, label)` after starting a subprocess.
- Optionally `unregister(pid)` after clean termination.
- Handlers are installed automatically when importing this module, but
can also be installed explicitly via `install_signal_handlers()`.
"""
from __future__ import annotations
import atexit
import os
import signal
import threading
import time
from typing import Dict, Optional
import psutil
_registry_lock = threading.RLock()
_registry: Dict[int, Dict[str, Optional[int]]] = {}
_handlers_installed = False
def _safe_get_pgid(pid: int) -> Optional[int]:
try:
return os.getpgid(pid)
except Exception:
return None
def register_popen(proc, label: str = "tracked-proc") -> None:
"""Register a subprocess.Popen for guarded cleanup."""
if proc is None:
return
pid = getattr(proc, "pid", None)
if not pid:
return
pgid = _safe_get_pgid(pid)
with _registry_lock:
_registry[pid] = {"label": label, "pgid": pgid}
def unregister(pid: int) -> None:
with _registry_lock:
_registry.pop(pid, None)
def _kill_pid_tree(pid: int, timeout: float = 1.0) -> None:
"""Terminate a process and its children, escalating if needed."""
try:
proc = psutil.Process(pid)
except psutil.NoSuchProcess:
return
# Try to terminate children first
children = proc.children(recursive=True)
for ch in children:
try:
ch.terminate()
except psutil.NoSuchProcess:
pass
# Terminate main process
try:
proc.terminate()
except psutil.NoSuchProcess:
return
t0 = time.time()
while time.time() - t0 < timeout:
if not proc.is_running():
return
time.sleep(0.1)
# Escalate to kill
for ch in children:
try:
ch.kill()
except psutil.NoSuchProcess:
pass
try:
proc.kill()
except psutil.NoSuchProcess:
pass
def kill_all(label_filter: Optional[str] = None) -> None:
"""Kill all tracked processes (optionally filtered by label)."""
with _registry_lock:
items = list(_registry.items())
for pid, meta in items:
label = (meta or {}).get("label")
pgid = (meta or {}).get("pgid")
if label_filter and label != label_filter:
continue
# Try process group termination first (POSIX)
if pgid and pgid > 0 and hasattr(os, "killpg"):
try:
os.killpg(pgid, signal.SIGTERM)
# Give the group a moment
time.sleep(0.2)
except Exception:
pass
# Fallback to individual tree kill with short timeout
_kill_pid_tree(pid, timeout=0.8)
# Final escalation: SIGKILL the group if still around
if pgid and pgid > 0 and hasattr(os, "killpg"):
try:
os.killpg(pgid, signal.SIGKILL)
except Exception:
pass
unregister(pid)
def _signal_handler_factory(prev_handler):
def _handler(signum, frame):
# Best-effort kill of tracked server processes
try:
kill_all()
finally:
# Chain to previous handler behavior
if callable(prev_handler):
try:
prev_handler(signum, frame)
return
except Exception:
# If previous handler was Python's default raising KeyboardInterrupt,
# re-raise to allow pytest to handle interruption.
raise
# If default/ignore, re-send signal to self to honor semantics
try:
signal.signal(signum, signal.SIG_DFL)
os.kill(os.getpid(), signum)
except Exception:
pass
return _handler
def install_signal_handlers() -> None:
global _handlers_installed
if _handlers_installed:
return
if os.environ.get("MLXK_TEST_DISABLE_PROCESS_GUARD"):
_handlers_installed = True
return
# Chain SIGINT and SIGTERM
for sig in (signal.SIGINT, signal.SIGTERM):
try:
prev = signal.getsignal(sig)
signal.signal(sig, _signal_handler_factory(prev))
except Exception:
pass
atexit.register(lambda: kill_all())
_handlers_installed = True
# Note: Do NOT auto-install on import. Tests that need the guard should call
# install_signal_handlers() explicitly to avoid interfering with non-server runs.