Skip to content

feat(header): opt-in GPU and CPU clock readings after CPU and MEM - #692

Merged
Ark0N merged 4 commits into
Ark0N:masterfrom
opticon454:feature/host-gpu-clocks
Oct 24, 2026
Merged

Ark0N merged 4 commits into
Ark0N:masterfrom
opticon454:feature/host-gpu-clocks

Conversation

@opticon454

@opticon454 opticon454 commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

What

Two opt-in header readings after CPU and MEM, both off by default (Settings, Header & Panels):

  • GPU Stats adds three separate items: GPU load, graphics memory and the GPU clock.
  • CPU MHz adds the live CPU clock.

Header with GPU, VRAM and CPU MHz

(Captured on a laptop with an Intel GPU under WSL. Windows publishes no clock for an Intel GPU, so the GPU clock item stays hidden there; NVIDIA and AMD report one.)

Why

The header shows CPU and MEM but nothing about the GPU, and local-model and video work leans on it. The clock speeds are the quickest way to see throttling.

How

  • GET /api/system/stats takes ?gpu=1 and ?cpumhz=1. Nothing is read, and nvidia-smi or typeperf.exe is never run, unless the browser asks, and it only asks while the matching setting is on. A host without the sensor omits the field and the header shows nothing for it.
  • src/host-sensors.ts (new, pure parsers exported for tests):
    • GPU: nvidia-smi, else the amdgpu sysfs files, else, inside WSL, the Windows performance counters through typeperf.exe (any vendor). That path gives load and memory in use; the memory total is taken as half of Windows' RAM, the limit Windows applies to integrated GPUs.
    • CPU clock: scaling_cur_freq, else /proc/cpuinfo. Under WSL the Linux side only has a fixed number, so the Windows Processor Frequency counter is used and nothing is shown until it answers.
    • Every typeperf sample takes over a second, so one long-running sampler is kept, replaced every 30 seconds (its wildcard counters are expanded once, at start) and stopped after 30 seconds with no reads. Reads are cached for 2 seconds.
  • The new items are hidden by the hidden attribute; a selector that outranks the Tiles style's own display keeps them hidden there too.
  • Also in this PR: the header stats now keep polling when no session is open. They used to start with the first session and stop with the last, so a fresh install showed CPU --% MEM -- until a session existed, and the new items would never have filled in.
  • Settings (showGpuStats, showCpuMhz, per device, in the strict settings schema), zh-CN strings, a docs paragraph in Settings-Reference.md and a changeset.

Overlap with open PRs

This touches the shared header and settings files, so it shares files with these open PRs (the list pr-watch reported). The overlaps are small hunks next to theirs (new chips, new header items, new schema keys), not changes to the same lines, and it rebases cleanly on current master. I have not tried it against each of them, and will rebase as they land:

The new items are also written to sit with the header divider in my own #689.

Tests

  • test/host-sensors.test.ts: the nvidia-smi, amdgpu, cpuinfo and typeperf parsers, and the sampler's life cycle (first sample, recycling, idle stop, back-off after a failed start).
  • test/header-host-sensors-ui.test.ts: both settings off by default, only the requested readings fetched, items shown only when reported, GPU clock follows GPU Stats and CPU clock follows CPU MHz, and the polling with no session.
  • test/routes/system-routes.test.ts: nothing extra without the query, only the requested readings passed on.
  • test/header-host-sensors.browser.test.ts (real Chromium, listed in BROWSER_TEST_GLOBS): unreported items stay hidden under the Compact, Tiles and Classic styles.
  • Lint, tsc --noEmit, the frontend syntax check and prettier pass. The GPU path through the Windows counters was run on a real Intel laptop under WSL; the NVIDIA and AMD paths are covered by parser tests only, not real hardware.

@Ark0N

Ark0N commented Oct 23, 2026

Copy link
Copy Markdown
Owner

