diff --git a/src/core/sensors.py b/src/core/sensors.py index 60a514e..dcf43a2 100644 --- a/src/core/sensors.py +++ b/src/core/sensors.py @@ -6,6 +6,7 @@ import platform import shutil import subprocess +import threading import time from typing import Any @@ -13,6 +14,11 @@ logger = logging.getLogger(__name__) +# A Windows GPU-counter probe slower than this serves cached values between +# rare retries instead of stalling every sample. +GPU_PROBE_SLOW_SECONDS = 0.2 +GPU_PROBE_BACKOFF_SECONDS = 30.0 + class SensorReader: """Collect metrics and calculate network throughput between reads.""" @@ -20,9 +26,27 @@ class SensorReader: def __init__(self) -> None: self._network = psutil.net_io_counters() self._network_time = time.monotonic() + self._nvidia_smi = shutil.which("nvidia-smi") # PATH scan once; presence is fixed per run. + self._wmi_cimv2 = None # COM objects are created lazily and reused per thread. + self._wmi_thermal = None + self._gpu_values = {"usage": None, "temperature": None} + self._gpu_next_probe = 0.0 - @staticmethod - def _temperatures() -> dict[str, float | None]: + def _cimv2(self): + if self._wmi_cimv2 is None: + import wmi # type: ignore[import-not-found] + + self._wmi_cimv2 = wmi.WMI(namespace=r"root\cimv2") + return self._wmi_cimv2 + + def _thermal(self): + if self._wmi_thermal is None: + import wmi # type: ignore[import-not-found] + + self._wmi_thermal = wmi.WMI(namespace=r"root\wmi") + return self._wmi_thermal + + def _temperatures(self) -> dict[str, float | None]: cpu: float | None = None gpu: float | None = None try: @@ -39,19 +63,16 @@ def _temperatures() -> dict[str, float | None]: if platform.system() == "Windows" and cpu is None: try: - import wmi # type: ignore[import-not-found] - - readings = wmi.WMI(namespace=r"root\wmi").MSAcpi_ThermalZoneTemperature() + readings = self._thermal().MSAcpi_ThermalZoneTemperature() if readings: cpu = round((float(readings[0].CurrentTemperature) / 10) - 273.15, 1) except Exception as error: # noqa: BLE001 - WMI exposes provider-specific COM errors. logger.debug("Windows temperature sensor unavailable: %s", error) return {"cpu": cpu, "gpu": gpu} - @staticmethod - def _gpu() -> dict[str, float | None]: + def _gpu(self) -> dict[str, float | None]: """Read NVIDIA CLI metrics, then vendor-neutral Windows counters.""" - if shutil.which("nvidia-smi"): + if self._nvidia_smi: try: result = subprocess.run( ["nvidia-smi", "--query-gpu=utilization.gpu,temperature.gpu", "--format=csv,noheader,nounits"], @@ -61,20 +82,38 @@ def _gpu() -> dict[str, float | None]: timeout=2, ) usage, temperature = result.stdout.splitlines()[0].split(",", maxsplit=1) - return {"usage": float(usage.strip()), "temperature": float(temperature.strip())} + self._gpu_values = {"usage": float(usage.strip()), "temperature": float(temperature.strip())} + return dict(self._gpu_values) except (OSError, subprocess.SubprocessError, ValueError, IndexError): pass if platform.system() == "Windows": - try: - import wmi # type: ignore[import-not-found] - - engines = wmi.WMI(namespace=r"root\cimv2").Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine() - usage = sum( - float(engine.UtilizationPercentage or 0) for engine in engines if "engtype_3D" in engine.Name - ) - return {"usage": min(100.0, usage), "temperature": None} - except Exception as error: # noqa: BLE001 - WMI exposes provider-specific COM errors. - logger.debug("Windows GPU counters unavailable: %s", error) + now = time.monotonic() + if now >= self._gpu_next_probe: + started = time.perf_counter() + try: + engines = self._cimv2().query( + "SELECT Name, UtilizationPercentage" + " FROM Win32_PerfFormattedData_GPUPerformanceCounters_GPUEngine WHERE Name LIKE '%engtype_3D%'" + ) + usage = sum(float(engine.UtilizationPercentage or 0) for engine in engines) + # An empty counter set means nothing usable was exposed. + self._gpu_values = ( + {"usage": min(100.0, usage), "temperature": None} + if engines + else {"usage": None, "temperature": None} + ) + except Exception as error: # noqa: BLE001 - WMI exposes provider-specific COM errors. + logger.debug("Windows GPU counters unavailable: %s", error) + self._gpu_values = {"usage": None, "temperature": None} + elapsed = time.perf_counter() - started + if elapsed > GPU_PROBE_SLOW_SECONDS: + self._gpu_next_probe = time.monotonic() + GPU_PROBE_BACKOFF_SECONDS + logger.debug( + "Slow GPU probe (%.0f ms); caching values for %.0f s", + elapsed * 1000, + GPU_PROBE_BACKOFF_SECONDS, + ) + return dict(self._gpu_values) return {"usage": None, "temperature": None} @staticmethod @@ -102,15 +141,26 @@ def _network_rates(self) -> dict[str, float]: self._network_time = now return rates - def get_all(self) -> dict[str, Any]: - gpu = self._gpu() - temperatures = self._temperatures() + @staticmethod + def _cancelled(stop: threading.Event | None) -> bool: + return stop is not None and stop.is_set() + + def get_all(self, stop: threading.Event | None = None) -> dict[str, Any]: + # Cooperative cancellation: once stopped, skip the remaining heavy + # probes so shutdown never waits on an in-flight slow provider. + gpu = {"usage": None, "temperature": None} + temperatures = {"cpu": None, "gpu": None} + disks: dict[str, float] = {} + if not self._cancelled(stop): + gpu = self._gpu() + temperatures = self._temperatures() + disks = self._disks() if gpu["temperature"] is not None: temperatures["gpu"] = gpu["temperature"] return { "cpu": psutil.cpu_percent(), "ram": psutil.virtual_memory().percent, - "disks": self._disks(), + "disks": disks, "temps": temperatures, "gpu": gpu, "network": self._network_rates(), diff --git a/src/ui/hud.py b/src/ui/hud.py index 8d81085..854a3df 100644 --- a/src/ui/hud.py +++ b/src/ui/hud.py @@ -2,16 +2,43 @@ from __future__ import annotations -from PyQt6.QtCore import Qt, QTimer +import threading + +from PyQt6.QtCore import Qt, QThread, pyqtSignal from PyQt6.QtGui import QLinearGradient, QPainter, QPen from PyQt6.QtWidgets import QApplication, QMenu, QWidget -from src.core.sensors import SensorReader from src.core.settings_storage import load_settings, save_settings from src.core.theme import color, load_theme from src.ui.layout import create_widgets, load_layout, save_layout +class SensorWorker(QThread): + """Samples metrics off the GUI thread so slow probes never block painting.""" + + ready = pyqtSignal(dict) + + def __init__(self, interval_ms: int) -> None: + super().__init__() + self.interval_ms = max(int(interval_ms), 50) + self._stop = threading.Event() + + def run(self) -> None: + from src.core.sensors import SensorReader # imported in the worker thread; owns its COM objects + + reader = SensorReader() + while True: + data = reader.get_all(stop=self._stop) + if self._stop.is_set(): + break + self.ready.emit(data) + if self._stop.wait(self.interval_ms / 1000): + break + + def stop(self) -> None: + self._stop.set() + + class GlassHUD(QWidget): def __init__(self) -> None: super().__init__() @@ -19,7 +46,6 @@ def __init__(self) -> None: self.theme = load_theme(self.settings["theme"]) self.layout_data = load_layout(self.settings["layout"]) self.widgets = create_widgets(self.layout_data) - self.sensor_reader = SensorReader() self.drag_pos = None self.settings_window = None flags = Qt.WindowType.FramelessWindowHint | Qt.WindowType.Tool @@ -35,13 +61,11 @@ def __init__(self) -> None: position = self.settings["window"] if position["x"] is not None and position["y"] is not None: self.move(position["x"], position["y"]) - self.timer = QTimer(self) - self.timer.timeout.connect(self.update_stats) - self.timer.start(self.settings["refresh_interval_ms"]) - self.update_stats() + self.sensor_worker = SensorWorker(self.settings["refresh_interval_ms"]) + self.sensor_worker.ready.connect(self.apply_stats) # queued across threads + self.sensor_worker.start(QThread.Priority.LowPriority) - def update_stats(self) -> None: - data = self.sensor_reader.get_all() + def apply_stats(self, data: dict) -> None: for widget in self.widgets: widget.update(data) self.update() @@ -50,7 +74,7 @@ def apply_settings(self, settings: dict) -> None: self.settings = save_settings(settings) self.theme = load_theme(self.settings["theme"]) self.setWindowOpacity(self.settings["opacity"]) - self.timer.setInterval(self.settings["refresh_interval_ms"]) + self.sensor_worker.interval_ms = self.settings["refresh_interval_ms"] for widget in self.widgets: widget.set_theme(self.theme) self.update() @@ -113,6 +137,9 @@ def mouseReleaseEvent(self, event) -> None: save_settings(self.settings) def shutdown(self) -> None: + # Stop sampling first so exit never races an in-flight probe. + self.sensor_worker.stop() + self.sensor_worker.wait(5000) # Single exit path so the layout is saved whether the user exits from # the HUD context menu or the tray menu. save_layout(self.widgets, self.width(), self.height(), self.settings["layout"]) diff --git a/tests/test_core.py b/tests/test_core.py index a1e8c3c..899e33f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,4 +1,5 @@ import json +import time from unittest.mock import Mock, patch from src.core.sensors import SensorReader @@ -82,5 +83,85 @@ def test_sensor_schema_is_stable_without_optional_hardware(): assert set(result) == {"cpu", "ram", "disks", "temps", "gpu", "network"} +class _FakeGpuConnection: + def __init__(self, engines): + self.engines = engines + self.queries = [] + + def query(self, wql): + self.queries.append(wql) + return self.engines + + +class _FakeEngine: + Name = "pid_1_eng_0_engtype_3D" + UtilizationPercentage = 40 + + +def test_slow_windows_gpu_probe_backs_off(monkeypatch): + # Regression: probing the GPU counter set every tick pegged a CPU core on + # machines where the provider is slow; slow probes must now be throttled. + import src.core.sensors as sensors_module + + reader = SensorReader() + connection = _FakeGpuConnection([_FakeEngine()]) + + class SlowConnection: + def query(self, wql): + time.sleep(0.25) # above the 0.2 s backoff threshold + return connection.query(wql) + + monkeypatch.setattr(sensors_module.platform, "system", lambda: "Windows") + monkeypatch.setattr(SensorReader, "_cimv2", lambda self: SlowConnection()) + first = reader._gpu() + second = reader._gpu() # inside the backoff window -> served from cache + assert first == {"usage": 40.0, "temperature": None} + assert second == first + assert len(connection.queries) == 1 + + +def test_windows_gpu_query_filters_and_handles_empty_counters(monkeypatch): + import src.core.sensors as sensors_module + + reader = SensorReader() + connection = _FakeGpuConnection([]) + monkeypatch.setattr(sensors_module.platform, "system", lambda: "Windows") + monkeypatch.setattr(SensorReader, "_cimv2", lambda self: connection) + assert reader._gpu() == {"usage": None, "temperature": None} + assert "engtype_3D" in connection.queries[0] # filtering happens server-side + + +def test_nvidia_smi_path_is_resolved_once(monkeypatch): + # Regression: the PATH was rescanned on every sample. + import src.core.sensors as sensors_module + + calls = [] + monkeypatch.setattr(sensors_module.shutil, "which", lambda name: calls.append(name) or "/usr/bin/nvidia-smi") + reader = SensorReader() + monkeypatch.setattr(sensors_module.platform, "system", lambda: "Linux") # skip Windows fallback + reader._gpu() + reader._gpu() + assert len(calls) == 1 + + +def test_get_all_skips_heavy_probes_after_stop(): + import threading + + reader = SensorReader() + stop = threading.Event() + stop.set() + with ( + patch.object(reader, "_gpu") as gpu_mock, + patch.object(reader, "_temperatures") as temps_mock, + patch.object(reader, "_disks") as disks_mock, + ): + result = reader.get_all(stop=stop) + gpu_mock.assert_not_called() + temps_mock.assert_not_called() + disks_mock.assert_not_called() + assert set(result) == {"cpu", "ram", "disks", "temps", "gpu", "network"} # schema stays stable + assert result["gpu"] == {"usage": None, "temperature": None} + + def test_bundled_default_theme_loads(): assert load_theme()["colors"]["text"] diff --git a/tests/test_ui.py b/tests/test_ui.py index c559abe..cb18a4e 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -37,6 +37,7 @@ def test_hud_shutdown_saves_layout(monkeypatch): hud, saved = _make_hud(monkeypatch) hud.shutdown() assert len(saved) == 1 # Regression: every exit path must persist the layout. + assert not hud.sensor_worker.isRunning() # Sampling must stop before quit. app.processEvents()