From 0905adcf433709e19b93ce9db503199626455afa Mon Sep 17 00:00:00 2001 From: jkobject Date: Sun, 21 Jun 2026 13:16:02 +0200 Subject: [PATCH] fix: narrow system health psutil fallback --- api/system_health.py | 35 ++++++++------ tests/test_issue693_system_health_panel.py | 56 ++++++++++++++++++++++ 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/api/system_health.py b/api/system_health.py index 3bb25880..576a9faa 100644 --- a/api/system_health.py +++ b/api/system_health.py @@ -1,8 +1,10 @@ -"""Safe aggregate host resource metrics for the WebUI VPS panel (#693). +"""Safe aggregate host resource metrics for the WebUI system panel (#693). -The browser only needs coarse CPU/RAM/disk usage. Keep this module intentionally -small and dependency-free: no process lists, command strings, user identities, -environment variables, or filesystem topology leave the server. +The browser only needs coarse CPU/RAM/disk usage. Linux uses procfs first; +platforms without procfs (for example macOS) fall back to psutil for aggregate +CPU/RAM metrics. Keep the payload intentionally small: no process lists, +command strings, user identities, environment variables, or filesystem topology +leave the server. """ from __future__ import annotations @@ -71,12 +73,16 @@ def _cpu_percent() -> float: """ try: start = _read_proc_stat_cpu() - time.sleep(_CPU_SAMPLE_SECONDS) - end = _read_proc_stat_cpu() - return _cpu_delta_percent(start, end) - except Exception: + except OSError: psutil = import_module("psutil") return _clamp_percent(psutil.cpu_percent(interval=_CPU_SAMPLE_SECONDS)) + time.sleep(_CPU_SAMPLE_SECONDS) + try: + end = _read_proc_stat_cpu() + except OSError: + psutil = import_module("psutil") + return _clamp_percent(psutil.cpu_percent(interval=0.0)) + return _cpu_delta_percent(start, end) def _read_meminfo_kib() -> dict[str, int]: @@ -99,6 +105,13 @@ def _read_meminfo_kib() -> dict[str, int]: def _memory_usage() -> dict[str, int | float]: try: meminfo = _read_meminfo_kib() + except OSError: + vm = import_module("psutil").virtual_memory() + total = int(getattr(vm, "total", 0) or 0) + if total <= 0: + raise RuntimeError("memory_unavailable") from None + available = max(0, int(getattr(vm, "available", 0) or 0)) + else: total = int(meminfo.get("MemTotal") or 0) * 1024 if total <= 0: raise RuntimeError("meminfo_unavailable") @@ -112,12 +125,6 @@ def _memory_usage() -> dict[str, int | float]: - meminfo.get("Shmem", 0) ) available = max(0, int(available_kib) * 1024) - except Exception: - vm = import_module("psutil").virtual_memory() - total = int(getattr(vm, "total", 0) or 0) - if total <= 0: - raise RuntimeError("memory_unavailable") from None - available = max(0, int(getattr(vm, "available", 0) or 0)) used = max(0, min(total, total - available)) return { "used_bytes": used, diff --git a/tests/test_issue693_system_health_panel.py b/tests/test_issue693_system_health_panel.py index feb42b43..dc447e41 100644 --- a/tests/test_issue693_system_health_panel.py +++ b/tests/test_issue693_system_health_panel.py @@ -131,6 +131,62 @@ def test_system_health_falls_back_to_psutil_when_procfs_is_unavailable(monkeypat } +def test_system_health_procfs_parse_errors_remain_visible(monkeypatch): + from api import system_health + + fake_psutil = SimpleNamespace( + cpu_percent=lambda interval=0.0: 42.25, + virtual_memory=lambda: SimpleNamespace(total=1000, available=250), + ) + monkeypatch.setitem(sys.modules, "psutil", fake_psutil) + monkeypatch.setattr( + system_health, + "_read_proc_stat_cpu", + lambda: (_ for _ in ()).throw(RuntimeError("proc_stat_unavailable")), + ) + monkeypatch.setattr(system_health, "_read_meminfo_kib", lambda: {}) + + try: + system_health._cpu_percent() + except RuntimeError as exc: + assert str(exc) == "proc_stat_unavailable" + else: # pragma: no cover - defensive regression clarity + raise AssertionError("procfs parse RuntimeError should not fall back to psutil") + + try: + system_health._memory_usage() + except RuntimeError as exc: + assert str(exc) == "meminfo_unavailable" + else: # pragma: no cover - defensive regression clarity + raise AssertionError("meminfo invariant RuntimeError should not fall back to psutil") + + +def test_system_health_cpu_second_procfs_read_fallback_does_not_sleep_twice(monkeypatch): + from api import system_health + + calls = [] + + def fake_read_proc_stat_cpu(): + calls.append("proc") + if len(calls) == 1: + return (10, 100) + raise FileNotFoundError("/proc/stat disappeared") + + def fake_sleep(seconds): + calls.append(("sleep", seconds)) + + def fake_cpu_percent(interval=0.0): + calls.append(("psutil", interval)) + return 12.34 + + monkeypatch.setattr(system_health, "_read_proc_stat_cpu", fake_read_proc_stat_cpu) + monkeypatch.setattr(system_health.time, "sleep", fake_sleep) + monkeypatch.setitem(sys.modules, "psutil", SimpleNamespace(cpu_percent=fake_cpu_percent)) + + assert system_health._cpu_percent() == 12.3 + assert calls == ["proc", ("sleep", system_health._CPU_SAMPLE_SECONDS), "proc", ("psutil", 0.0)] + + def test_system_health_route_registered_and_auth_gated(monkeypatch): assert 'parsed.path == "/api/system/health"' in ROUTES_PY assert "build_system_health_payload()" in ROUTES_PY