Thank you for this one. It adds opt-in GPU load, VRAM and GPU clock readings plus a CPU clock to the header, read only while the browser asks for them, and the non-WSL paths, the frontend and the tests are in good shape: typecheck, lint, format, the frontend syntax check, the targeted tests and your Chromium test all pass here.

Two things in the WSL typeperf sampler need a fix before merge. I reproduced both by driving createCounterStream with a fake typeperf:

  1. The sampler restarts on every request when two browsers ask for different readings (src/host-sensors.ts:314). read() replaces the running process whenever the counter list differs, and since showGpuStats / showCpuMhz are per device, a browser with GPU Stats on and another with GPU Stats + CPU MHz on alternate on every poll. In the repro that was 16 spawns and 16 kills in about 33 seconds, every read waited for a fresh first sample (so CPU and MEM lag in both browsers, since the route awaits the sensors), and the GPU client was once handed the CPU-only sample. Please sample the union of the four counters whenever any reading is requested on WSL, pick the fields per request afterwards, and add a test with two readers asking for different sets that asserts a single spawn.

  2. A late row from the replaced sampler becomes the new sampler's header (src/host-sensors.ts:277). header lives at stream scope and stop() clears it, but the old child's data handler has no if (child !== started) return; guard (the exit handler at :296 has one). If the killed process had a row in the pipe, that row becomes the header and every sample from the new process parses to {} until the next recycle, so the items disappear for up to 30 seconds. Please add the guard, keep header per begin() call, and cover the late-row case in test/host-sensors.test.ts.

Smaller things for the same round:

  1. src/host-sensors.ts:417: on WSL the Windows counters are read before readGpu(), so a WSL machine with a working nvidia-smi keeps a GPU Engine(*) sampler running (and waits for its first sample) for data that is then discarded. Please try the native readers first and add the GPU counters to the Windows request only when they come back empty.
  2. src/web/schemas.ts:1623: CLAUDE.md (Adding Features, App setting) says per-device keys go in displayKeys and are not added to SettingsUpdateSchema. Please drop the two keys from the schema and strip them in the saveAppSettings() destructure the way showSplitButton and showGitStatus are, and drop the schema assertion from the UI test.
  3. src/web/public/panels-ui.js:6813, :6819, :6822: guard these three this.$(...) writes like the rest of the function, or call _updateHostSensors() after the MEM block, so a missing element can never stop MEM updating.
  4. src/web/public/app.js:2406: the first-session startSystemStatsPolling() now only restarts the interval handleInit already started, so it can go.
  5. src/host-sensors.ts:183, :192, :199: memoize inWsl() and spawn the absolute typeperf.exe / powershell.exe paths directly instead of calling existsSync on /mnt/c in the read path (an ENOENT already lands in your back-off).
  6. src/web/public/index.html:2125: the settings preview says 3.4 GHz while the header shows 3400 MHz.
  7. src/host-sensors.ts:339: export a stop for the sweep interval and the sampler, and call it from the server's shutdown path.

One question only your hardware can answer: does the WSL CPU MHz reading go above the base clock under load? Task Manager's Speed is the Processor Frequency counter scaled by % Processor Performance, and Processor Frequency on its own can sit at the nominal clock with Hyper-V on, which WSL2 enables. If yours stays at or below base while Task Manager shows a boost, multiply it by \Processor Information(_Total)\% Processor Performance / 100.

Once the two sampler fixes and their tests are in, this is ready to merge. The bundled change that keeps the stats polling with no session open is fine to keep in this PR; it will get its own line in the release notes.

@opticon454

Copy link
Copy Markdown
Contributor Author

