Release YQ (v0.51.687): macOS/non-procfs system-health metrics via optional psutil (#4616)
Release YQ (v0.51.687): macOS/non-procfs system-health metrics via optional psutil (#4616)
This commit is contained in:
4 files changed
+170
-23
No files matched your search
@@ -3,6 +3,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.687] — 2026-06-26 — Release YQ (system-health CPU/RAM metrics work on macOS and other non-procfs platforms)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The system-health panel now reports CPU/RAM on macOS and other platforms without `/proc`.** The aggregate CPU/RAM collectors only read Linux procfs, so on macOS those metrics failed. They now fall back to `psutil` when procfs is unavailable. `psutil` is kept **optional** (listed only as a comment in `requirements.txt`, matching the `edge-tts` opt-in pattern — base installs stay lean): on Linux procfs is always hit first and `psutil` is never imported; on a non-procfs platform with `psutil` installed the fallback works; without it, the affected metric degrades to "unavailable" with no crash. Thanks @jkobject. (#4616)
|
||||
|
||||
## [v0.51.686] — 2026-06-26 — Release YP (internal: enhanced-table copy boundary-case test coverage)
|
||||
|
||||
### Changed
|
||||
|
||||
+50
-22
@@ -1,14 +1,17 @@
|
||||
"""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
|
||||
|
||||
import shutil
|
||||
import time
|
||||
from importlib import import_module
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -19,6 +22,13 @@ _PROC_MEMINFO = Path("/proc/meminfo")
|
||||
_CPU_SAMPLE_SECONDS = 0.05
|
||||
|
||||
|
||||
def _load_optional_psutil():
|
||||
try:
|
||||
return import_module("psutil")
|
||||
except ImportError:
|
||||
raise RuntimeError("psutil_unavailable") from None
|
||||
|
||||
|
||||
def _checked_at() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
@@ -61,14 +71,24 @@ def _cpu_delta_percent(start: tuple[int, int], end: tuple[int, int]) -> float:
|
||||
|
||||
|
||||
def _cpu_percent() -> float:
|
||||
"""Sample aggregate CPU usage without psutil.
|
||||
"""Sample aggregate CPU usage.
|
||||
|
||||
A short local sample avoids storing cross-request state and returns a stable
|
||||
percentage on the first poll. Unsupported platforms raise a safe error code.
|
||||
percentage on the first poll. Linux uses procfs without extra dependencies;
|
||||
platforms without procfs fall back to psutil when it is already available.
|
||||
Unsupported platforms raise a safe error code.
|
||||
"""
|
||||
start = _read_proc_stat_cpu()
|
||||
try:
|
||||
start = _read_proc_stat_cpu()
|
||||
except OSError:
|
||||
psutil = _load_optional_psutil()
|
||||
return _clamp_percent(psutil.cpu_percent(interval=_CPU_SAMPLE_SECONDS))
|
||||
time.sleep(_CPU_SAMPLE_SECONDS)
|
||||
end = _read_proc_stat_cpu()
|
||||
try:
|
||||
end = _read_proc_stat_cpu()
|
||||
except OSError:
|
||||
psutil = _load_optional_psutil()
|
||||
return _clamp_percent(psutil.cpu_percent(interval=0.0))
|
||||
return _cpu_delta_percent(start, end)
|
||||
|
||||
|
||||
@@ -90,20 +110,28 @@ def _read_meminfo_kib() -> dict[str, int]:
|
||||
|
||||
|
||||
def _memory_usage() -> dict[str, int | float]:
|
||||
meminfo = _read_meminfo_kib()
|
||||
total = int(meminfo.get("MemTotal") or 0) * 1024
|
||||
if total <= 0:
|
||||
raise RuntimeError("meminfo_unavailable")
|
||||
available_kib = meminfo.get("MemAvailable")
|
||||
if available_kib is None:
|
||||
available_kib = (
|
||||
meminfo.get("MemFree", 0)
|
||||
+ meminfo.get("Buffers", 0)
|
||||
+ meminfo.get("Cached", 0)
|
||||
+ meminfo.get("SReclaimable", 0)
|
||||
- meminfo.get("Shmem", 0)
|
||||
)
|
||||
available = max(0, int(available_kib) * 1024)
|
||||
try:
|
||||
meminfo = _read_meminfo_kib()
|
||||
except OSError:
|
||||
vm = _load_optional_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")
|
||||
available_kib = meminfo.get("MemAvailable")
|
||||
if available_kib is None:
|
||||
available_kib = (
|
||||
meminfo.get("MemFree", 0)
|
||||
+ meminfo.get("Buffers", 0)
|
||||
+ meminfo.get("Cached", 0)
|
||||
+ meminfo.get("SReclaimable", 0)
|
||||
- meminfo.get("Shmem", 0)
|
||||
)
|
||||
available = max(0, int(available_kib) * 1024)
|
||||
used = max(0, min(total, total - available))
|
||||
return {
|
||||
"used_bytes": used,
|
||||
|
||||
@@ -6,5 +6,9 @@
|
||||
# needs `edge-tts`. It is intentionally NOT a hard dependency — the /api/tts
|
||||
# endpoint returns 503 with an install hint when it's absent. Install it only if
|
||||
# you want server-side Microsoft neural voices: pip install edge-tts
|
||||
#
|
||||
# OPTIONAL: macOS/non-procfs CPU/RAM system-health metrics need `psutil`.
|
||||
# Linux uses procfs without this dependency; install only if you want aggregate
|
||||
# CPU/RAM metrics on platforms without /proc: pip install psutil>=5.9
|
||||
pyyaml>=6.0
|
||||
cryptography>=42.0
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -102,6 +103,115 @@ def test_system_health_payload_partial_and_unavailable_are_graceful(monkeypatch)
|
||||
assert "/home/user" not in repr(unavailable)
|
||||
|
||||
|
||||
def test_system_health_falls_back_to_psutil_when_procfs_is_unavailable(monkeypatch):
|
||||
from api import system_health
|
||||
|
||||
class _MissingProcPath:
|
||||
def open(self, *args, **kwargs):
|
||||
raise FileNotFoundError("/private/proc/path")
|
||||
|
||||
class _FakeMemory:
|
||||
total = 1000
|
||||
available = 250
|
||||
percent = 75.0
|
||||
|
||||
fake_psutil = SimpleNamespace(
|
||||
cpu_percent=lambda interval=0.0: 42.25,
|
||||
virtual_memory=lambda: _FakeMemory(),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "psutil", fake_psutil)
|
||||
monkeypatch.setattr(system_health, "_PROC_STAT", _MissingProcPath())
|
||||
monkeypatch.setattr(system_health, "_PROC_MEMINFO", _MissingProcPath())
|
||||
|
||||
assert system_health._cpu_percent() == 42.2
|
||||
assert system_health._memory_usage() == {
|
||||
"used_bytes": 750,
|
||||
"total_bytes": 1000,
|
||||
"percent": 75.0,
|
||||
}
|
||||
|
||||
|
||||
def test_system_health_missing_optional_psutil_is_safe_unavailable(monkeypatch):
|
||||
from api import system_health
|
||||
|
||||
class _MissingProcPath:
|
||||
def open(self, *args, **kwargs):
|
||||
raise FileNotFoundError("/private/proc/path")
|
||||
|
||||
def missing_psutil(name):
|
||||
if name == "psutil":
|
||||
raise ModuleNotFoundError("No module named 'psutil'")
|
||||
raise AssertionError(f"unexpected import: {name}")
|
||||
|
||||
monkeypatch.setattr(system_health, "_PROC_STAT", _MissingProcPath())
|
||||
monkeypatch.setattr(system_health, "_PROC_MEMINFO", _MissingProcPath())
|
||||
monkeypatch.setattr(system_health, "import_module", missing_psutil)
|
||||
|
||||
for collect in (system_health._cpu_percent, system_health._memory_usage):
|
||||
try:
|
||||
collect()
|
||||
except RuntimeError as exc:
|
||||
assert str(exc) == "psutil_unavailable"
|
||||
else: # pragma: no cover - defensive regression clarity
|
||||
raise AssertionError("missing optional psutil should surface a safe unavailable error")
|
||||
|
||||
|
||||
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
|
||||
@@ -187,7 +297,6 @@ def test_system_health_frontend_polls_visible_and_renders_progress_labels():
|
||||
def test_system_health_backend_uses_no_shell_or_private_process_sources():
|
||||
src = (REPO_ROOT / "api" / "system_health.py").read_text(encoding="utf-8")
|
||||
assert "import subprocess" not in src
|
||||
assert "import psutil" not in src
|
||||
assert "os.environ" not in src
|
||||
assert "ps aux" not in src
|
||||
assert "/proc/self/environ" not in src
|
||||
|
||||
Reference in new issue
Block a user