mirror of
https://github.com/cloudstack-llc/mlx-knife.git
synced 2026-08-28 04:30:19 -04:00
5045f9e1bd
- 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
165 lines
4.7 KiB
Python
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.
|