From e1d64718080cc881630d26c53731755dc9f520fc Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:36:10 +0800 Subject: [PATCH 1/4] feat(header): opt-in GPU and CPU clock readings after CPU and MEM --- .changeset/host-gpu-clocks.md | 5 + config/test-suites.ts | 1 + docs/wiki/Settings-Reference.md | 9 + src/host-sensors.ts | 432 +++++++++++++++++++++++ src/web/public/app.js | 13 +- src/web/public/i18n.js | 2 + src/web/public/index.html | 26 ++ src/web/public/panels-ui.js | 55 ++- src/web/public/settings-ui.js | 9 +- src/web/public/styles.css | 7 + src/web/routes/system-routes.ts | 12 +- src/web/schemas.ts | 3 + test/header-host-sensors-ui.test.ts | 144 ++++++++ test/header-host-sensors.browser.test.ts | 48 +++ test/host-sensors.test.ts | 176 +++++++++ test/routes/system-routes.test.ts | 17 + 16 files changed, 947 insertions(+), 12 deletions(-) create mode 100644 .changeset/host-gpu-clocks.md create mode 100644 src/host-sensors.ts create mode 100644 test/header-host-sensors-ui.test.ts create mode 100644 test/header-host-sensors.browser.test.ts create mode 100644 test/host-sensors.test.ts diff --git a/.changeset/host-gpu-clocks.md b/.changeset/host-gpu-clocks.md new file mode 100644 index 000000000..66d4a8740 --- /dev/null +++ b/.changeset/host-gpu-clocks.md @@ -0,0 +1,5 @@ +--- +"aicodeman": minor +--- + +Opt-in header readings after CPU and MEM. **GPU Stats** adds three items: GPU load, graphics memory and the GPU clock (each only when the host reports it). **CPU MHz** adds the live CPU clock. Both chips are in Settings, Header & Panels and off by default. `GET /api/system/stats` takes `?gpu=1` and `?cpumhz=1`; nothing is read unless the browser asks, and a host without the sensor simply shows nothing. GPU: NVIDIA through `nvidia-smi`, AMD through sysfs, and inside WSL any vendor (Intel included) through the Windows performance counters, which give load and memory in use but no GPU clock. Inside WSL the CPU clock is also read from Windows, since WSL's own figure never changes. The header also keeps polling its stats when no session is open, so CPU and MEM no longer sit at `--` until one is. diff --git a/config/test-suites.ts b/config/test-suites.ts index ae866007e..c55b18adb 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -26,6 +26,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/tab-rail-search.browser.test.ts', 'test/tab-focus.browser.test.ts', 'test/session-sidebar-ux.browser.test.ts', + 'test/header-host-sensors.browser.test.ts', 'test/session-options-responsive.browser.test.ts', 'test/inline-rename.test.ts', 'test/opencode-resize.test.ts', diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 89e09246c..612e5fd16 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -65,6 +65,15 @@ Manager, Attachments, File Viewer, Multi-monitor, Split, Tiles, Plan Usage, Life Project Insights, File Browser, Subagents, Approvals Inbox, Read My Mind, Ultracode Agents, Ultracode Windows, Cron. +**Host readings** (opt-in, both off by default and per device): **GPU Stats** adds three items after CPU and MEM: GPU load, graphics memory and the GPU clock, +and **CPU MHz** adds the live CPU clock. An item appears only when the +host reports it, so a machine with no readable GPU shows nothing extra. Sources: `nvidia-smi` or the amdgpu +sysfs files for the GPU; inside WSL, the Windows performance counters through `typeperf.exe`, which cover +any vendor (an Intel GPU gives load and memory in use, but Windows publishes no clock for it, and its memory +total is taken as half of Windows' RAM, the limit Windows applies to integrated GPUs); and +`scaling_cur_freq`, `/proc/cpuinfo` or, inside WSL, the Windows `Processor Frequency` counter for the CPU +clock. Nothing is read, and `nvidia-smi` and `typeperf.exe` are never run, while both are off. + **Bottom bar** (below the chips): **Git status** shows a small indicator at the right of the bottom bar, off by default and per device. It reads `● N` uncommitted files, `↑ N` commits not pushed, `⚠ N` merge conflicts, `? N` repositories git could not read, or `✓` when everything is diff --git a/src/host-sensors.ts b/src/host-sensors.ts new file mode 100644 index 000000000..07f9263ca --- /dev/null +++ b/src/host-sensors.ts @@ -0,0 +1,432 @@ +/** + * @fileoverview Optional host readings for the header stats: GPU load and memory, and the live CPU and GPU clock + * speeds. + * + * Every reader is opt-in per request (the route only calls the ones the browser asks for, and the browser only + * asks when the matching setting is on), answers `undefined` when the host has no such sensor, and never + * throws. Reads are cached for a couple of seconds so several open tabs cost one read. + * + * Sources, with no extra dependency: + * - GPU: `nvidia-smi` (NVIDIA), else the amdgpu sysfs files (`gpu_busy_percent`, `mem_info_vram_*`, + * `pp_dpm_sclk`), else, inside WSL, the Windows performance counters through `typeperf.exe` (any vendor, + * Intel included: load and memory in use, but Windows publishes no GPU clock for them). + * - CPU clock: inside WSL the Windows `Processor Frequency` counter (WSL's own cpuinfo is a fixed number), + * else the mean of `scaling_cur_freq` across cores, else the `cpu MHz` lines of /proc/cpuinfo. + * + * The parsers are pure and exported so the tests need no hardware. + * + * @module host-sensors + */ + +import { execFile, spawn, type ChildProcess } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +export interface GpuReading { + /** Core utilisation, 0 to 100. */ + percent: number; + /** Graphics memory in use as a share of what is available, 0 to 100, when that is known. */ + memPercent?: number; + memUsedMB?: number; + memTotalMB?: number; + /** Current graphics clock in MHz, when known. */ + mhz?: number; +} + +export interface HostSensors { + gpu?: GpuReading; + cpuMhz?: number; +} + +export interface SensorRequest { + /** GPU load, graphics memory and clock. */ + gpu?: boolean; + cpuMhz?: boolean; +} + +const CACHE_MS = 2000; +/** A counter sample older than this (a few sampling intervals) is no longer a live reading. */ +const FRESH_MS = 10000; +const EXEC_TIMEOUT_MS = 1500; +const SYS_DRM = '/sys/class/drm'; +const SYS_CPU = '/sys/devices/system/cpu'; +const TYPEPERF = '/mnt/c/Windows/System32/typeperf.exe'; +const POWERSHELL = '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'; +const MB = 1048576; + +const clampPercent = (n: number) => Math.min(100, Math.max(0, Math.round(n))); +const finite = (n: number) => (Number.isFinite(n) ? n : undefined); + +/** Parse `nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,clocks.gr --format=csv,noheader,nounits`. */ +export function parseNvidiaSmi(output: string): GpuReading | undefined { + const line = output.split('\n').find((l) => l.trim()); + if (!line) return undefined; + const [util, used, total, clock] = line.split(',').map((s) => finite(Number(s.trim()))); + if (util === undefined) return undefined; + const reading: GpuReading = { percent: clampPercent(util) }; + if (used !== undefined && total !== undefined && total > 0) { + reading.memUsedMB = Math.round(used); + reading.memTotalMB = Math.round(total); + reading.memPercent = clampPercent((used / total) * 100); + } + if (clock !== undefined && clock > 0) reading.mhz = Math.round(clock); + return reading; +} + +/** The active line of an amdgpu `pp_dpm_sclk` table (`1: 1800Mhz *`). */ +export function parseDpmClock(text: string): number | undefined { + const active = text.split('\n').find((l) => l.trim().endsWith('*')); + const mhz = active ? /(\d+)\s*mhz/i.exec(active)?.[1] : undefined; + return mhz ? Number(mhz) : undefined; +} + +/** Mean of per-core `scaling_cur_freq` values (kHz) as MHz. */ +export function meanKhzAsMhz(values: number[]): number | undefined { + const good = values.filter((v) => Number.isFinite(v) && v > 0); + if (!good.length) return undefined; + return Math.round(good.reduce((a, b) => a + b, 0) / good.length / 1000); +} + +/** Mean of the `cpu MHz : 3412.5` lines of /proc/cpuinfo. */ +export function parseCpuInfoMhz(text: string): number | undefined { + const values = [...text.matchAll(/^cpu MHz\s*:\s*([\d.]+)/gim)].map((m) => Number(m[1])); + return meanKhzAsMhz(values.map((v) => v * 1000)); +} + +export interface WindowsCounters { + gpu?: { percent: number; memUsedMB: number; dedicated: boolean }; + cpuMhz?: number; +} + +const ENGINE = + /GPU Engine\(.*?_luid_(0x[0-9a-f]+_0x[0-9a-f]+)_phys_\d+_eng_\d+_engtype_([^)]*)\)\\Utilization Percentage/i; +const ADAPTER_MEMORY = /GPU Adapter Memory\(luid_(0x[0-9a-f]+_0x[0-9a-f]+)_phys_\d+\)\\(Shared|Dedicated) Usage/i; +const CPU_FREQUENCY = /Processor Information\(_Total\)\\Processor Frequency/i; + +/** + * Parse one `typeperf.exe -sc 1 ...` sample: a quoted CSV header of counter paths and one row of values. + * Task Manager's GPU figure is its busiest engine, so per adapter the load is the largest per-engine-type sum + * (3D, copy, video decode...), capped at 100. The adapter that holds the most graphics memory is the real one + * (Windows also lists software adapters that hold a few kilobytes). A `Shared Usage` only means an integrated + * GPU, whose memory is carved out of system RAM. + */ +export function parseTypeperf(output: string): WindowsCounters { + const rows = output + .split(/\r?\n/) + .filter((l) => l.startsWith('"')) + .map((l) => l.slice(1, l.lastIndexOf('"')).split('","')); + if (rows.length < 2) return {}; + const [names, values] = rows; + const engines = new Map>(); + const memory = new Map(); + let cpuMhz: number | undefined; + names.forEach((name, i) => { + const value = finite(Number(values[i])); + if (value === undefined || values[i].trim() === '') return; + const engine = ENGINE.exec(name); + if (engine) { + const byType = engines.get(engine[1].toLowerCase()) ?? new Map(); + byType.set(engine[2], (byType.get(engine[2]) ?? 0) + value); + engines.set(engine[1].toLowerCase(), byType); + return; + } + const mem = ADAPTER_MEMORY.exec(name); + if (mem) { + const luid = mem[1].toLowerCase(); + const entry = memory.get(luid) ?? { shared: 0, dedicated: 0 }; + if (mem[2].toLowerCase() === 'shared') entry.shared = value; + else entry.dedicated = value; + memory.set(luid, entry); + return; + } + if (CPU_FREQUENCY.test(name) && value > 0) cpuMhz = Math.round(value); + }); + const result: WindowsCounters = {}; + if (cpuMhz) result.cpuMhz = cpuMhz; + const luids = new Set([...engines.keys(), ...memory.keys()]); + let best: { luid: string; bytes: number; load: number } | undefined; + for (const luid of luids) { + const m = memory.get(luid); + const bytes = (m?.shared ?? 0) + (m?.dedicated ?? 0); + const load = Math.max(0, ...(engines.get(luid)?.values() ?? [])); + if (!best || bytes > best.bytes || (bytes === best.bytes && load > best.load)) best = { luid, bytes, load }; + } + if (best && (engines.size > 0 || memory.size > 0)) { + result.gpu = { + percent: clampPercent(best.load), + memUsedMB: Math.round(best.bytes / MB), + dedicated: (memory.get(best.luid)?.dedicated ?? 0) > 0, + }; + } + return result; +} + +const run = (cmd: string, args: string[], timeout = EXEC_TIMEOUT_MS) => + new Promise((resolve) => { + execFile(cmd, args, { timeout, windowsHide: true, maxBuffer: 4 * MB }, (err, stdout) => + resolve(err ? undefined : stdout) + ); + }); + +const readText = (path: string) => + readFile(path, 'utf8').then( + (t) => t.trim(), + () => undefined + ); +const readNumber = async (path: string) => { + const text = await readText(path); + return text === undefined ? undefined : finite(Number(text)); +}; + +/** Linux running under WSL: Windows programs can be started, and the GPU belongs to Windows. */ +function inWsl(): boolean { + return ( + process.platform === 'linux' && + (Boolean(process.env.WSL_INTEROP) || + existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') || + existsSync('/proc/sys/fs/binfmt_misc/WSLInterop-late')) + ); +} + +const typeperfPath = () => (existsSync(TYPEPERF) ? TYPEPERF : 'typeperf.exe'); + +let windowsRamMB: Promise | undefined; + +/** Windows' installed RAM, asked once (PowerShell takes about a second) and remembered when it answers. */ +function readWindowsRamMB(): Promise { + windowsRamMB ??= run( + existsSync(POWERSHELL) ? POWERSHELL : 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory'], + 8000 + ).then((out) => { + const bytes = out ? finite(Number(out.trim())) : undefined; + if (!bytes || bytes <= 0) windowsRamMB = undefined; + return bytes && bytes > 0 ? Math.round(bytes / MB) : undefined; + }); + return windowsRamMB; +} + +/** What the sampler needs from a child process; `ChildProcess` fits, and tests pass a fake. */ +export type SamplerChild = Pick; + +export interface CounterStreamOptions { + start: (counters: string[]) => SamplerChild; + now?: () => number; + /** How long the first read waits for the first sample. */ + firstSampleMs?: number; + /** A sampler older than this is replaced, so counters of processes that started since are picked up. */ + recycleMs?: number; + /** A sampler nobody has read for this long is stopped. */ + idleMs?: number; + /** After the sampler fails to start or exits at once, do not try again for this long. */ + backoffMs?: number; +} + +/** + * One long-running `typeperf.exe -si 2` instead of a process per read: every `typeperf` sample takes over a + * second (rate counters need two collections), which would keep one running back to back. The newest row is kept + * and handed out; the process is replaced every `recycleMs` (its wildcard counters are expanded once, at + * start) and stopped after `idleMs` without a read, so nothing runs while the settings are off. + */ +export function createCounterStream(options: CounterStreamOptions) { + const now = options.now ?? Date.now; + const firstSampleMs = options.firstSampleMs ?? 8000; + const recycleMs = options.recycleMs ?? 30000; + const idleMs = options.idleMs ?? 30000; + const backoffMs = options.backoffMs ?? 60000; + let child: SamplerChild | undefined; + let key = ''; + let startedAt = 0; + let lastRead = 0; + let blockedUntil = 0; + let header: string | undefined; + let latest: { at: number; value: WindowsCounters } | undefined; + let waiting: Array<() => void> = []; + + const stop = () => { + const old = child; + child = undefined; + header = undefined; + if (old) { + try { + old.kill(); + } catch { + /* already gone */ + } + } + }; + + const begin = (counters: string[]) => { + stop(); + const nextKey = counters.join('\n'); + // A sample of other counters is no answer; one from an older sampler of the same counters still is. + if (nextKey !== key) latest = undefined; + key = nextKey; + startedAt = now(); + let buffer = ''; + let sampled = false; + let started: SamplerChild; + try { + started = options.start(counters); + } catch { + blockedUntil = now() + backoffMs; + return; + } + child = started; + started.stdout?.on('data', (chunk: Buffer | string) => { + buffer += chunk.toString(); + let at: number; + while ((at = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, at).replace(/\r$/, ''); + buffer = buffer.slice(at + 1); + if (!line.startsWith('"')) continue; + if (!header) { + header = line; + continue; + } + sampled = true; + latest = { at: now(), value: parseTypeperf(`${header}\n${line}`) }; + const release = waiting; + waiting = []; + release.forEach((fn) => fn()); + } + }); + const ended = () => { + if (child !== started) return; + child = undefined; + // It ended without a sample (typeperf missing, counters unknown): leave it alone for a while. + if (!sampled) blockedUntil = now() + backoffMs; + const release = waiting; + waiting = []; + release.forEach((fn) => fn()); + }; + started.once('exit', ended); + started.once('error', ended); + }; + + return { + /** The newest sample for these counters; the first read waits briefly for one. */ + async read(counters: string[]): Promise { + lastRead = now(); + if (now() < blockedUntil) return undefined; + const wanted = counters.join('\n'); + if (!child || key !== wanted || now() - startedAt > recycleMs) begin(counters); + if (!latest || key !== wanted || now() - latest.at >= FRESH_MS) { + if (!child) return undefined; + await new Promise((resolve) => { + waiting.push(resolve); + setTimeout(resolve, firstSampleMs).unref(); + }); + } + return latest && now() - latest.at < FRESH_MS ? latest.value : undefined; + }, + /** Stop the sampler when nobody has read it lately. */ + sweep() { + if (child && now() - lastRead > idleMs) { + stop(); + latest = undefined; + } + }, + stop, + }; +} + +const windowsCounters = createCounterStream({ + start: (counters) => + spawn(typeperfPath(), ['-si', '2', ...counters], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }), +}); +setInterval(() => windowsCounters.sweep(), 10000).unref(); + +async function readWindowsCounters(request: SensorRequest): Promise { + const counters: string[] = []; + if (request.gpu) { + counters.push( + '\\GPU Engine(*)\\Utilization Percentage', + '\\GPU Adapter Memory(*)\\Shared Usage', + '\\GPU Adapter Memory(*)\\Dedicated Usage' + ); + } + if (request.cpuMhz) counters.push('\\Processor Information(_Total)\\Processor Frequency'); + if (!counters.length) return {}; + return (await windowsCounters.read(counters)) ?? {}; +} + +async function readAmdGpu(): Promise { + const cards = (await readdir(SYS_DRM).catch(() => [] as string[])).filter((n) => /^card\d+$/.test(n)).sort(); + for (const card of cards) { + const dev = join(SYS_DRM, card, 'device'); + const busy = await readNumber(join(dev, 'gpu_busy_percent')); + if (busy === undefined) continue; + const reading: GpuReading = { percent: clampPercent(busy) }; + const used = await readNumber(join(dev, 'mem_info_vram_used')); + const total = await readNumber(join(dev, 'mem_info_vram_total')); + if (used !== undefined && total !== undefined && total > 0) { + reading.memUsedMB = Math.round(used / MB); + reading.memTotalMB = Math.round(total / MB); + reading.memPercent = clampPercent((used / total) * 100); + } + const sclk = await readText(join(dev, 'pp_dpm_sclk')); + const mhz = sclk ? parseDpmClock(sclk) : undefined; + if (mhz) reading.mhz = mhz; + return reading; + } + return undefined; +} + +/** NVIDIA, then AMD; the Windows counters (WSL only) are the slower last resort and only for load and memory. */ +async function readGpu(windows: WindowsCounters | undefined): Promise { + const out = await run('nvidia-smi', [ + '--query-gpu=utilization.gpu,memory.used,memory.total,clocks.gr', + '--format=csv,noheader,nounits', + ]); + const native = (out && parseNvidiaSmi(out)) || (await readAmdGpu()); + if (native || !windows?.gpu) return native; + const { percent, memUsedMB, dedicated } = windows.gpu; + const reading: GpuReading = { percent, memUsedMB }; + // An integrated GPU draws its memory from system RAM, and Windows lets it use up to half of it. + const ram = dedicated ? undefined : await readWindowsRamMB(); + if (ram) { + reading.memTotalMB = Math.round(ram / 2); + reading.memPercent = clampPercent((memUsedMB / reading.memTotalMB) * 100); + } + return reading; +} + +async function readCpuMhz(windows: WindowsCounters | undefined): Promise { + // Under WSL the Linux side only has a fixed figure, so until Windows answers there is no reading. + if (windows) return windows.cpuMhz; + const cores = (await readdir(SYS_CPU).catch(() => [] as string[])).filter((n) => /^cpu\d+$/.test(n)); + const khz = await Promise.all(cores.map((c) => readNumber(join(SYS_CPU, c, 'cpufreq', 'scaling_cur_freq')))); + const fromSysfs = meanKhzAsMhz(khz.filter((v): v is number => v !== undefined)); + if (fromSysfs) return fromSysfs; + const info = await readText('/proc/cpuinfo'); + return info ? parseCpuInfoMhz(info) : undefined; +} + +let cache: { at: number; key: string; value: HostSensors } | undefined; +let inFlight: { key: string; promise: Promise } | undefined; + +/** Read only what was asked for. Missing sensors are simply absent from the result. */ +export async function readHostSensors(request: SensorRequest): Promise { + const key = `${request.gpu ? 'g' : ''}${request.cpuMhz ? 'c' : ''}`; + if (!key) return {}; + if (cache && cache.key === key && Date.now() - cache.at < CACHE_MS) return cache.value; + if (inFlight?.key === key) return inFlight.promise; + const promise = (async () => { + const windows = inWsl() ? await readWindowsCounters(request) : undefined; + const [gpu, cpuMhz] = await Promise.all([ + request.gpu ? readGpu(windows) : undefined, + request.cpuMhz ? readCpuMhz(windows) : undefined, + ]); + const value: HostSensors = {}; + if (gpu) value.gpu = gpu; + if (cpuMhz) value.cpuMhz = cpuMhz; + cache = { at: Date.now(), key, value }; + return value; + })().finally(() => { + inFlight = undefined; + }); + inFlight = { key, promise }; + return promise; +} diff --git a/src/web/public/app.js b/src/web/public/app.js index 033838544..09ed42af9 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2459,8 +2459,8 @@ class CodemanApp { this.renderSessionTabs(); this.renderRalphStatePanel(); // Update ralph panel after session deleted this.renderProjectInsightsPanel(); // Update project insights panel after session deleted - // Stop stats polling when no sessions remain - if (this.sessions.size === 0) this.stopSystemStatsPolling(); + // Stats polling keeps going with no session left: the header shows CPU, MEM and the opt-in readings + // regardless, and would sit at "--" otherwise. } // SSE wrappers — skip terminal events while WebSocket owns active terminal I/O. @@ -5025,12 +5025,9 @@ class CodemanApp { // survive reloads and SSE reconnects (methods in approvals-ui.js). this.seedApprovals?.(); - // Start/stop system stats polling based on session count - if (this.sessions.size > 0) { - this.startSystemStatsPolling(); - } else { - this.stopSystemStatsPolling(); - } + // Poll the header stats whether or not a session is open (fetchSystemStats skips the call while the + // stats pill is hidden); starting restarts the interval, so only start it once. + if (!this.systemStatsInterval) this.startSystemStatsPolling(); // CRITICAL: Clean up all floating windows before loading new subagents // This prevents memory leaks from ResizeObservers, EventSources, and DOM elements diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 2d0bbda93..6aa41301e 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -449,6 +449,8 @@ 'Header Displays': '顶部栏显示', 'Font Controls': '字体控制', 'System Stats': '系统状态', + 'GPU Stats': 'GPU 状态', + 'CPU MHz': 'CPU 频率', 'Lifecycle Log': '生命周期日志', 'Response Viewer': '回复查看器', 'Attachments Button': '附件按钮', diff --git a/src/web/public/index.html b/src/web/public/index.html index f3a2b2a27..c4b16c17e 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -178,6 +178,30 @@ -- + + + + @@ -2097,6 +2121,8 @@

Header & Panels

+ + diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index dedaea577..7c301089b 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -6718,7 +6718,12 @@ Object.assign(CodemanApp.prototype, { if (!statsEl || statsEl.style.display === 'none') return; try { - const res = await fetch('/api/system/stats'); + // Opt-in readings are only requested while their header setting is on (default off). + const settings = this.loadAppSettingsFromStorage?.() ?? {}; + const wanted = ['gpu', settings.showGpuStats, 'cpumhz', settings.showCpuMhz]; + const query = []; + for (let i = 0; i < wanted.length; i += 2) if (wanted[i + 1] === true) query.push(`${wanted[i]}=1`); + const res = await fetch('/api/system/stats' + (query.length ? '?' + query.join('&') : '')); const stats = await res.json(); this.updateSystemStatsDisplay(stats.data); } catch (err) { @@ -6752,6 +6757,8 @@ Object.assign(CodemanApp.prototype, { this._setStatRing('statCpuRing', stats.cpu); this._setStatRing('statMemRing', stats.memory?.percent); + this._updateHostSensors(stats); + if (memEl && memBar) { const memGB = (stats.memory.usedMB / 1024).toFixed(1); memEl.textContent = `${memGB}G`; @@ -6769,6 +6776,52 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * The opt-in readings after CPU and MEM: GPU load, GPU memory and GPU clock (the GPU Stats setting) and the CPU clock. An item + * shows only while its setting is on AND the host reported it, so a machine without a readable GPU just + * has nothing extra in the header. + */ + _updateHostSensors(stats) { + const settings = this.loadAppSettingsFromStorage?.() ?? {}; + const show = (id, visible) => { + const el = this.$(id); + if (el) el.hidden = !visible; + }; + const fill = (valueId, ringId, barId, percent, text) => { + const value = this.$(valueId); + if (value) { + value.textContent = text; + value.classList.toggle('high', percent > 80); + } + this._setStatRing(ringId, percent); + const bar = this.$(barId); + if (bar) bar.style.width = `${Math.min(100, percent)}%`; + }; + const gpu = settings.showGpuStats === true ? stats.gpu : undefined; + show('statGpuItem', Boolean(gpu)); + if (gpu) fill('statGpu', 'statGpuRing', 'statGpuBar', gpu.percent, `${gpu.percent}%`); + // Graphics memory: the amount in use whenever it is known, with a ring only when the total is too (an + // integrated GPU under WSL, where Windows reports usage but the share of RAM is a guess, may have none). + show('statVramItem', Boolean(gpu && gpu.memUsedMB !== undefined)); + if (gpu && gpu.memUsedMB !== undefined) { + const used = `${(gpu.memUsedMB / 1024).toFixed(1)}G`; + const ring = this.$('statVramRing'); + if (gpu.memPercent !== undefined) { + fill('statVram', 'statVramRing', 'statVramBar', gpu.memPercent, used); + if (ring) ring.style.display = ''; + } else { + this.$('statVram').textContent = used; + if (ring) ring.style.display = 'none'; + } + } + // The GPU clock is a third GPU Stats item (the host may not report one: Windows has none for Intel). + show('statGpuMhzItem', Boolean(gpu && gpu.mhz !== undefined)); + if (gpu && gpu.mhz !== undefined) this.$('statGpuMhz').textContent = `${gpu.mhz} MHz`; + const cpuMhz = settings.showCpuMhz === true && stats.cpuMhz !== undefined ? stats.cpuMhz : undefined; + show('statCpuMhzItem', cpuMhz !== undefined); + if (cpuMhz !== undefined) this.$('statCpuMhz').textContent = `${cpuMhz} MHz`; + }, + /** * Fill one stat ring (Compact header style) to `percent`, clamped to 0-100, * and flag it `high` above 80%, the same threshold at which the value next diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 5504719c7..0d5404514 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -514,6 +514,9 @@ Object.assign(CodemanApp.prototype, { // Header visibility settings document.getElementById('appSettingsShowFontControls').checked = settings.showFontControls ?? defaults.showFontControls ?? false; document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? defaults.showSystemStats ?? true; + // Opt-in host readings after CPU and MEM: all default OFF. + document.getElementById('appSettingsShowGpuStats').checked = settings.showGpuStats ?? defaults.showGpuStats ?? false; + document.getElementById('appSettingsShowCpuMhz').checked = settings.showCpuMhz ?? defaults.showCpuMhz ?? false; document.getElementById('appSettingsHeaderStatsStyle').value = this.resolveHeaderStatsStyle(settings); document.getElementById('appSettingsShowLifecycleLog').checked = settings.showLifecycleLog ?? defaults.showLifecycleLog ?? false; document.getElementById('appSettingsShowResponseViewer').checked = settings.showResponseViewer ?? defaults.showResponseViewer ?? false; @@ -2904,6 +2907,8 @@ Object.assign(CodemanApp.prototype, { // Header visibility settings showFontControls: document.getElementById('appSettingsShowFontControls').checked, showSystemStats: document.getElementById('appSettingsShowSystemStats').checked, + showGpuStats: document.getElementById('appSettingsShowGpuStats').checked, + showCpuMhz: document.getElementById('appSettingsShowCpuMhz').checked, headerStatsStyle: document.getElementById('appSettingsHeaderStatsStyle').value, showLifecycleLog: document.getElementById('appSettingsShowLifecycleLog').checked, showResponseViewer: document.getElementById('appSettingsShowResponseViewer').checked, @@ -4113,6 +4118,8 @@ Object.assign(CodemanApp.prototype, { // Header visibility - hide everything on mobile showFontControls: false, showSystemStats: false, + showGpuStats: false, + showCpuMhz: false, showTokenCount: false, showCost: false, // Panel visibility - hide panels on mobile (not enough space) @@ -4880,7 +4887,7 @@ Object.assign(CodemanApp.prototype, { */ _displaySettingKeySet() { const displayKeys = new Set([ - 'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost', + 'showFontControls', 'showSystemStats', 'showGpuStats', 'showCpuMhz', 'headerStatsStyle', 'showTokenCount', 'showCost', 'showLifecycleLog', 'showResponseViewer', 'showRedrawButton', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', 'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 65755b90e..731155f35 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2251,6 +2251,13 @@ html[data-tab-orientation='vertical'].tab-rail-compact border-left: 1px solid var(--glass-border); } +/* The opt-in host readings (GPU, VRAM, MHz) start hidden and are shown by panels-ui.js once the host + reports them; a plain `display` on .stat-item would override the attribute, and the Tiles style sets its own + (a grid) at a higher specificity, so the selector carries the style attribute to outrank it. */ +html[data-header-stats] .header-system-stats .stat-item[hidden] { + display: none; +} + /* Connection Indicator */ .connection-indicator { display: flex; diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index c7a37c109..91518568b 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -13,6 +13,7 @@ import { totalmem, freemem, loadavg, cpus } from 'node:os'; import { execSync, spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { dataPath } from '../../config/instance.js'; +import { readHostSensors } from '../../host-sensors.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type NiceConfig } from '../../types.js'; import { isUnauthenticatedNetworkAcknowledged } from '../network-auth-policy.js'; import { isMultiUserMode } from '../../config/multiuser.js'; @@ -1007,8 +1008,15 @@ export function registerSystemRoutes( // ========== System Stats ========== - app.get('/api/system/stats', async () => { - return getSystemStats(); + // GPU (load, memory, clock) and CPU clock readings are opt-in: the browser adds ?gpu=1 and ?cpumhz=1 only while + // the matching header setting is on, so a default install never runs nvidia-smi or reads sysfs. + app.get('/api/system/stats', async (req) => { + const query = req.query as { gpu?: string; cpumhz?: string }; + const sensors = await readHostSensors({ + gpu: query.gpu === '1', + cpuMhz: query.cpumhz === '1', + }); + return { ...getSystemStats(), ...sensors }; }); // ========== Settings ========== diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 86aa3f257..fac445baa 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1619,6 +1619,9 @@ export const SettingsUpdateSchema = z // UI visibility showFontControls: z.boolean().optional(), showSystemStats: z.boolean().optional(), + /** Opt-in host readings after CPU and MEM in the header (default off): GPU load/memory/clock (showGpuStats) and the CPU clock (showCpuMhz). */ + showGpuStats: z.boolean().optional(), + showCpuMhz: z.boolean().optional(), /** * How the header draws its WS / CPU / MEM / plan-usage cluster. Display key * (per-device), desktop only (the cluster is hidden below 768px). diff --git a/test/header-host-sensors-ui.test.ts b/test/header-host-sensors-ui.test.ts new file mode 100644 index 000000000..50f9df229 --- /dev/null +++ b/test/header-host-sensors-ui.test.ts @@ -0,0 +1,144 @@ +/** + * @fileoverview The opt-in header readings (GPU, VRAM, GPU MHz, CPU MHz): all off by default, requested from + * the server only while their setting is on, and shown only when the host reported them. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import vm from 'node:vm'; + +const read = (name: string) => readFileSync(join(process.cwd(), 'src/web/public', name), 'utf-8'); + +type El = { + hidden: boolean; + textContent: string; + style: Record; + classList: ReturnType; +}; +const classList = () => { + const set = new Set(); + return { + set, + add: (c: string) => set.add(c), + remove: (c: string) => set.delete(c), + toggle: (c: string, force?: boolean) => { + const on = force ?? !set.has(c); + if (on) set.add(c); + else set.delete(c); + return on; + }, + }; +}; + +function harness(settings: Record) { + const els = new Map(); + const fetched: string[] = []; + class CodemanApp {} + const context = vm.createContext({ + CodemanApp, + window: {}, + document: { getElementById: () => ({ style: {} }) }, + fetch: async (url: string) => { + fetched.push(url); + return { json: async () => ({ data: { cpu: 1, memory: { usedMB: 1, totalMB: 2, percent: 50 } } }) }; + }, + localStorage: { getItem: () => null }, + setInterval: () => 0, + clearInterval: () => {}, + console, + }); + vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' }); + const app = new (context.CodemanApp as new () => Record unknown>)(); + Object.assign(app, { + loadAppSettingsFromStorage: () => settings, + $: (id: string) => { + if (!els.has(id)) + els.set(id, { + hidden: true, + textContent: '', + style: { setProperty: () => {} } as unknown as Record, + classList: classList(), + }); + return els.get(id); + }, + }); + return { app, els, fetched }; +} + +describe('header host sensors', () => { + it('requests nothing extra from the server when every setting is off (the default)', async () => { + const { app, fetched } = harness({}); + await app.fetchSystemStats.call(app); + expect(fetched).toEqual(['/api/system/stats']); + }); + + it('asks for exactly the readings whose setting is on', async () => { + const { app, fetched } = harness({ showGpuStats: true, showCpuMhz: true }); + await app.fetchSystemStats.call(app); + expect(fetched).toEqual(['/api/system/stats?gpu=1&cpumhz=1']); + }); + + it('shows an item only when its setting is on and the host reported it', () => { + const stats = { + gpu: { percent: 42, memPercent: 50, memUsedMB: 4096, memTotalMB: 8192, mhz: 1800 }, + cpuMhz: 3400, + }; + const on = harness({ showGpuStats: true, showCpuMhz: true }); + on.app._updateHostSensors(stats); + for (const id of ['statGpuItem', 'statVramItem', 'statCpuMhzItem', 'statGpuMhzItem']) { + expect(on.els.get(id)?.hidden, id).toBe(false); + } + expect(on.els.get('statGpu')?.textContent).toBe('42%'); + expect(on.els.get('statVram')?.textContent).toBe('4.0G'); + expect(on.els.get('statCpuMhz')?.textContent).toBe('3400 MHz'); + expect(on.els.get('statGpuMhz')?.textContent).toBe('1800 MHz'); + + const off = harness({}); + off.app._updateHostSensors(stats); + for (const id of ['statGpuItem', 'statVramItem', 'statCpuMhzItem', 'statGpuMhzItem']) { + expect(off.els.get(id)?.hidden, id).toBe(true); + } + + // the GPU clock belongs to GPU Stats; with only CPU MHz on it stays hidden + const cpuOnly = harness({ showCpuMhz: true }); + cpuOnly.app._updateHostSensors(stats); + expect(cpuOnly.els.get('statCpuMhzItem')?.hidden).toBe(false); + expect(cpuOnly.els.get('statGpuMhzItem')?.hidden).toBe(true); + const gpuOnly = harness({ showGpuStats: true }); + gpuOnly.app._updateHostSensors(stats); + expect(gpuOnly.els.get('statGpuMhzItem')?.hidden).toBe(false); + expect(gpuOnly.els.get('statCpuMhzItem')?.hidden).toBe(true); + + const noHardware = harness({ showGpuStats: true, showCpuMhz: true }); + noHardware.app._updateHostSensors({}); + expect(noHardware.els.get('statGpuItem')?.hidden).toBe(true); + }); + + it('shows graphics memory in use even when its total is unknown, with no ring', () => { + const { app, els } = harness({ showGpuStats: true }); + app._updateHostSensors({ gpu: { percent: 5, memUsedMB: 1536 } }); + expect(els.get('statVramItem')?.hidden).toBe(false); + expect(els.get('statVram')?.textContent).toBe('1.5G'); + expect(els.get('statVramRing')?.style.display).toBe('none'); + app._updateHostSensors({ gpu: { percent: 5, memUsedMB: 1536, memTotalMB: 8192, memPercent: 19 } }); + expect(els.get('statVramRing')?.style.display).toBe(''); + }); + + it('keeps polling the header stats with no session open, so CPU, MEM and the readings are never stuck at --', () => { + const appJs = read('app.js'); + expect(appJs).toContain('if (!this.systemStatsInterval) this.startSystemStatsPolling();'); + expect(appJs).not.toMatch(/sessions\.size === 0\) this\.stopSystemStatsPolling\(\)/); + expect(appJs).not.toMatch(/\} else \{\s*this\.stopSystemStatsPolling\(\);/); + }); + + it('is opt-in in the settings page: unchecked chips, off in every default, accepted by the schema', () => { + const html = read('index.html'); + for (const id of ['appSettingsShowGpuStats', 'appSettingsShowCpuMhz']) { + expect(html).toMatch(new RegExp(``)); + } + const settingsUi = read('settings-ui.js'); + expect(settingsUi).toContain('showGpuStats ?? defaults.showGpuStats ?? false'); + expect(settingsUi).toContain('showCpuMhz ?? defaults.showCpuMhz ?? false'); + expect(readFileSync(join(process.cwd(), 'src/web/schemas.ts'), 'utf-8')).toMatch(/showCpuMhz: z\.boolean\(\)/); + }); +}); diff --git a/test/header-host-sensors.browser.test.ts b/test/header-host-sensors.browser.test.ts new file mode 100644 index 000000000..b2a32606d --- /dev/null +++ b/test/header-host-sensors.browser.test.ts @@ -0,0 +1,48 @@ +/** + * Real Chromium, real header markup and styles.css: the opt-in host readings stay out of the header until they + * are switched on and reported, under every header-stats style, and a reported one is drawn with its value. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; + +const dir = resolve(import.meta.dirname, '../src/web/public'); +const styles = readFileSync(resolve(dir, 'styles.css'), 'utf8'); +const header = /
[\s\S]*?<\/header>/.exec(readFileSync(resolve(dir, 'index.html'), 'utf8'))![0]; +const ITEMS = ['statGpuItem', 'statVramItem', 'statCpuMhzItem', 'statGpuMhzItem']; + +describe('opt-in host readings in the header (real Chromium)', () => { + let browser: Browser; + let p: Page; + beforeAll(async () => { + browser = await chromium.launch(); + }); + afterAll(async () => { + await browser.close(); + }); + + for (const style of ['compact', 'tiles', 'classic']) { + it(`draws none of them until reported, and the reported one with its value, in the ${style} style`, async () => { + p = await browser.newPage({ viewport: { width: 1500, height: 400 } }); + await p.setContent( + `${header}` + ); + const shown = () => + p.evaluate( + (ids) => ids.filter((id) => getComputedStyle(document.getElementById(id)!).display !== 'none'), + ITEMS + ); + expect(await shown()).toEqual([]); + await p.evaluate(() => { + document.getElementById('statGpuItem')!.hidden = false; + document.getElementById('statGpu')!.textContent = '42%'; + }); + expect(await shown()).toEqual(['statGpuItem']); + const box = await p.locator('#statGpuItem').boundingBox(); + expect(box!.width).toBeGreaterThan(20); + expect(await p.locator('#statGpuItem').innerText()).toContain('42%'); + await p.close(); + }); + } +}); diff --git a/test/host-sensors.test.ts b/test/host-sensors.test.ts new file mode 100644 index 000000000..93b4820ab --- /dev/null +++ b/test/host-sensors.test.ts @@ -0,0 +1,176 @@ +/** + * @fileoverview The pure parsers behind the opt-in header readings (GPU, CPU clock), the route's + * opt-in query handling, and the header display's defaults. No hardware needed. + */ +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { + createCounterStream, + meanKhzAsMhz, + parseCpuInfoMhz, + parseDpmClock, + parseNvidiaSmi, + parseTypeperf, + readHostSensors, +} from '../src/host-sensors.js'; + +describe('parseNvidiaSmi', () => { + it('reads load, memory and clock from the first GPU', () => { + expect(parseNvidiaSmi('37, 2048, 8192, 1815\n12, 1, 8192, 210\n')).toEqual({ + percent: 37, + memUsedMB: 2048, + memTotalMB: 8192, + memPercent: 25, + mhz: 1815, + }); + }); + + it('keeps the load when the driver reports no memory or clock, and refuses non-numbers', () => { + expect(parseNvidiaSmi('5, [N/A], [N/A], [N/A]')).toEqual({ percent: 5 }); + expect(parseNvidiaSmi('[N/A], 1, 2, 3')).toBeUndefined(); + expect(parseNvidiaSmi('')).toBeUndefined(); + }); + + it('clamps a load above 100', () => { + expect(parseNvidiaSmi('140, 1, 2, 3')?.percent).toBe(100); + }); +}); + +describe('clock parsers', () => { + it('takes the active line of an amdgpu pp_dpm_sclk table', () => { + expect(parseDpmClock('0: 500Mhz\n1: 1200Mhz *\n2: 2100Mhz\n')).toBe(1200); + expect(parseDpmClock('0: 500Mhz\n')).toBeUndefined(); + }); + + it('averages per-core kHz readings into MHz and ignores gaps', () => { + expect(meanKhzAsMhz([3000000, 4000000, Number.NaN, 0])).toBe(3500); + expect(meanKhzAsMhz([])).toBeUndefined(); + }); + + it('averages the cpu MHz lines of /proc/cpuinfo', () => { + expect(parseCpuInfoMhz('processor : 0\ncpu MHz\t\t: 3000.500\nprocessor : 1\ncpu MHz\t\t: 4000.500\n')).toBe(3501); + expect(parseCpuInfoMhz('model name : x\n')).toBeUndefined(); + }); +}); + +describe('parseTypeperf', () => { + const header = [ + '(PDH-CSV 4.0)', + '\\\\DAEDALUS\\GPU Engine(pid_10728_luid_0x00000000_0x000139EC_phys_0_eng_0_engtype_3D)\\Utilization Percentage', + '\\\\DAEDALUS\\GPU Engine(pid_2016_luid_0x00000000_0x000139EC_phys_0_eng_0_engtype_3D)\\Utilization Percentage', + '\\\\DAEDALUS\\GPU Engine(pid_2016_luid_0x00000000_0x000139EC_phys_0_eng_1_engtype_VideoDecode)\\Utilization Percentage', + '\\\\DAEDALUS\\GPU Engine(pid_9_luid_0x00000000_0x00013DF2_phys_0_eng_0_engtype_3D)\\Utilization Percentage', + '\\\\DAEDALUS\\GPU Adapter Memory(luid_0x00000000_0x000139EC_phys_0)\\Shared Usage', + '\\\\DAEDALUS\\GPU Adapter Memory(luid_0x00000000_0x000139EC_phys_0)\\Dedicated Usage', + '\\\\DAEDALUS\\GPU Adapter Memory(luid_0x00000000_0x00013DF2_phys_0)\\Shared Usage', + '\\\\DAEDALUS\\Processor Information(_Total)\\Processor Frequency', + ]; + const sample = (values: string[]) => + `\r\n"${header.join('","')}"\r\n"${values.join('","')}"\r\nExiting, please wait... The command completed successfully.\r\n`; + + it('takes the busiest engine type of the adapter holding the most memory, and the clock', () => { + const out = parseTypeperf( + sample(['10/23/2026 21:00:00.123', '12.5', '30', '3.5', '99', '1015808000', '0', '8192', '1640']) + ); + // 3D engines sum to 42.5 (the video decode engine is lower), and the software adapter's 99 is ignored + expect(out.gpu).toEqual({ percent: 43, memUsedMB: 969, dedicated: false }); + expect(out.cpuMhz).toBe(1640); + }); + + it('caps the load at 100 and marks an adapter with dedicated memory', () => { + const out = parseTypeperf(sample(['t', '80', '70', '0', '0', '0', '2147483648', '0', '1000'])); + expect(out.gpu).toEqual({ percent: 100, memUsedMB: 2048, dedicated: true }); + }); + + it('skips counters with no value and answers nothing for unusable output', () => { + const out = parseTypeperf(sample(['t', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '])); + expect(out.cpuMhz).toBeUndefined(); + expect(parseTypeperf('')).toEqual({}); + expect(parseTypeperf('The specified counter could not be found.')).toEqual({}); + }); +}); + +describe('readHostSensors', () => { + it('reads nothing at all unless a reading is asked for', async () => { + expect(await readHostSensors({})).toEqual({}); + expect(await readHostSensors({ gpu: false, cpuMhz: false })).toEqual({}); + }); +}); + +describe('createCounterStream', () => { + const ROW = (a: string) => `"t","${a}"\r\n`; + const HEADER = '"(PDH-CSV 4.0)","\\\\H\\Processor Information(_Total)\\Processor Frequency"\r\n'; + + function fake() { + const children: Array<{ stdout: PassThrough; killed: boolean; emitter: EventEmitter }> = []; + let clock = 1000; + const stream = createCounterStream({ + start: () => { + const stdout = new PassThrough(); + const emitter = new EventEmitter(); + const child = { stdout, killed: false, emitter }; + children.push(child); + return { + stdout, + kill: () => { + child.killed = true; + return true; + }, + once: (event: string, cb: () => void) => emitter.once(event, cb), + } as never; + }, + now: () => clock, + firstSampleMs: 50, + recycleMs: 30000, + idleMs: 30000, + backoffMs: 60000, + }); + return { children, stream, advance: (ms: number) => (clock += ms) }; + } + const counters = ['\\Processor Information(_Total)\\Processor Frequency']; + + it('waits for the first sample, then hands out the newest without starting another sampler', async () => { + const { children, stream } = fake(); + const first = stream.read(counters); + children[0].stdout.write('\r\n' + HEADER + ROW('1700')); + expect(await first).toEqual({ cpuMhz: 1700 }); + children[0].stdout.write(ROW('1900')); + await new Promise((r) => setImmediate(r)); + expect(await stream.read(counters)).toEqual({ cpuMhz: 1900 }); + expect(children).toHaveLength(1); + }); + + it('replaces an old sampler so new GPU processes are counted, and stops an idle one', async () => { + const { children, stream, advance } = fake(); + const first = stream.read(counters); + children[0].stdout.write(HEADER + ROW('1700')); + await first; + advance(31000); + const again = stream.read(counters); + expect(children).toHaveLength(2); + expect(children[0].killed).toBe(true); + children[1].stdout.write(HEADER + ROW('1800')); + expect(await again).toEqual({ cpuMhz: 1800 }); + advance(31000); + stream.sweep(); + expect(children[1].killed).toBe(true); + }); + + it('gives up quietly, and waits before trying again, when the sampler ends without a sample', async () => { + const { children, stream, advance } = fake(); + const first = stream.read(counters); + children[0].emitter.emit('exit'); + expect(await first).toBeUndefined(); + expect(await stream.read(counters)).toBeUndefined(); + expect(children).toHaveLength(1); + advance(61000); + void stream.read(counters); + expect(children).toHaveLength(2); + }); + + it('answers nothing, within the wait, when no sample ever arrives', async () => { + const { stream } = fake(); + expect(await stream.read(counters)).toBeUndefined(); + }); +}); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index 0cd44d47c..50795698f 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -12,6 +12,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; import { registerSystemRoutes } from '../../src/web/routes/system-routes.js'; +const hostSensors = vi.hoisted(() => ({ readHostSensors: vi.fn(async (_request: unknown) => ({})) })); +vi.mock('../../src/host-sensors.js', () => hostSensors); + // ── Mocks ────────────────────────────────────────────────────────── vi.mock('node:fs/promises', () => ({ @@ -326,6 +329,20 @@ describe('system-routes', () => { expect(body).toHaveProperty('cpu'); expect(body).toHaveProperty('memory'); }); + + it('adds no GPU or clock readings unless they are asked for', async () => { + const body = JSON.parse((await harness.app.inject({ method: 'GET', url: '/api/system/stats' })).body); + expect(body).not.toHaveProperty('gpu'); + expect(body).not.toHaveProperty('cpuMhz'); + }); + + it('passes only the requested readings on to the sensor reader', async () => { + hostSensors.readHostSensors.mockClear(); + hostSensors.readHostSensors.mockResolvedValueOnce({ cpuMhz: 3200 }); + const res = await harness.app.inject({ method: 'GET', url: '/api/system/stats?cpumhz=1&gpu=0' }); + expect(hostSensors.readHostSensors).toHaveBeenCalledWith({ gpu: false, cpuMhz: true }); + expect(JSON.parse(res.body).cpuMhz).toBe(3200); + }); }); // ========== POST /api/cleanup-state ========== From ce7d198b91197fce6cb51bd15f1654a94e74f32e Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:05:21 +0800 Subject: [PATCH 2/4] fix(host-sensors): one shared Windows sampler, guard late rows, address review --- src/host-sensors.ts | 112 ++++++++++++++++++---------- src/web/public/app.js | 2 - src/web/public/index.html | 2 +- src/web/public/panels-ui.js | 14 ++-- src/web/public/settings-ui.js | 3 + src/web/schemas.ts | 3 - src/web/server.ts | 4 + test/header-host-sensors-ui.test.ts | 7 +- test/host-sensors.test.ts | 89 ++++++++++++++++++++++ 9 files changed, 184 insertions(+), 52 deletions(-) diff --git a/src/host-sensors.ts b/src/host-sensors.ts index 07f9263ca..23af12dfb 100644 --- a/src/host-sensors.ts +++ b/src/host-sensors.ts @@ -180,23 +180,22 @@ const readNumber = async (path: string) => { }; /** Linux running under WSL: Windows programs can be started, and the GPU belongs to Windows. */ +let wsl: boolean | undefined; function inWsl(): boolean { - return ( + wsl ??= process.platform === 'linux' && (Boolean(process.env.WSL_INTEROP) || existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') || - existsSync('/proc/sys/fs/binfmt_misc/WSLInterop-late')) - ); + existsSync('/proc/sys/fs/binfmt_misc/WSLInterop-late')); + return wsl; } -const typeperfPath = () => (existsSync(TYPEPERF) ? TYPEPERF : 'typeperf.exe'); - let windowsRamMB: Promise | undefined; /** Windows' installed RAM, asked once (PowerShell takes about a second) and remembered when it answers. */ function readWindowsRamMB(): Promise { windowsRamMB ??= run( - existsSync(POWERSHELL) ? POWERSHELL : 'powershell.exe', + POWERSHELL, ['-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory'], 8000 ).then((out) => { @@ -240,14 +239,12 @@ export function createCounterStream(options: CounterStreamOptions) { let startedAt = 0; let lastRead = 0; let blockedUntil = 0; - let header: string | undefined; let latest: { at: number; value: WindowsCounters } | undefined; let waiting: Array<() => void> = []; const stop = () => { const old = child; child = undefined; - header = undefined; if (old) { try { old.kill(); @@ -264,6 +261,9 @@ export function createCounterStream(options: CounterStreamOptions) { if (nextKey !== key) latest = undefined; key = nextKey; startedAt = now(); + // The header row and the partial line belong to this process alone: a late row from a replaced sampler + // must never become the next one's header. + let header: string | undefined; let buffer = ''; let sampled = false; let started: SamplerChild; @@ -275,6 +275,7 @@ export function createCounterStream(options: CounterStreamOptions) { } child = started; started.stdout?.on('data', (chunk: Buffer | string) => { + if (child !== started) return; buffer += chunk.toString(); let at: number; while ((at = buffer.indexOf('\n')) >= 0) { @@ -332,24 +333,47 @@ export function createCounterStream(options: CounterStreamOptions) { }; } +const GPU_COUNTERS = [ + '\\GPU Engine(*)\\Utilization Percentage', + '\\GPU Adapter Memory(*)\\Shared Usage', + '\\GPU Adapter Memory(*)\\Dedicated Usage', +]; +const CPU_COUNTERS = ['\\Processor Information(_Total)\\Processor Frequency']; + +/** + * The counters one sampler reads. They do not depend on which reading a browser asked for (those are per + * device, so two browsers can differ): the CPU clock is always sampled, and the GPU counters are added, once + * and for good, on a machine with no native GPU reader. A request then only picks its fields from the sample. + */ +export function windowsCounterList(needGpu: boolean): string[] { + return needGpu ? [...GPU_COUNTERS, ...CPU_COUNTERS] : [...CPU_COUNTERS]; +} + +export function createWindowsSource(stream: Pick, 'read' | 'stop'>) { + let needGpu = false; + return { + /** A GPU reading is wanted and no native reader answered: sample the GPU counters too. */ + needGpuCounters() { + needGpu = true; + }, + read: () => stream.read(windowsCounterList(needGpu)), + stop: () => stream.stop(), + }; +} + const windowsCounters = createCounterStream({ + // Spawn the Windows program directly: if it is missing, the error lands in the stream's back-off. start: (counters) => - spawn(typeperfPath(), ['-si', '2', ...counters], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }), + spawn(TYPEPERF, ['-si', '2', ...counters], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }), }); -setInterval(() => windowsCounters.sweep(), 10000).unref(); - -async function readWindowsCounters(request: SensorRequest): Promise { - const counters: string[] = []; - if (request.gpu) { - counters.push( - '\\GPU Engine(*)\\Utilization Percentage', - '\\GPU Adapter Memory(*)\\Shared Usage', - '\\GPU Adapter Memory(*)\\Dedicated Usage' - ); - } - if (request.cpuMhz) counters.push('\\Processor Information(_Total)\\Processor Frequency'); - if (!counters.length) return {}; - return (await windowsCounters.read(counters)) ?? {}; +const windowsSource = createWindowsSource(windowsCounters); +let sweepTimer: ReturnType | undefined; + +/** Stop the background sampler and its idle sweep (the server calls this on shutdown). */ +export function stopHostSensors(): void { + if (sweepTimer) clearInterval(sweepTimer); + sweepTimer = undefined; + windowsSource.stop(); } async function readAmdGpu(): Promise { @@ -374,14 +398,18 @@ async function readAmdGpu(): Promise { return undefined; } -/** NVIDIA, then AMD; the Windows counters (WSL only) are the slower last resort and only for load and memory. */ -async function readGpu(windows: WindowsCounters | undefined): Promise { +/** NVIDIA, then AMD: the readers that need nothing from Windows. */ +async function readNativeGpu(): Promise { const out = await run('nvidia-smi', [ '--query-gpu=utilization.gpu,memory.used,memory.total,clocks.gr', '--format=csv,noheader,nounits', ]); - const native = (out && parseNvidiaSmi(out)) || (await readAmdGpu()); - if (native || !windows?.gpu) return native; + return (out && parseNvidiaSmi(out)) || readAmdGpu(); +} + +/** A GPU reading from the Windows counters (WSL only): load and memory in use, but no clock. */ +async function gpuFromWindows(windows: WindowsCounters | undefined): Promise { + if (!windows?.gpu) return undefined; const { percent, memUsedMB, dedicated } = windows.gpu; const reading: GpuReading = { percent, memUsedMB }; // An integrated GPU draws its memory from system RAM, and Windows lets it use up to half of it. @@ -404,29 +432,35 @@ async function readCpuMhz(windows: WindowsCounters | undefined): Promise } | undefined; +const cache = new Map(); +const inFlight = new Map>(); /** Read only what was asked for. Missing sensors are simply absent from the result. */ export async function readHostSensors(request: SensorRequest): Promise { const key = `${request.gpu ? 'g' : ''}${request.cpuMhz ? 'c' : ''}`; if (!key) return {}; - if (cache && cache.key === key && Date.now() - cache.at < CACHE_MS) return cache.value; - if (inFlight?.key === key) return inFlight.promise; + sweepTimer ??= setInterval(() => windowsCounters.sweep(), 10000).unref(); + const hit = cache.get(key); + if (hit && Date.now() - hit.at < CACHE_MS) return hit.value; + const running = inFlight.get(key); + if (running) return running; const promise = (async () => { - const windows = inWsl() ? await readWindowsCounters(request) : undefined; + // Native GPU readers first: a machine with a working nvidia-smi never starts the GPU counters. + const nativeGpu = request.gpu ? await readNativeGpu() : undefined; + const wsl = inWsl(); + const windowsGpu = wsl && request.gpu && !nativeGpu; + if (windowsGpu) windowsSource.needGpuCounters(); + const windows = wsl && (request.cpuMhz || windowsGpu) ? await windowsSource.read() : undefined; const [gpu, cpuMhz] = await Promise.all([ - request.gpu ? readGpu(windows) : undefined, - request.cpuMhz ? readCpuMhz(windows) : undefined, + request.gpu ? (nativeGpu ?? gpuFromWindows(windows)) : undefined, + request.cpuMhz ? readCpuMhz(wsl ? (windows ?? {}) : undefined) : undefined, ]); const value: HostSensors = {}; if (gpu) value.gpu = gpu; if (cpuMhz) value.cpuMhz = cpuMhz; - cache = { at: Date.now(), key, value }; + cache.set(key, { at: Date.now(), value }); return value; - })().finally(() => { - inFlight = undefined; - }); - inFlight = { key, promise }; + })().finally(() => inFlight.delete(key)); + inFlight.set(key, promise); return promise; } diff --git a/src/web/public/app.js b/src/web/public/app.js index 09ed42af9..ed6ebbc27 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2402,8 +2402,6 @@ class CodemanApp { if (data.parentSessionId) this.markConnectionLineEntering?.('lineage:' + data.id); this.renderSessionTabs(); this.updateCost(); - // Start stats polling when first session appears - if (this.sessions.size === 1) this.startSystemStatsPolling(); if (this._urlSessionId === data.id) this._selectUrlSession(); } diff --git a/src/web/public/index.html b/src/web/public/index.html index c4b16c17e..a6bc3859f 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -2122,7 +2122,7 @@

Header & Panels

- + diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 7c301089b..6a852b6a6 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -6757,8 +6757,6 @@ Object.assign(CodemanApp.prototype, { this._setStatRing('statCpuRing', stats.cpu); this._setStatRing('statMemRing', stats.memory?.percent); - this._updateHostSensors(stats); - if (memEl && memBar) { const memGB = (stats.memory.usedMB / 1024).toFixed(1); memEl.textContent = `${memGB}G`; @@ -6774,6 +6772,9 @@ Object.assign(CodemanApp.prototype, { memBar.classList.add('medium'); } } + + // After the MEM block, so nothing in the optional readings can stop MEM updating. + this._updateHostSensors(stats); }, /** @@ -6810,16 +6811,19 @@ Object.assign(CodemanApp.prototype, { fill('statVram', 'statVramRing', 'statVramBar', gpu.memPercent, used); if (ring) ring.style.display = ''; } else { - this.$('statVram').textContent = used; + const vram = this.$('statVram'); + if (vram) vram.textContent = used; if (ring) ring.style.display = 'none'; } } // The GPU clock is a third GPU Stats item (the host may not report one: Windows has none for Intel). show('statGpuMhzItem', Boolean(gpu && gpu.mhz !== undefined)); - if (gpu && gpu.mhz !== undefined) this.$('statGpuMhz').textContent = `${gpu.mhz} MHz`; + const gpuMhzEl = gpu && gpu.mhz !== undefined ? this.$('statGpuMhz') : null; + if (gpuMhzEl) gpuMhzEl.textContent = `${gpu.mhz} MHz`; const cpuMhz = settings.showCpuMhz === true && stats.cpuMhz !== undefined ? stats.cpuMhz : undefined; show('statCpuMhzItem', cpuMhz !== undefined); - if (cpuMhz !== undefined) this.$('statCpuMhz').textContent = `${cpuMhz} MHz`; + const cpuMhzEl = cpuMhz !== undefined ? this.$('statCpuMhz') : null; + if (cpuMhzEl) cpuMhzEl.textContent = `${cpuMhz} MHz`; }, /** diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 0d5404514..84874d379 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -3239,6 +3239,9 @@ Object.assign(CodemanApp.prototype, { showCronButton: _crb, // Per-device bottom-bar indicator, absent from SettingsUpdateSchema (.strict()): it must not reach the PUT. showGitStatus: _sgs, + // Opt-in header readings (GPU Stats, CPU MHz): per-device and absent from the .strict() schema. + showGpuStats: _sgp, + showCpuMhz: _scm, gitStatusTree: _gst, gitStatusMaxRepos: _gsm, gitStatusTimeoutSeconds: _gst2, diff --git a/src/web/schemas.ts b/src/web/schemas.ts index fac445baa..86aa3f257 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1619,9 +1619,6 @@ export const SettingsUpdateSchema = z // UI visibility showFontControls: z.boolean().optional(), showSystemStats: z.boolean().optional(), - /** Opt-in host readings after CPU and MEM in the header (default off): GPU load/memory/clock (showGpuStats) and the CPU clock (showCpuMhz). */ - showGpuStats: z.boolean().optional(), - showCpuMhz: z.boolean().optional(), /** * How the header draws its WS / CPU / MEM / plan-usage cluster. Display key * (per-device), desktop only (the cluster is hidden below 768px). diff --git a/src/web/server.ts b/src/web/server.ts index b3f537e9e..fa172289f 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -45,6 +45,7 @@ import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from import { rebootRestoreRegistry } from './reboot-restore-registry.js'; import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js'; import { WebhookNotifier, readWebhookConfig } from '../webhook-notify.js'; +import { stopHostSensors } from '../host-sensors.js'; import type { WebhookUrgency } from '../types/push.js'; import { webviewFetch } from './webview-egress.js'; import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js'; @@ -4173,6 +4174,9 @@ export class WebServer extends EventEmitter { // got wrong once. void stopDeepSeekWeb(); + // The header's GPU / CPU clock sampler is a Windows child process under WSL, and its idle sweep a timer. + stopHostSensors(); + // Same teardown rule: the per-endpoint llama-swap log tails are otherwise closed // only by the periodic idle sweep, whose interval is disposed just below. closeAllLlamaSwapLogTails(); diff --git a/test/header-host-sensors-ui.test.ts b/test/header-host-sensors-ui.test.ts index 50f9df229..2e13b4794 100644 --- a/test/header-host-sensors-ui.test.ts +++ b/test/header-host-sensors-ui.test.ts @@ -131,7 +131,7 @@ describe('header host sensors', () => { expect(appJs).not.toMatch(/\} else \{\s*this\.stopSystemStatsPolling\(\);/); }); - it('is opt-in in the settings page: unchecked chips, off in every default, accepted by the schema', () => { + it('is opt-in in the settings page: unchecked chips, off in every default, stripped from the settings PUT', () => { const html = read('index.html'); for (const id of ['appSettingsShowGpuStats', 'appSettingsShowCpuMhz']) { expect(html).toMatch(new RegExp(``)); @@ -139,6 +139,9 @@ describe('header host sensors', () => { const settingsUi = read('settings-ui.js'); expect(settingsUi).toContain('showGpuStats ?? defaults.showGpuStats ?? false'); expect(settingsUi).toContain('showCpuMhz ?? defaults.showCpuMhz ?? false'); - expect(readFileSync(join(process.cwd(), 'src/web/schemas.ts'), 'utf-8')).toMatch(/showCpuMhz: z\.boolean\(\)/); + // per-device, so stripped from the PUT body like the other header toggles (the schema is .strict()) + expect(settingsUi).toContain('showGpuStats: _sgp,'); + expect(settingsUi).toContain('showCpuMhz: _scm,'); + expect(readFileSync(join(process.cwd(), 'src/web/schemas.ts'), 'utf-8')).not.toContain('showCpuMhz'); }); }); diff --git a/test/host-sensors.test.ts b/test/host-sensors.test.ts index 93b4820ab..0053058c8 100644 --- a/test/host-sensors.test.ts +++ b/test/host-sensors.test.ts @@ -7,6 +7,8 @@ import { PassThrough } from 'node:stream'; import { describe, expect, it } from 'vitest'; import { createCounterStream, + createWindowsSource, + windowsCounterList, meanKhzAsMhz, parseCpuInfoMhz, parseDpmClock, @@ -174,3 +176,90 @@ describe('createCounterStream', () => { expect(await stream.read(counters)).toBeUndefined(); }); }); + +describe('the Windows sampler shared by every browser', () => { + const HEADER = '"(PDH-CSV 4.0)","\\\\H\\Processor Information(_Total)\\Processor Frequency"\r\n'; + + function rig() { + const children: Array<{ stdout: PassThrough; killed: boolean; counters: string[] }> = []; + const stream = createCounterStream({ + start: (counters) => { + const stdout = new PassThrough(); + const child = { stdout, killed: false, counters }; + children.push(child); + return { + stdout, + kill: () => { + child.killed = true; + return true; + }, + once: () => undefined, + } as never; + }, + firstSampleMs: 20, + }); + return { children, source: createWindowsSource(stream) }; + } + + it('samples the same counters whatever a browser asked for, so two browsers share one process', async () => { + const { children, source } = rig(); + // browser A (GPU Stats) and browser B (GPU Stats and CPU MHz) read the same sampler in turn + for (let i = 0; i < 8; i++) { + const read = source.read(); + if (i === 0) children[0].stdout.write(HEADER + '"t","1700"\r\n'); + await read; + } + expect(children).toHaveLength(1); + expect(children[0].killed).toBe(false); + }); + + it('adds the GPU counters once, for good, on a machine with no native GPU reader', async () => { + const { children, source } = rig(); + void source.read(); + expect(children[0].counters).toEqual(windowsCounterList(false)); + expect(children[0].counters.some((c) => c.includes('GPU Engine'))).toBe(false); + source.needGpuCounters(); + void source.read(); + expect(children).toHaveLength(2); + expect(children[0].killed).toBe(true); + expect(children[1].counters).toEqual(windowsCounterList(true)); + source.needGpuCounters(); + void source.read(); + void source.read(); + expect(children).toHaveLength(2); + }); +}); + +describe('a late row from a replaced sampler', () => { + it("is ignored, so it can never become the new sampler's header", async () => { + const children: PassThrough[] = []; + let clock = 1000; + const stream = createCounterStream({ + start: () => { + const stdout = new PassThrough(); + children.push(stdout); + return { stdout, kill: () => true, once: () => undefined } as never; + }, + now: () => clock, + firstSampleMs: 20, + recycleMs: 30000, + }); + const counters = ['\\Processor Information(_Total)\\Processor Frequency']; + const header = '"(PDH-CSV 4.0)","\\\\H\\Processor Information(_Total)\\Processor Frequency"\r\n'; + const first = stream.read(counters); + children[0].write(header + '"t","1700"\r\n'); + await first; + clock += 31000; + const second = stream.read(counters); // replaces the sampler + expect(children).toHaveLength(2); + // the killed process still had a data row in its pipe: it arrives after the replacement began + children[0].write('"t","999"\r\n'); + await new Promise((r) => setImmediate(r)); + // the stale row is not a sample: with nothing from the new process yet there is still nothing to hand out + expect(await stream.read(counters)).toBeUndefined(); + children[1].write(header + '"t","1800"\r\n'); + await second; + await new Promise((r) => setImmediate(r)); + expect(await stream.read(counters)).toEqual({ cpuMhz: 1800 }); + }); +}); From f15afe1de39093412cea3253c0e6a437080959a8 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:32:20 +0800 Subject: [PATCH 3/4] test(settings): poll again when settings.json is read half-written --- test/settings-admin-only-multiuser.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/settings-admin-only-multiuser.test.ts b/test/settings-admin-only-multiuser.test.ts index e6c181204..533007ae4 100644 --- a/test/settings-admin-only-multiuser.test.ts +++ b/test/settings-admin-only-multiuser.test.ts @@ -57,8 +57,12 @@ async function send(method: string, path: string, headers: Record) => boolean): Promise> { let last: Record = {}; for (let i = 0; i < 40; i++) { - last = JSON.parse(await readSettingsBytes()); - if (predicate(last)) return last; + try { + last = JSON.parse(await readSettingsBytes()); + if (predicate(last)) return last; + } catch { + // the write is not atomic: a read that lands between its truncate and its data is empty, so poll again + } await new Promise((r) => setTimeout(r, 25)); } return last; From 23a9d78cdd95dcd276b5adf5c825bd96fb7dfd05 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:17:20 +0800 Subject: [PATCH 4/4] fix(host-sensors): never wait on a cold sampler, back off failing readers, one stats request at a time --- docs/architecture-invariants.md | 2 +- src/host-sensors.ts | 75 +++++++++---- src/web/public/index.html | 4 +- src/web/public/panels-ui.js | 13 ++- test/header-host-sensors-ui.test.ts | 22 +++- test/host-sensors-readers.test.ts | 162 ++++++++++++++++++++++++++++ test/host-sensors.test.ts | 20 ++++ 7 files changed, 270 insertions(+), 28 deletions(-) create mode 100644 test/host-sensors-readers.test.ts diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index f446f4a1a..ec98ba4c5 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1029,7 +1029,7 @@ With `classic` nothing is left behind: no headings, no breaks, no inline order, ### Header stats styles -**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): how the WS readout, CPU, MEM and the plan-usage windows are drawn. `tiles` gives each its own label-over-value tile with a bar along the bottom edge; `compact` is two pills, WS / CPU / MEM and the plan windows, with a ring beside every value (stat rings accent, red past 80%; plan rings green, yellow or red); `classic` is the bars and `5H · 7D` chip as before. `data-header-stats` on `` (pre-paint, then `applyHeaderStatsStyle()` from `applyHeaderVisibilitySettings()`) drives every rule; narrower than 768px and in a solo window it resolves to `classic`, because the stats are hidden there anyway and the moves below would otherwise reach the phone header. +**Header stats styles** (`headerStatsStyle`, per-device, desktop only, default `compact`; Discussion #426 option G): how the WS readout, CPU, MEM and the plan-usage windows are drawn. `tiles` gives each its own label-over-value tile with a bar along the bottom edge; `compact` is two pills, WS / CPU / MEM and the plan windows, with a ring beside every value (stat rings accent, red past 80%; plan rings green, yellow or red); `classic` is the bars and `5H · 7D` chip as before. `data-header-stats` on `` (pre-paint, then `applyHeaderStatsStyle()` from `applyHeaderVisibilitySettings()`) drives every rule; narrower than 768px and in a solo window it resolves to `classic`, because the stats are hidden there anyway and the moves below would otherwise reach the phone header. The opt-in host readings after MEM (GPU, VRAM, GPU MHz, CPU MHz: `.stat-item` elements that start `hidden` and are shown by `_updateHostSensors` in panels-ui.js) rely on `html[data-header-stats] .header-system-stats .stat-item[hidden]` in styles.css outranking the Tiles style's own `display` on `.stat-item`; keep that selector's specificity above any style rule that sets `display` on a stat item, or the hidden placeholders (`--%`) reappear. ⚠️ The clustered styles need the three elements contiguous, and the template keeps the classic order, so `applyHeaderStatsStyle()` MOVES `#connectionIndicator` into `#headerSystemStats` (first child) and `#planUsageChip` right after it, and `classic` moves them back to comment anchors it left at the template positions. Ids are unchanged, so every writer still finds them. ⚠️ The indicator joins the pill only while System Stats is shown: the pill is hidden with `display: none`, and the WS readout must not disappear with it. diff --git a/src/host-sensors.ts b/src/host-sensors.ts index 23af12dfb..c2128de32 100644 --- a/src/host-sensors.ts +++ b/src/host-sensors.ts @@ -48,6 +48,10 @@ export interface SensorRequest { const CACHE_MS = 2000; /** A counter sample older than this (a few sampling intervals) is no longer a live reading. */ const FRESH_MS = 10000; +/** After a reader fails, leave it alone this long: nvidia-smi absent or timing out, PowerShell failing. */ +const READER_BACKOFF_MS = 60000; +/** The GPU counters stay in the sampler this long after the last GPU read that needed them. */ +const GPU_COUNTERS_HOLD_MS = 120000; const EXEC_TIMEOUT_MS = 1500; const SYS_DRM = '/sys/class/drm'; const SYS_CPU = '/sys/devices/system/cpu'; @@ -191,16 +195,24 @@ function inWsl(): boolean { } let windowsRamMB: Promise | undefined; +let ramRetryAt = 0; -/** Windows' installed RAM, asked once (PowerShell takes about a second) and remembered when it answers. */ +/** + * Windows' installed RAM, asked once (PowerShell takes about a second) and remembered when it answers. A + * failure is not retried on every GPU poll (8 s timeout each): it waits READER_BACKOFF_MS first. + */ function readWindowsRamMB(): Promise { + if (!windowsRamMB && Date.now() < ramRetryAt) return Promise.resolve(undefined); windowsRamMB ??= run( POWERSHELL, ['-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory'], 8000 ).then((out) => { const bytes = out ? finite(Number(out.trim())) : undefined; - if (!bytes || bytes <= 0) windowsRamMB = undefined; + if (!bytes || bytes <= 0) { + windowsRamMB = undefined; + ramRetryAt = Date.now() + READER_BACKOFF_MS; + } return bytes && bytes > 0 ? Math.round(bytes / MB) : undefined; }); return windowsRamMB; @@ -212,7 +224,11 @@ export type SamplerChild = Pick; export interface CounterStreamOptions { start: (counters: string[]) => SamplerChild; now?: () => number; - /** How long the first read waits for the first sample. */ + /** + * How long a read waits for a sample when it has none fresh (0, the default, never waits: the read + * answers undefined and the next poll shows the reading). The stats route awaits the sensors before it + * answers CPU and MEM, so a cold sampler must not hold it up. + */ firstSampleMs?: number; /** A sampler older than this is replaced, so counters of processes that started since are picked up. */ recycleMs?: number; @@ -230,7 +246,7 @@ export interface CounterStreamOptions { */ export function createCounterStream(options: CounterStreamOptions) { const now = options.now ?? Date.now; - const firstSampleMs = options.firstSampleMs ?? 8000; + const firstSampleMs = options.firstSampleMs ?? 0; const recycleMs = options.recycleMs ?? 30000; const idleMs = options.idleMs ?? 30000; const backoffMs = options.backoffMs ?? 60000; @@ -307,7 +323,7 @@ export function createCounterStream(options: CounterStreamOptions) { }; return { - /** The newest sample for these counters; the first read waits briefly for one. */ + /** The newest fresh sample for these counters, or undefined (a cold sampler is not waited for unless `firstSampleMs` says so). */ async read(counters: string[]): Promise { lastRead = now(); if (now() < blockedUntil) return undefined; @@ -315,10 +331,12 @@ export function createCounterStream(options: CounterStreamOptions) { if (!child || key !== wanted || now() - startedAt > recycleMs) begin(counters); if (!latest || key !== wanted || now() - latest.at >= FRESH_MS) { if (!child) return undefined; - await new Promise((resolve) => { - waiting.push(resolve); - setTimeout(resolve, firstSampleMs).unref(); - }); + if (firstSampleMs > 0) { + await new Promise((resolve) => { + waiting.push(resolve); + setTimeout(resolve, firstSampleMs).unref(); + }); + } } return latest && now() - latest.at < FRESH_MS ? latest.value : undefined; }, @@ -342,21 +360,28 @@ const CPU_COUNTERS = ['\\Processor Information(_Total)\\Processor Frequency']; /** * The counters one sampler reads. They do not depend on which reading a browser asked for (those are per - * device, so two browsers can differ): the CPU clock is always sampled, and the GPU counters are added, once - * and for good, on a machine with no native GPU reader. A request then only picks its fields from the sample. + * device, so two browsers can differ): the CPU clock is always sampled, and the GPU counters are added on a + * machine with no native GPU reader (and dropped again once nothing has needed them for a while). A request then only picks its fields from the sample. */ export function windowsCounterList(needGpu: boolean): string[] { return needGpu ? [...GPU_COUNTERS, ...CPU_COUNTERS] : [...CPU_COUNTERS]; } -export function createWindowsSource(stream: Pick, 'read' | 'stop'>) { - let needGpu = false; +export function createWindowsSource( + stream: Pick, 'read' | 'stop'>, + now: () => number = Date.now +) { + let gpuUntil = 0; return { - /** A GPU reading is wanted and no native reader answered: sample the GPU counters too. */ + /** + * A GPU reading is wanted and no native reader answered: sample the GPU counters too, for + * GPU_COUNTERS_HOLD_MS after the last such read. A single failed nvidia-smi therefore costs a sampler + * restart, not a GPU sampler for the life of the server. + */ needGpuCounters() { - needGpu = true; + gpuUntil = now() + GPU_COUNTERS_HOLD_MS; }, - read: () => stream.read(windowsCounterList(needGpu)), + read: () => stream.read(windowsCounterList(now() < gpuUntil)), stop: () => stream.stop(), }; } @@ -399,12 +424,20 @@ async function readAmdGpu(): Promise { } /** NVIDIA, then AMD: the readers that need nothing from Windows. */ +let nvidiaSkipUntil = 0; + +/** NVIDIA, then AMD. A host without a working nvidia-smi is not asked again on every poll. */ async function readNativeGpu(): Promise { - const out = await run('nvidia-smi', [ - '--query-gpu=utilization.gpu,memory.used,memory.total,clocks.gr', - '--format=csv,noheader,nounits', - ]); - return (out && parseNvidiaSmi(out)) || readAmdGpu(); + if (Date.now() >= nvidiaSkipUntil) { + const out = await run('nvidia-smi', [ + '--query-gpu=utilization.gpu,memory.used,memory.total,clocks.gr', + '--format=csv,noheader,nounits', + ]); + const reading = out ? parseNvidiaSmi(out) : undefined; + if (reading) return reading; + nvidiaSkipUntil = Date.now() + READER_BACKOFF_MS; + } + return readAmdGpu(); } /** A GPU reading from the Windows counters (WSL only): load and memory in use, but no clock. */ diff --git a/src/web/public/index.html b/src/web/public/index.html index a6bc3859f..f8c124153 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -194,11 +194,11 @@
--% -