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/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/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..c2128de32 --- /dev/null +++ b/src/host-sensors.ts @@ -0,0 +1,499 @@ +/** + * @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; +/** 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'; +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. */ +let wsl: boolean | undefined; +function inWsl(): boolean { + wsl ??= + process.platform === 'linux' && + (Boolean(process.env.WSL_INTEROP) || + existsSync('/proc/sys/fs/binfmt_misc/WSLInterop') || + existsSync('/proc/sys/fs/binfmt_misc/WSLInterop-late')); + return wsl; +} + +let windowsRamMB: Promise | undefined; +let ramRetryAt = 0; + +/** + * 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; + ramRetryAt = Date.now() + READER_BACKOFF_MS; + } + 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 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; + /** 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 ?? 0; + 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 latest: { at: number; value: WindowsCounters } | undefined; + let waiting: Array<() => void> = []; + + const stop = () => { + const old = child; + child = 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(); + // 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; + try { + started = options.start(counters); + } catch { + blockedUntil = now() + backoffMs; + return; + } + 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) { + 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 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; + 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; + if (firstSampleMs > 0) { + 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 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 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'>, + now: () => number = Date.now +) { + let gpuUntil = 0; + return { + /** + * 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() { + gpuUntil = now() + GPU_COUNTERS_HOLD_MS; + }, + read: () => stream.read(windowsCounterList(now() < gpuUntil)), + 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(TYPEPERF, ['-si', '2', ...counters], { stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }), +}); +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 { + 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 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 { + 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. */ +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. + 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; +} + +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 {}; + 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 () => { + // 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 ? (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.set(key, { at: Date.now(), value }); + return value; + })().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 033838544..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(); } @@ -2459,8 +2457,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 +5023,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..f8c124153 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..118c347c1 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -6716,13 +6716,29 @@ Object.assign(CodemanApp.prototype, { // Skip polling when system stats display is hidden const statsEl = document.getElementById('headerSystemStats'); if (!statsEl || statsEl.style.display === 'none') return; + // One request at a time: the poll fires every 2 s, and a slow answer (a cold WSL sampler, a hung + // server) must not pile up requests behind it. + if (this._systemStatsPending) return; + this._systemStatsPending = true; 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('&') : ''), { + signal: + typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function' + ? AbortSignal.timeout(8000) + : undefined, + }); const stats = await res.json(); this.updateSystemStatsDisplay(stats.data); } catch (err) { // Silently fail - system stats are not critical + } finally { + this._systemStatsPending = false; } }, @@ -6767,6 +6783,58 @@ 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); + }, + + /** + * 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 { + 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)); + 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); + 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 5504719c7..84874d379 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, @@ -3234,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, @@ -4113,6 +4121,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 +4890,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/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 new file mode 100644 index 000000000..a02f0d71b --- /dev/null +++ b/test/header-host-sensors-ui.test.ts @@ -0,0 +1,163 @@ +/** + * @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, options: { hold?: boolean } = {}) { + const els = new Map(); + const fetched: string[] = []; + let release: () => void = () => undefined; + const gate = options.hold ? new Promise((resolve) => (release = resolve)) : null; + class CodemanApp {} + const context = vm.createContext({ + CodemanApp, + window: {}, + document: { getElementById: () => ({ style: {} }) }, + fetch: async (url: string) => { + fetched.push(url); + if (gate) await gate; + 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, release }; +} + +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('runs one stats request at a time, so a slow answer cannot pile requests up behind it', async () => { + const { app, fetched, release } = harness({}, { hold: true }); + const polls = [app.fetchSystemStats.call(app), app.fetchSystemStats.call(app), app.fetchSystemStats.call(app)]; + expect(fetched).toHaveLength(1); + release(); + await Promise.all(polls); + // answered: the next poll goes out again + await app.fetchSystemStats.call(app); + expect(fetched).toHaveLength(2); + }); + + 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(``)); + } + const settingsUi = read('settings-ui.js'); + expect(settingsUi).toContain('showGpuStats ?? defaults.showGpuStats ?? false'); + expect(settingsUi).toContain('showCpuMhz ?? defaults.showCpuMhz ?? false'); + // 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,'); + const schemas = readFileSync(join(process.cwd(), 'src/web/schemas.ts'), 'utf-8'); + expect(schemas).not.toContain('showCpuMhz'); + expect(schemas).not.toContain('showGpuStats'); + }); +}); 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-readers.test.ts b/test/host-sensors-readers.test.ts new file mode 100644 index 000000000..8030a77be --- /dev/null +++ b/test/host-sensors-readers.test.ts @@ -0,0 +1,162 @@ +/** + * The order and the back-offs of readHostSensors, with `child_process` and the sysfs reads mocked so no + * hardware is needed: native GPU readers go first (a machine with a working nvidia-smi never starts the + * Windows GPU counters), a failing reader is left alone for a minute, a cold Windows sampler is not + * waited for, and the GPU counters leave the sampler again once nothing needs them. + */ +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type ExecCall = { cmd: string; args: string[] }; +const state = vi.hoisted(() => ({ + nvidia: '' as string | Error, + powershell: '' as string | Error, + execCalls: [] as Array<{ cmd: string; args: string[] }>, + spawnCalls: [] as Array<{ cmd: string; args: string[]; stdout: unknown }>, +})); + +vi.mock('node:child_process', () => ({ + execFile: (cmd: string, args: string[], _opts: unknown, cb: (err: Error | null, stdout: string) => void) => { + state.execCalls.push({ cmd, args }); + const answer = cmd === 'nvidia-smi' ? state.nvidia : cmd.endsWith('powershell.exe') ? state.powershell : ''; + if (answer instanceof Error) cb(answer, ''); + else cb(null, answer); + }, + spawn: (cmd: string, args: string[]) => { + const stdout = new PassThrough(); + const child = Object.assign(new EventEmitter(), { stdout, kill: () => true }); + state.spawnCalls.push({ cmd, args, stdout }); + return child; + }, +})); +vi.mock('node:fs/promises', () => ({ + readdir: async () => [], + readFile: async () => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }, +})); + +const NVIDIA = '37, 2048, 8192, 1815\n'; +const HEADER = + '"(PDH-CSV 4.0)",' + + '"\\\\H\\GPU Engine(pid_1_luid_0x00000000_0x000139EC_phys_0_eng_0_engtype_3D)\\Utilization Percentage",' + + '"\\\\H\\GPU Adapter Memory(luid_0x00000000_0x000139EC_phys_0)\\Shared Usage",' + + '"\\\\H\\GPU Adapter Memory(luid_0x00000000_0x000139EC_phys_0)\\Dedicated Usage",' + + '"\\\\H\\Processor Information(_Total)\\Processor Frequency"\r\n'; +const ROW = '"t","20","1073741824","0","1650"\r\n'; + +async function load() { + vi.resetModules(); + return import('../src/host-sensors.js'); +} +const counted = (cmd: string) => state.execCalls.filter((c: ExecCall) => c.cmd.endsWith(cmd)).length; +const tick = () => new Promise((r) => setImmediate(r)); + +beforeEach(() => { + state.nvidia = NVIDIA; + state.powershell = new Error('powershell failed'); + state.execCalls = []; + state.spawnCalls = []; + process.env.WSL_INTEROP = '1'; + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-24T00:00:00Z')); +}); +afterEach(() => { + delete process.env.WSL_INTEROP; + vi.useRealTimers(); +}); + +describe('native readers first', () => { + it('answers from nvidia-smi on WSL and never starts the Windows GPU counters', async () => { + const { readHostSensors, stopHostSensors } = await load(); + const out = await readHostSensors({ gpu: true }); + expect(out.gpu).toMatchObject({ percent: 37, memUsedMB: 2048, memPercent: 25, mhz: 1815 }); + expect(state.spawnCalls).toEqual([]); + stopHostSensors(); + }); + + it('starts only the CPU clock counter for a CPU MHz request, and never waits for it', async () => { + const { readHostSensors, stopHostSensors } = await load(); + const started = Date.now(); + const out = await readHostSensors({ cpuMhz: true }); + expect(Date.now() - started).toBeLessThan(1000); + expect(out).toEqual({}); + expect(state.spawnCalls).toHaveLength(1); + expect(state.spawnCalls[0].args.join(' ')).toContain('Processor Frequency'); + expect(state.spawnCalls[0].args.join(' ')).not.toContain('GPU Engine'); + stopHostSensors(); + }); + + it('falls back to the Windows counters, GPU ones included, when nvidia-smi gives nothing', async () => { + state.nvidia = new Error('nvidia-smi: command not found'); + const { readHostSensors, stopHostSensors } = await load(); + await readHostSensors({ gpu: true }); + expect(state.spawnCalls).toHaveLength(1); + expect(state.spawnCalls[0].args.join(' ')).toContain('GPU Engine'); + stopHostSensors(); + }); +}); + +describe('a failing reader is left alone for a minute', () => { + it('does not run nvidia-smi on every poll of a host without one, and tries again after the back-off', async () => { + state.nvidia = new Error('nvidia-smi: command not found'); + const { readHostSensors, stopHostSensors } = await load(); + for (let i = 0; i < 5; i++) { + await readHostSensors({ gpu: true }); + vi.setSystemTime(Date.now() + 2500); // past the 2 s cache each time + } + expect(counted('nvidia-smi')).toBe(1); + vi.setSystemTime(Date.now() + 61_000); + await readHostSensors({ gpu: true }); + expect(counted('nvidia-smi')).toBe(2); + stopHostSensors(); + }); + + it('does not retry a failing PowerShell on every GPU poll', async () => { + state.nvidia = new Error('no nvidia'); + const { readHostSensors, stopHostSensors } = await load(); + await readHostSensors({ gpu: true }); // starts the sampler + (state.spawnCalls[0].stdout as PassThrough).write(HEADER + ROW); + await tick(); + for (let i = 0; i < 4; i++) { + vi.setSystemTime(Date.now() + 2500); + // the real sampler emits a row every 2 s + (state.spawnCalls[0].stdout as PassThrough).write(ROW); + await tick(); + const out = await readHostSensors({ gpu: true }); + expect(out.gpu).toMatchObject({ percent: 20, memUsedMB: 1024 }); + expect(out.gpu?.memPercent).toBeUndefined(); // no RAM total without PowerShell + } + expect(counted('powershell.exe')).toBe(1); + vi.setSystemTime(Date.now() + 61_000); + (state.spawnCalls[0].stdout as PassThrough).write(ROW); + await tick(); + await readHostSensors({ gpu: true }); + expect(counted('powershell.exe')).toBe(2); + stopHostSensors(); + }); +}); + +describe('the GPU counters do not stay in the sampler for good', () => { + it('holds them for a while after the last read that needed them, then drops them', async () => { + const { createWindowsSource, windowsCounterList } = await load(); + let clock = 1_000; + const started: string[][] = []; + const stream = { + read: async (counters: string[]) => { + started.push(counters); + return undefined; + }, + stop: () => undefined, + }; + const source = createWindowsSource(stream, () => clock); + await source.read(); + source.needGpuCounters(); + clock += 60_000; + await source.read(); // still inside the hold + clock += 61_000; // 121 s after the last need + await source.read(); + expect(started).toEqual([windowsCounterList(false), windowsCounterList(true), windowsCounterList(false)]); + }); +}); diff --git a/test/host-sensors.test.ts b/test/host-sensors.test.ts new file mode 100644 index 000000000..e6733e580 --- /dev/null +++ b/test/host-sensors.test.ts @@ -0,0 +1,285 @@ +/** + * @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, + createWindowsSource, + windowsCounterList, + 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('does not wait at all by default: a cold sampler answers nothing and the next read has the sample', async () => { + const children: PassThrough[] = []; + const stream = createCounterStream({ + start: () => { + const stdout = new PassThrough(); + children.push(stdout); + return { stdout, kill: () => true, once: () => undefined } as never; + }, + }); + const counters = ['\\Processor Information(_Total)\\Processor Frequency']; + const header = '"(PDH-CSV 4.0)","\\\\H\\Processor Information(_Total)\\Processor Frequency"\r\n'; + const started = Date.now(); + expect(await stream.read(counters)).toBeUndefined(); + expect(Date.now() - started).toBeLessThan(500); + children[0].write(header + '"t","1700"\r\n'); + await new Promise((r) => setImmediate(r)); + expect(await stream.read(counters)).toEqual({ cpuMhz: 1700 }); + expect(children).toHaveLength(1); + }); + + it('answers nothing, within the wait, when no sample ever arrives', async () => { + const { stream } = fake(); + 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 }); + }); +}); 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 ========== 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;