diff --git a/backend/services/system_stats.py b/backend/services/system_stats.py index 884a29f0..d6e54ddc 100644 --- a/backend/services/system_stats.py +++ b/backend/services/system_stats.py @@ -393,6 +393,41 @@ def collect_windows_memory(): return (float(total - available), float(total)) +class _PERFORMANCE_INFORMATION(ctypes.Structure): + _fields_ = [ + ("cb", ctypes.c_uint32), + ("CommitTotal", ctypes.c_size_t), + ("CommitLimit", ctypes.c_size_t), + ("CommitPeak", ctypes.c_size_t), + ("PhysicalTotal", ctypes.c_size_t), + ("PhysicalAvailable", ctypes.c_size_t), + ("SystemCache", ctypes.c_size_t), + ("KernelTotal", ctypes.c_size_t), + ("KernelPaged", ctypes.c_size_t), + ("KernelNonpaged", ctypes.c_size_t), + ("PageSize", ctypes.c_size_t), + ("HandleCount", ctypes.c_uint32), + ("ProcessCount", ctypes.c_uint32), + ("ThreadCount", ctypes.c_uint32), + ] + + +def collect_windows_memory_commit(): + """System-wide ``(committed_bytes, limit_bytes)`` via ``GetPerformanceInfo``.""" + psapi = ctypes.WinDLL("psapi", use_last_error=True) + get_info = psapi.GetPerformanceInfo + get_info.argtypes = [ctypes.POINTER(_PERFORMANCE_INFORMATION), ctypes.c_uint32] + get_info.restype = ctypes.c_int + info = _PERFORMANCE_INFORMATION() + info.cb = ctypes.sizeof(info) + if not get_info(ctypes.byref(info), info.cb): + return None + if info.PageSize <= 0 or info.CommitLimit <= 0 or info.CommitTotal > info.CommitLimit: + return None + return (int(info.CommitTotal) * int(info.PageSize), + int(info.CommitLimit) * int(info.PageSize)) + + # -------------------------------------------------------------------------- class _PDH_RAW_COUNTER(ctypes.Structure): _fields_ = [ @@ -663,6 +698,7 @@ def collect_system_counters(ctx, platform_name): "wall": time.time(), "cpu": None, "memory": None, + "memory_commit": None, "disk": None, "disk_usage": None, } @@ -677,6 +713,7 @@ def collect_system_counters(ctx, platform_name): collectors = { "cpu": collect_windows_cpu, "memory": collect_windows_memory, + "memory_commit": collect_windows_memory_commit, "disk": collect_windows_disk_counters, } elif platform_name == "darwin": @@ -1671,13 +1708,22 @@ def _build_cpu_metric(previous, current_cpu, interval_ok): return {"available": True, "percent": percent} -def _build_memory_metric(current_memory): +def _build_memory_metric(current_memory, current_commit=None): + commit_fields = {"committed_bytes": None, "commit_limit_bytes": None} + if current_commit is not None: + committed, limit = (finite_non_negative(value) for value in current_commit) + if committed is not None and limit is not None and limit > 0 and committed <= limit: + commit_fields = { + "committed_bytes": int(committed), + "commit_limit_bytes": int(limit), + } if current_memory is None: return { "available": False, "used_bytes": None, "total_bytes": None, "percent": None, + **commit_fields, } used, total = current_memory used = finite_non_negative(used) @@ -1688,12 +1734,14 @@ def _build_memory_metric(current_memory): "used_bytes": None, "total_bytes": None, "percent": None, + **commit_fields, } return { "available": True, "used_bytes": int(used), "total_bytes": int(total), "percent": usage_percent(used, total), + **commit_fields, } @@ -1848,7 +1896,7 @@ def collect_sample(ctx, previous, allow_probe_cache=True): "interval_seconds": interval_seconds if interval_ok else None, "system": { "cpu": _build_cpu_metric(previous, counters.get("cpu"), interval_ok), - "memory": _build_memory_metric(counters.get("memory")), + "memory": _build_memory_metric(counters.get("memory"), counters.get("memory_commit")), "disk": _build_disk_metric(previous, counters, interval_seconds, interval_ok), }, "gpus": gpu_devices, diff --git a/docs/directory.md b/docs/directory.md index c60a1b58..ca7f61d8 100644 --- a/docs/directory.md +++ b/docs/directory.md @@ -751,6 +751,19 @@ Frontend downloader controls, status rendering, progress polling, cancel handlin --- +## Monitor Memory + +The Memory card's percentage, bar, and used/total reading measure physical RAM. +On Windows, a second line shows system-wide committed memory and its current +commit limit, matching Task Manager's Committed reading. The backend reads +`CommitTotal` and `CommitLimit` through +[`GetPerformanceInfo`](https://learn.microsoft.com/en-us/windows/win32/api/psapi/nf-psapi-getperformanceinfo) +and multiplies each by `PageSize`. The `/api/system-stats` memory object includes +optional `committed_bytes` and `commit_limit_bytes` fields; they are null on other +platforms or when the independent commit collector is unavailable. The frontend +hides that line when readings are unavailable, while physical RAM keeps its own +availability state. + ## Monitor Disk Activity The `system:disk` card shows read/write throughput instead of capacity, preserving its layout and visibility preference. The `/api/system-stats` disk object retains capacity fields for compatibility and adds `io_available` (counter availability, independent of capacity) and `io_label` (the measured scope). Rates stay null during warmup, long sampling gaps, counter rollback, or a source change; a valid zero is Idle. diff --git a/tests/backend/test_system_stats.py b/tests/backend/test_system_stats.py index 578e8ae6..97924a8a 100644 --- a/tests/backend/test_system_stats.py +++ b/tests/backend/test_system_stats.py @@ -62,6 +62,7 @@ def make_counters( disk_write=500_000, disk_source="dev:8:0", memory=(4.0 * 1024 ** 3, 16.0 * 1024 ** 3), + memory_commit=None, disk_usage=(500.0 * 1024 ** 3, 1000.0 * 1024 ** 3), ): return { @@ -69,6 +70,7 @@ def make_counters( "wall": wall, "cpu": {"source": "cpu:/proc/stat", "total": cpu_total, "idle": cpu_idle}, "memory": memory, + "memory_commit": memory_commit, "disk": { "source": disk_source, "bytes_read": disk_read, @@ -109,6 +111,28 @@ def test_first_sample_has_null_rates(self): # Capacity is not a rate: it is available on the very first sample. self.assertIsNotNone(disk["percent"]) + def test_memory_commit_is_independent_of_physical_ram(self): + for physical in ((4 * 1024 ** 3, 16 * 1024 ** 3), None): + with self.subTest(physical=physical): + data, _ = self._sample(None, make_counters( + memory=physical, memory_commit=(28 * 1024 ** 3, 128 * 1024 ** 3), + )) + memory = data["system"]["memory"] + self.assertEqual(memory["available"], physical is not None) + self.assertEqual(memory["committed_bytes"], 28 * 1024 ** 3) + self.assertEqual(memory["commit_limit_bytes"], 128 * 1024 ** 3) + self.assertEqual(memory["percent"], 25.0 if physical else None) + + def test_missing_or_invalid_commit_preserves_physical_ram(self): + for commit in (None, (-1, 100), (10, 0), (101, 100), (float("nan"), 100)): + with self.subTest(commit=commit): + data, _ = self._sample(None, make_counters(memory_commit=commit)) + memory = data["system"]["memory"] + self.assertTrue(memory["available"]) + self.assertEqual(memory["percent"], 25.0) + self.assertIsNone(memory["committed_bytes"]) + self.assertIsNone(memory["commit_limit_bytes"]) + def test_valid_interval_produces_rates(self): previous = { "monotonic": 98.0, @@ -357,6 +381,7 @@ def test_windows_collector_failure_marks_metric_unavailable(self): ctx = make_context(platform="win32") with mock.patch.object(svc, "collect_windows_cpu", side_effect=OSError("boom")), \ mock.patch.object(svc, "collect_windows_memory", return_value=None), \ + mock.patch.object(svc, "collect_windows_memory_commit", return_value=None), \ mock.patch.object(svc, "collect_windows_disk_counters", side_effect=OSError("no counters")), \ mock.patch("shutil.disk_usage", side_effect=OSError("no disk")): counters = svc.collect_system_counters(ctx, "win32") @@ -373,6 +398,19 @@ def test_windows_collector_failure_marks_metric_unavailable(self): self.assertFalse(data["system"]["memory"]["available"]) self.assertFalse(data["system"]["disk"]["available"]) + def test_windows_commit_failure_is_logged_without_losing_ram(self): + ctx = make_context(platform="win32") + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr), \ + mock.patch.object(svc, "collect_windows_cpu", return_value=None), \ + mock.patch.object(svc, "collect_windows_memory", return_value=(1024, 2048)), \ + mock.patch.object(svc, "collect_windows_memory_commit", side_effect=OSError("commit unavailable")), \ + mock.patch.object(svc, "collect_windows_disk_counters", return_value=None): + counters = svc.collect_system_counters(ctx, "win32") + self.assertEqual(counters["memory"], (1024, 2048)) + self.assertIsNone(counters["memory_commit"]) + self.assertIn("memory_commit collector failed: OSError: commit unavailable", stderr.getvalue()) + class NativeDiskCountersTests(unittest.TestCase): def make_pdh(self): diff --git a/tests/backend/test_system_stats_native.py b/tests/backend/test_system_stats_native.py index 8f77b8a7..ff74e611 100644 --- a/tests/backend/test_system_stats_native.py +++ b/tests/backend/test_system_stats_native.py @@ -8,6 +8,37 @@ class WindowsCollectorTests(unittest.TestCase): + def memory_commit(self, committed, limit, page_size=4096, success=True): + def get_info(pointer, size): + expected_size = 104 if svc.ctypes.sizeof(svc.ctypes.c_size_t) == 8 else 56 + self.assertEqual(size, expected_size, "Windows PERFORMANCE_INFORMATION ABI size") + self.assertEqual(pointer._obj.cb, size) + pointer._obj.CommitTotal = committed + pointer._obj.CommitLimit = limit + pointer._obj.PageSize = page_size + return success + + psapi = SimpleNamespace(GetPerformanceInfo=get_info) + with mock.patch.object(svc.ctypes, "WinDLL", return_value=psapi, create=True) as dll: + result = svc.collect_windows_memory_commit() + dll.assert_called_once_with("psapi", use_last_error=True) + return result + + def test_memory_commit_converts_system_pages_to_bytes(self): + for page_size in (4096, 16384): + with self.subTest(page_size=page_size): + self.assertEqual(self.memory_commit(7000000, 32000000, page_size), + (7000000 * page_size, 32000000 * page_size)) + self.assertEqual(self.memory_commit(0, 100), (0, 100 * 4096)) + if svc.ctypes.sizeof(svc.ctypes.c_size_t) == 8: + self.assertEqual(self.memory_commit(2 ** 32 + 3, 2 ** 33 + 7), + ((2 ** 32 + 3) * 4096, (2 ** 33 + 7) * 4096)) + + def test_memory_commit_failed_and_invalid_samples_are_unavailable(self): + for values in ((10, 100, 4096, False), (0, 0), (101, 100), (10, 100, 0)): + with self.subTest(values=values): + self.assertIsNone(self.memory_commit(*values)) + def cpu(self, idle, kernel, user, success=True): def get_times(idle_ptr, kernel_ptr, user_ptr): for pointer, ticks in ((idle_ptr, idle), (kernel_ptr, kernel), (user_ptr, user)): diff --git a/tests/frontend/flag_sync_smoke.cjs b/tests/frontend/flag_sync_smoke.cjs index 8820399c..71acc0c6 100644 --- a/tests/frontend/flag_sync_smoke.cjs +++ b/tests/frontend/flag_sync_smoke.cjs @@ -1733,7 +1733,10 @@ async function runScenario(browser, port, verify) { interval_seconds: 2.0, system: { cpu: { available: true, percent: 18.4 }, - memory: { available: true, used_bytes: 12884901888, total_bytes: 34359738368, percent: 37.5 }, + memory: { + available: true, used_bytes: 12884901888, total_bytes: 34359738368, percent: 37.5, + committed_bytes: 28 * 1024 ** 3, commit_limit_bytes: 128 * 1024 ** 3, + }, disk: { available: true, path_label: "Application disk", @@ -4214,6 +4217,8 @@ async function runScenario(browser, port, verify) { // System/GPU cards render from the mocked endpoint while visible. await page.waitForFunction(() => document.getElementById("monitor-cpu-value")?.textContent === "18.4%"); assert.equal(await page.textContent("#monitor-memory-value"), "37.5%"); + assert.equal(await page.textContent("#monitor-memory-commit"), "Committed: 28.0 GB / 128 GB"); + assert.equal(await page.locator("#monitor-memory-commit").isVisible(), true); assert.equal(await page.textContent("#monitor-disk-read"), "1.2 MB/s"); assert.equal(await page.textContent("#monitor-disk-write"), "410 KB/s"); assert.equal(await page.textContent("#monitor-disk-activity"), "Reading and writing"); diff --git a/tests/frontend/monitor_harness.cjs b/tests/frontend/monitor_harness.cjs index aa8fe4b4..682c10a2 100644 --- a/tests/frontend/monitor_harness.cjs +++ b/tests/frontend/monitor_harness.cjs @@ -346,6 +346,7 @@ function createMonitorHarness() { mount(`monitor-${prefix}-sub`); } mount("monitor-disk-io"); + mount("monitor-memory-commit").classList.add("hidden"); mount("monitor-disk-activity"); mount("monitor-disk-sub"); mount("monitor-disk-read", "span"); diff --git a/tests/frontend/monitor_system_cards_unit.cjs b/tests/frontend/monitor_system_cards_unit.cjs index d545dee0..09575058 100644 --- a/tests/frontend/monitor_system_cards_unit.cjs +++ b/tests/frontend/monitor_system_cards_unit.cjs @@ -111,6 +111,44 @@ test("CPU warmup", async (t) => { }); +test("committed memory updates independently and hides missing or invalid readings", async (t) => { + const fixture = createMonitorHarness(); + t.after(fixture.dispose); + const { monitorUi, documentStub, makeSample, wait } = fixture; + const commitEl = documentStub.getElementById("monitor-memory-commit"); + for (const [commit, expected] of [ + [{ committed_bytes: 28 * 1024 ** 3, commit_limit_bytes: 128 * 1024 ** 3 }, "Committed: 28.0 GB / 128 GB"], + [{ committed_bytes: 0, commit_limit_bytes: 128 * 1024 ** 3 }, "Committed: 0 B / 128 GB"], + [{ committed_bytes: null, commit_limit_bytes: null }, ""], + [{ committed_bytes: 1 }, ""], + [{}, ""], + [{ committed_bytes: -1, commit_limit_bytes: 100 }, ""], + [{ committed_bytes: 101, commit_limit_bytes: 100 }, ""], + [{ committed_bytes: 1, commit_limit_bytes: 0 }, ""], + [{ committed_bytes: "bad", commit_limit_bytes: 100 }, ""], + [{ committed_bytes: 32 * 1024 ** 3, commit_limit_bytes: 128 * 1024 ** 3 }, "Committed: 32.0 GB / 128 GB"], + ]) { + monitorUi.configure({ fetchJson: async () => makeSample({ system: { memory: { + available: true, used_bytes: 12 * 1024 ** 3, total_bytes: 32 * 1024 ** 3, + percent: 37.5, ...commit, + } } }) }); + monitorUi.recheck(); + await wait(80); + assert.equal(commitEl.textContent, expected); + assert.equal(commitEl.classList.contains("hidden"), expected === ""); + assert.equal(documentStub.getElementById("monitor-memory-value").textContent, "37.5%"); + assert.equal(documentStub.getElementById("monitor-memory-sub").textContent, "12.0 GB used of 32.0 GB"); + } + monitorUi.configure({ fetchJson: async () => makeSample({ system: { memory: { + available: false, committed_bytes: 28 * 1024 ** 3, commit_limit_bytes: 128 * 1024 ** 3, + } } }) }); + monitorUi.recheck(); + await wait(80); + assert.equal(commitEl.textContent, "Committed: 28.0 GB / 128 GB"); + assert.equal(commitEl.classList.contains("hidden"), false); + assert.equal(documentStub.getElementById("monitor-memory-value").textContent, "Not available"); +}); + test("partial readings", async (t) => { const fixture = createMonitorHarness(); t.after(fixture.dispose); diff --git a/ui/index.html b/ui/index.html index 87620f6b..c487d506 100644 --- a/ui/index.html +++ b/ui/index.html @@ -993,6 +993,7 @@