Thanks @Ark0N. Both sampler fixes and all seven smaller items are in ce7d198b.

  1. Sampler restarts. The sampler no longer depends on what a browser asked for. windowsCounterList() always samples the CPU clock, and the GPU counters are added once, for good, on a machine whose native readers (nvidia-smi, amdgpu) came back empty; a request only picks its fields from the sample. Two browsers with different settings now share one process. createWindowsSource() has tests for that: eight alternating reads spawn once, and adding the GPU counters costs exactly one restart. I also made the 2 second cache per request shape, so two shapes no longer evict each other.
  2. Late row. header is now per begin() call and the data handler has the if (child !== started) return; guard. The test writes a row to the killed process after the replacement began and asserts it is not handed out as a sample (it fails without the guard).
  3. Native readers first. readHostSensors() tries nvidia-smi and amdgpu first. A WSL machine with a working nvidia-smi never starts a GPU Engine(*) sampler, and a request for only the CPU clock never waits on GPU counters.
  4. Schema. showGpuStats and showCpuMhz are out of SettingsUpdateSchema and stripped in the saveAppSettings() destructure next to showGitStatus. The UI test now asserts the strip and that the schema does not mention the key.
  5. panels-ui. _updateHostSensors() runs after the MEM block and its three element writes are guarded.
  6. The first-session startSystemStatsPolling() in app.js is gone.
  7. inWsl() is memoised and typeperf.exe and powershell.exe are spawned by absolute path with no existsSync on the read path (a missing binary lands in the existing back-off).
  8. The settings preview says 3400 MHz.
  9. stopHostSensors() clears the sweep interval (now created on the first read) and stops the sampler; WebServer.stop() calls it.

Your hardware question. I measured on the Intel i7-1255U laptop (base 1700 MHz): Processor Frequency read 1596 to 1676 MHz, idle and with 12 busy processes (64% % Processor Utility), and % Processor Performance stayed between 74 and 96. It never went above the base clock and the performance counter never reached 100, so I saw no boost for the scaling to correct. Multiplying by it would only lower the figure (about 1.5 GHz), so I left the reading as is. I did not have Task Manager's Speed alongside, so a machine that does boost under WSL may still read low.

Typecheck, lint, prettier, the frontend syntax check, the related unit tests (943) and the Chromium test pass here.

@opticon454

Copy link
Copy Markdown
Contributor Author

The one failed check on ce7d198b was test/settings-admin-only-multiuser.test.ts ("a non-admin and the server-wide settings.json"): SyntaxError: Unexpected end of JSON input in settleSettings, which polls settings.json while the server's fire-and-forget quick-start write is still running. The write truncates and then writes, so a read between the two sees an empty file. It is a race in the test's reader and does not involve this PR's code (nothing here touches quick-start or settings writes); it passed locally six times out of six and in the other 616 test files in the same run.

f15afe1d makes that poll treat a half-written file as "not settled yet" and read again, which is all the helper needed. I could not re-run the failed job (it needs admin rights on this repository), so the push also starts a fresh run. The other check, Typecheck & Lint, passed.

@Ark0N

Ark0N commented Oct 23, 2026

Copy link
Copy Markdown
Owner

Thank you, @opticon454, for this one and for the quick second round. It adds opt-in GPU load, graphics memory and GPU clock readings plus a live CPU clock to the header, read only while the browser asks for them.

All nine items from the first round are in ce7d198b, and both sampler fixes come with tests that fail without them. Typecheck, lint, format, the frontend syntax check, the related tests, your Chromium test and the full npm test pass here, and the AMD sysfs and scaling_cur_freq paths read correctly on a real amdgpu machine. Thanks also for measuring the WSL clock question on your laptop.

