Files
nvcurve/tests/test_processes.py
T
ARIA c11ea73ead feat: GPU process list with per-process VRAM/utilization and kill
Add a GPU process table to the Performance tab showing which processes
use the selected GPU, with per-process VRAM, GPU utilization, CPU, host
memory, and command. Includes a kill endpoint (SIGTERM/SIGKILL, with
optional parent kill) behind a confirm dialog.

- nvcurve/hal/processes.py: NVML + /proc process enumeration
- server.py: GET /api/processes, POST /api/processes/kill (refuses to
  signal init/kthreadd)
- frontend: ProcessList component, API client, types
- tests: test_processes.py
- docs: API reference for the new endpoints
2026-10-03 20:25:33 +02:00

347 lines
11 KiB
Python

"""Tests for the GPU process list + kill endpoints.
Standalone (no pytest required):
python tests/test_processes.py
Also works under pytest if available. Covers the /proc reader (including
comm values with spaces and parentheses), the kill endpoint's input
validation, a real SIGTERM round-trip, and the GET /api/processes payload.
"""
import contextlib
import os
import subprocess
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from nvcurve import server # noqa: E402
from nvcurve.hal import processes # noqa: E402
PASS = 0
FAIL = 0
def check(name: str, cond: bool) -> None:
global PASS, FAIL
if cond:
PASS += 1
print(f" PASS {name}")
else:
FAIL += 1
print(f" FAIL {name}")
def test_proc_info_self() -> None:
"""_proc_info must return sane data for our own process."""
info = processes._proc_info(os.getpid())
check("self info not None", info is not None)
if info is None:
return
check(
"user is non-empty string",
bool(isinstance(info["user"], str) and info["user"]),
)
check(
"cpu_pct is None or non-negative number",
info["cpu_pct"] is None or (isinstance(info["cpu_pct"], float) and info["cpu_pct"] >= 0),
)
check("mem_bytes > 0", info["mem_bytes"] is not None and info["mem_bytes"] > 0)
check("command non-empty", isinstance(info["command"], str) and len(info["command"]) > 0)
def test_proc_info_missing() -> None:
"""A pid that does not exist must yield None (not an exception)."""
check("missing pid -> None", processes._proc_info(99999999) is None)
def test_proc_info_comm_with_parens() -> None:
"""comm containing spaces and parentheses must not break /proc/stat parsing.
The kernel comm is set via prctl(PR_SET_NAME); `exec -a` only changes
argv[0] and would leave comm as the executable name, so the parens path
would never be exercised.
"""
proc = subprocess.Popen(
[
sys.executable,
"-c",
"import ctypes, time\n"
"ctypes.CDLL(None).prctl(15, b'proc (test) x', 0, 0, 0)\n"
"time.sleep(30)",
]
)
try:
# Wait until /proc/<pid>/stat exists and comm actually carries the
# parenthesised name (prctl may take a moment after exec).
comm = ""
for _ in range(100):
try:
with open(f"/proc/{proc.pid}/stat") as f:
raw = f.read()
comm = raw[raw.index("(") + 1 : raw.rfind(")")]
except OSError:
pass
if "(" in comm:
break
time.sleep(0.05)
check("fixture comm contains parens", "(" in comm)
info = processes._proc_info(proc.pid)
check("weird-comm info not None", info is not None)
if info is not None:
check("weird-comm command parsed", "prctl" in info["command"])
finally:
proc.kill()
proc.wait()
def test_kill_endpoint_validation() -> None:
"""The kill endpoint must reject bad input and unknown pids."""
from fastapi.testclient import TestClient
client = TestClient(server.app)
r = client.post(
"/api/processes/kill", json={"pid": os.getpid(), "signal": "BOGUS"}
)
check("invalid signal -> 400", r.status_code == 400)
r = client.post("/api/processes/kill", json={"pid": -5, "signal": "TERM"})
check("negative pid -> 400", r.status_code == 400)
r = client.post("/api/processes/kill", json={"pid": 0, "signal": "TERM"})
check("zero pid -> 400", r.status_code == 400)
r = client.post("/api/processes/kill", json={"pid": 99999999, "signal": "TERM"})
check("unknown pid -> 404", r.status_code == 404)
def test_kill_endpoint_real() -> None:
"""A real SIGTERM round-trip: the process must actually exit."""
from fastapi.testclient import TestClient
client = TestClient(server.app)
proc = subprocess.Popen(["sleep", "30"])
try:
r = client.post(
"/api/processes/kill", json={"pid": proc.pid, "signal": "TERM"}
)
check("kill -> 200", r.status_code == 200)
check("response ok", r.json().get("ok") is True)
try:
proc.wait(timeout=5)
check("process exited", True)
except subprocess.TimeoutExpired:
check("process exited", False)
finally:
proc.kill()
proc.wait()
def test_kill_endpoint_refuses_init_kthreadd() -> None:
"""The endpoint must refuse to signal init (pid 1) or kthreadd (pid 2).
SIGTERM to init is a system-shutdown vector; kernel threads don't handle
signals anyway. This fires before any NVML/state access, so no GPU needed.
"""
from fastapi.testclient import TestClient
client = TestClient(server.app)
for pid in (1, 2):
r = client.post(
"/api/processes/kill", json={"pid": pid, "signal": "KILL"}
)
check(f"pid {pid} refused -> 400", r.status_code == 400)
def test_get_parent_pid() -> None:
"""get_parent_pid must return the kernel ppid and degrade gracefully."""
check("self ppid matches", processes.get_parent_pid(os.getpid()) == os.getppid())
check("missing pid -> None", processes.get_parent_pid(99999999) is None)
check("pid 1 has no parent", processes.get_parent_pid(1) == 0)
def test_kill_parent_endpoint() -> None:
"""parent=true must signal the parent, leaving the child alive."""
from fastapi.testclient import TestClient
client = TestClient(server.app)
# Validation cases first (no fixture needed).
r = client.post(
"/api/processes/kill", json={"pid": 99999999, "signal": "KILL", "parent": True}
)
check("parent of missing pid -> 404", r.status_code == 404)
r = client.post(
"/api/processes/kill", json={"pid": 1, "signal": "KILL", "parent": True}
)
check("pid 1 has no parent -> 400", r.status_code == 400)
# bash forks a background sleep (child), then sleeps itself (parent).
parent = subprocess.Popen(["bash", "-c", "sleep 30 & sleep 30"])
child: int | None = None
for _ in range(100):
try:
with open(f"/proc/{parent.pid}/task/{parent.pid}/children") as f:
kids = f.read().split()
if kids:
child = int(kids[0])
break
except OSError:
pass
time.sleep(0.05)
try:
check("fixture has a child", child is not None)
if child is None:
return
r = client.post(
"/api/processes/kill",
json={"pid": child, "signal": "KILL", "parent": True},
)
check("kill parent -> 200", r.status_code == 200)
check("response targets the parent", r.json().get("pid") == parent.pid)
check("response reports parent flag", r.json().get("parent") is True)
try:
parent.wait(timeout=5)
check("parent died", True)
except subprocess.TimeoutExpired:
check("parent died", False)
time.sleep(0.2)
try:
os.kill(child, 0)
check("child survived (orphaned)", True)
except ProcessLookupError:
check("child survived (orphaned)", False)
finally:
for victim in (parent,):
with contextlib.suppress(ProcessLookupError, subprocess.TimeoutExpired):
victim.kill()
victim.wait(timeout=5)
if child is not None:
with contextlib.suppress(ProcessLookupError):
os.kill(child, 9)
def test_processes_endpoint() -> None:
"""GET /api/processes must return the HAL payload for a known GPU."""
from fastapi.testclient import TestClient
fake = {
"processes": [
{
"pid": 1,
"user": "root",
"dev": 3,
"type": "C",
"gpu_util_pct": 42.0,
"mem_util_pct": 1.0,
"vram_bytes": 1000,
"vram_pct": 0.1,
"cpu_pct": 1.5,
"mem_host_bytes": 2048,
"command": "/usr/bin/fake",
}
],
"mem_total_bytes": 1000000,
}
orig = server.list_gpu_processes
server._state["gpus"][3] = {"gpu": object(), "gpu_name": "fake"}
server.list_gpu_processes = lambda idx: fake
try:
client = TestClient(server.app)
r = client.get("/api/processes", params={"gpu_index": 3})
check("processes -> 200", r.status_code == 200)
check("payload matches HAL", r.json() == fake)
r = client.get("/api/processes", params={"gpu_index": 99})
check("unknown gpu -> 404", r.status_code == 404)
finally:
server.list_gpu_processes = orig
server._state["gpus"].pop(3, None)
def test_list_gpu_processes_shape() -> None:
"""With NVML available, list_gpu_processes must return a well-shaped payload."""
from nvcurve.hal import monitoring
if not monitoring._NVML_AVAILABLE:
print(" SKIP NVML not available")
return
if not monitoring.init_nvml():
print(" SKIP NVML init failed (no driver?)")
return
try:
data = processes.list_gpu_processes(0)
check("has processes list", isinstance(data.get("processes"), list))
check("has mem_total_bytes", "mem_total_bytes" in data)
keys = {
"pid",
"user",
"dev",
"type",
"gpu_util_pct",
"mem_util_pct",
"vram_bytes",
"vram_pct",
"cpu_pct",
"mem_host_bytes",
"command",
}
for p in data["processes"]:
if not keys.issubset(p.keys()):
check(f"process {p.get('pid')} has all keys", False)
break
else:
check("all processes have all keys", True)
# Sorted by VRAM descending.
vram = [p["vram_bytes"] or 0 for p in data["processes"]]
check("sorted by VRAM desc", vram == sorted(vram, reverse=True))
finally:
monitoring.shutdown_nvml()
def test_list_gpu_processes_no_nvml() -> None:
"""With NVML unavailable, list_gpu_processes returns an empty payload.
This is the branch the shape test skips (no driver): the UI uses
mem_total_bytes is None to render 'data unavailable' instead of
'no processes'.
"""
from nvcurve.hal import monitoring
orig_avail = monitoring._NVML_AVAILABLE
orig_init = monitoring._nvml_initialized
monitoring._NVML_AVAILABLE = False
monitoring._nvml_initialized = False
try:
data = processes.list_gpu_processes(0)
check("empty processes list", data["processes"] == [])
check("mem_total_bytes is None", data["mem_total_bytes"] is None)
finally:
monitoring._NVML_AVAILABLE = orig_avail
monitoring._nvml_initialized = orig_init
def main() -> None:
tests = [
test_proc_info_self,
test_proc_info_missing,
test_proc_info_comm_with_parens,
test_kill_endpoint_validation,
test_kill_endpoint_real,
test_kill_endpoint_refuses_init_kthreadd,
test_get_parent_pid,
test_kill_parent_endpoint,
test_processes_endpoint,
test_list_gpu_processes_shape,
test_list_gpu_processes_no_nvml,
]
for t in tests:
print(f"== {t.__name__} ==")
t()
print()
print(f"{PASS} passed, {FAIL} failed")
if FAIL:
sys.exit(1)
if __name__ == "__main__":
main()