Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions backend/services/system_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_ = [
Expand Down Expand Up @@ -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,
}
Expand All @@ -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":
Expand Down Expand Up @@ -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)
Expand All @@ -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,
}


Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions docs/directory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 38 additions & 0 deletions tests/backend/test_system_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,15 @@ 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 {
"monotonic": monotonic,
"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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand All @@ -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):
Expand Down
31 changes: 31 additions & 0 deletions tests/backend/test_system_stats_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)):
Expand Down
7 changes: 6 additions & 1 deletion tests/frontend/flag_sync_smoke.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions tests/frontend/monitor_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
38 changes: 38 additions & 0 deletions tests/frontend/monitor_system_cards_unit.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,7 @@ <h3 id="monitor-output-title">Process Output</h3>
<div class="monitor-metric-value" id="monitor-memory-value">Not available</div>
<div id="monitor-memory-bar"></div>
<div class="monitor-metric-sub" id="monitor-memory-sub"></div>
<div class="monitor-metric-sub hidden" id="monitor-memory-commit" title="System-wide committed memory / commit limit"></div>
</div>

<div class="card" data-monitor-key="system:disk" data-monitor-label="Disk activity">
Expand Down
10 changes: 10 additions & 0 deletions ui/js/monitor/monitor-system.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@
? `${I.dom.formatBytes(memUsed)} used of ${I.dom.formatBytes(memTotal)}`
: memory.available === true ? "Waiting for first sample" : "");

const committed = I.dom.finiteNonNegativeOrNull(memory.committed_bytes);
const commitLimit = I.dom.finiteNonNegativeOrNull(memory.commit_limit_bytes);
const hasCommit = committed !== null && commitLimit !== null
&& commitLimit > 0 && committed <= commitLimit;
const commitEl = I.dom.byId("monitor-memory-commit");
I.dom.setText(commitEl, hasCommit
? `Committed: ${I.dom.formatBytes(committed)} / ${I.dom.formatBytes(commitLimit)}`
: "");
if (commitEl) commitEl.classList.toggle("hidden", !hasCommit);

const readRate = I.dom.finiteNonNegativeOrNull(disk.read_bytes_per_second);
const writeRate = I.dom.finiteNonNegativeOrNull(disk.write_bytes_per_second);
const hasRates = readRate !== null || writeRate !== null;
Expand Down
Loading