A few small things remain. I will apply them at merge time unless you would rather push them yourself:

  1. The route waits up to 8 seconds on a cold WSL sampler (src/host-sensors.ts:233 and :320, awaited by src/web/routes/system-routes.ts:1015). read() waits for the first typeperf row, and the route waits for the sensors before it returns CPU and MEM. With a mocked typeperf that has not printed a row yet, readHostSensors({ cpuMhz: true }) took 8002 ms. fetchSystemStats() (src/web/public/panels-ui.js:6715) polls every 2 seconds with no in-flight guard, so requests pile up behind it. The fix: do not wait on a cold sampler in the route (return what is fresh and let the next poll show the reading, as the PR text describes), and skip a poll while one is still pending.
  2. Test for "native readers first" (test/host-sensors.test.ts:99). The ordering in readHostSensors() (src/host-sensors.ts:448-453) works: I checked it with a throwaway test that mocks node:child_process, sets WSL_INTEROP and has nvidia-smi answer, and no GPU Engine counter reached typeperf. A test of that shape pins it.
  3. test/header-host-sensors-ui.test.ts:145 checks that schemas.ts has no showCpuMhz. Please check showGpuStats there too.
  4. Remember failures for a while. A single nvidia-smi timeout on WSL adds the GPU counters for the life of the server (src/host-sensors.ts:452), a failing PowerShell is retried with its 8 second timeout on every GPU poll (:203), and a host without nvidia-smi tries to spawn it on every poll (:403). A one-minute back-off, like the sampler's own, covers all three.
  5. src/web/public/index.html:197 and :201: nothing styles the stat-mhz class. Keep it only if feat(header): resizable toolbar divider that collapses the usage readings as it narrows #689 uses it.
  6. docs/architecture-invariants.md:1036 (Header stats styles): one sentence saying the opt-in items rely on the [hidden] rule at src/web/public/styles.css:2257 outranking the Tiles display, so a later style rule does not unhide them.

None of these changes the design. Once they are in, this gets merged.

@opticon454

Copy link
Copy Markdown
Contributor Author

Thanks @Ark0N. All six are pushed in 23a9d78c, so there is nothing for you to apply.

  1. A cold sampler is no longer waited for. createCounterStream's firstSampleMs now defaults to 0: a read with no fresh sample answers undefined at once and the next poll shows the reading, as the PR text says. The route therefore never holds CPU and MEM back, and fetchSystemStats() skips a poll while one is pending (with an 8 s AbortSignal.timeout so a hung server cannot wedge it). Tests: a default stream answers undefined without waiting and the next read has the sample; the helper test runs three polls with the first held and sees one request.
  2. Native readers first is pinned. test/host-sensors-readers.test.ts mocks child_process and the sysfs reads, sets WSL_INTEROP, and checks that with nvidia-smi answering no typeperf is spawned at all; that a CPU MHz request starts the sampler with only the Processor Frequency counter; and that with nvidia-smi failing the GPU counters are added.
  3. The UI test now asserts that neither showCpuMhz nor showGpuStats is in schemas.ts.
  4. Failures are remembered for a minute. nvidia-smi is skipped for 60 s after it fails or times out (a host without one no longer spawns it on every poll); a failing PowerShell is retried after 60 s, not on every GPU poll; and the GPU counters stay in the Windows sampler only for 120 s after the last GPU read that needed them, so a single nvidia-smi timeout costs one sampler restart instead of a GPU sampler for the life of the server. Each has a test with a moved clock.
  5. The unused stat-mhz class is gone from both items (feat(header): resizable toolbar divider that collapses the usage readings as it narrows #689 does not use it).
  6. docs/architecture-invariants.md (Header stats styles) now says the opt-in items rely on the [hidden] rule outranking the Tiles style's display, with the specificity to keep.

Typecheck, lint, prettier, the frontend syntax check, the related unit tests (888 across 47 files) and the Chromium test pass here.

@Ark0N

Ark0N commented Oct 24, 2026

Copy link
Copy Markdown
Owner

Thank you, @opticon454, for the third round. This PR adds opt-in GPU load, graphics memory and GPU clock readings plus a live CPU clock to the header, read only while the browser asks for them.

All six items from the last round are in 23a9d78c, each with a test, and the round-one fixes still hold. Typecheck, lint, format, the frontend syntax check, the related tests, your Chromium test and the full npm test pass here.

A few small things remain. I will apply them at merge time, so there is nothing you need to push:

  1. The stats route still waits on the PowerShell RAM probe (src/host-sensors.ts:449). gpuFromWindows() awaits readWindowsRamMB(), and the route awaits the sensors before it answers CPU and MEM (src/web/routes/system-routes.ts:1015), so on a WSL integrated GPU the first GPU poll with a sample waits for PowerShell (up to its 8 s timeout if it hangs, then once per minute while it fails). With a mocked PowerShell that answers after 3 s, the read waited the full 3 s. I will keep the resolved RAM value, start the probe without awaiting it, and return the memory in use alone until it answers; _updateHostSensors() already draws VRAM without a ring in that case.
  2. test/host-sensors.test.ts:236: the title says the GPU counters are added "for good", but since the last round they leave again after 120 s. I will rename it.
  3. test/host-sensors.test.ts:2: the @fileoverview mentions the route's query handling and the header defaults, which live in the other two test files. I will describe what this file covers.
  4. src/host-sensors.ts:426: the JSDoc "NVIDIA, then AMD: the readers that need nothing from Windows" sits on nvidiaSkipUntil, right above readNativeGpu()'s own. I will make it describe the back-off deadline.
  5. docs/wiki/Settings-Reference.md:68: the new items live inside System Stats and nothing is fetched while that chip is off. I will add a clause saying so.

Thanks also for the settleSettings fix and for keeping the stats polling with no session open; both go in with this, and the polling change gets its own line in the release notes.

@Ark0N
Ark0N merged commit 23a9d78 into Ark0N:master Oct 24, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Oct 24, 2026
host-sensors: never await the PowerShell RAM probe on a read; keep the resolved total and show the memory in use alone until it answers (test with a held PowerShell)
test: rename the Windows sampler test that said the GPU counters are added "for good"
test: make host-sensors.test.ts's @fileoverview describe what that file covers
host-sensors: make the nvidiaSkipUntil JSDoc describe the back-off deadline
docs: Settings Reference says the host readings live inside System Stats and nothing is fetched while it is off

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 24, 2026
…ngs after CPU and MEM

Conflict in config/test-suites.ts resolved by keeping both sides: #689's
header-divider and #692's header-host-sensors browser tests are both
listed in BROWSER_TEST_GLOBS.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 24, 2026
…ss ones at Compact tiny (release review)

#689's density steps hide every `.header-system-stats .stat-label` once the
toolbar is narrowed, and #692's readings (GPU, VRAM, GPU MHz, CPU MHz) have
nothing else naming them: at tight, dense and tiny the header showed "40%"
beside CPU's "12%", "3.2G" beside MEM's "7.9G", and two bare "1800 MHz" /
"4200 MHz" with no way to tell the GPU clock from the CPU one. At Compact +
tiny, where values are hidden too, the two clocks (no ring) were left as
0-px items that still took an 8 px flex gap each. Neither PR shows it alone.

- The four host items carry `stat-item--host`, excluded from the density
  label hide, so they keep their labels at every step.
- `stat-item--ringless` (static on the two clocks, toggled by
  `_updateHostSensors` on VRAM while its total, and so its ring, is unknown)
  is hidden at Compact + tiny instead of staying an empty item.

Tests: the browser test drives every density in all three styles against
the real markup and styles.css (it fails on the previous files), and the
unit test pins the VRAM ringless toggle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ark0N

Ark0N commented Oct 24, 2026

Copy link
Copy Markdown
Owner

Merged, thank you! This ships in 1.45.0.

At merge the stats route stopped waiting on the PowerShell RAM probe: it shows the memory in use until the total answers. The release review found two more things, both fixed:

  • With feat(header): resizable toolbar divider that collapses the usage readings as it narrows #689's divider narrowing the toolbar, your readings lost their labels and the two clocks read as bare "1800 MHz" / "4200 MHz". The host items now keep their labels at every density (fb83a5c).
  • execFile's timeout only signals the child, and its callback waits for the exit, so an nvidia-smi stuck on a hung driver held every ?gpu=1 request, CPU and MEM included. run() now has its own 1.5 s deadline, and the 60 s backoff arms as it does for any failing reader (40158ce).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants