diff --git a/ui/src/server/cpuStats.ts b/ui/src/server/cpuStats.ts new file mode 100644 index 0000000..d00db78 --- /dev/null +++ b/ui/src/server/cpuStats.ts @@ -0,0 +1,227 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import si from 'systeminformation'; + +/** + * Spawn-free CPU load / memory / temperature readers for the monitor tick. + * + * Why this exists: `systeminformation` shells out for several of these + * (`si.currentLoad()` runs `execSync('cat /proc/stat')` on Linux, `si.mem()` + * runs PowerShell on Windows / vm_stat on macOS, `si.cpuTemperature()` runs + * `sensors`). Every child process forks the calling process first, and on + * Linux fork copies the parent's page tables — from a multi-GB Node heap that + * is ~300ms with the event loop frozen. At the 500ms tick cadence that made + * *every* request through the UI server wait behind the monitor. + * + * Everything on the tick path here reads from Node built-ins or plain files, + * on every platform. `systeminformation` is kept only as a throttled fallback + * for temperature where the OS offers no file-based source (Windows), and for + * memory on macOS when macstats isn't available. + */ + +const isLinux = os.platform() === 'linux'; +const isMac = os.platform() === 'darwin'; + +// --------------------------------------------------------------------------- +// Load: os.cpus() cumulative times, differenced between calls. Same numbers +// systeminformation derives on every platform, minus its /proc/stat shell-out. +// --------------------------------------------------------------------------- +type CpuTimes = { busy: number; total: number }; + +function readCpuTimes(): CpuTimes { + let busy = 0; + let total = 0; + for (const cpu of os.cpus()) { + const t = cpu.times; + const all = t.user + t.nice + t.sys + t.idle + t.irq; + busy += all - t.idle; + total += all; + } + return { busy, total }; +} + +/** + * Returns a function that yields the CPU load percentage (0-100) accumulated + * since the previous call. The first call reports the since-boot average + * (there is nothing else to difference against), matching systeminformation. + */ +export function createLoadSampler(): () => number { + let last: CpuTimes | null = null; + let lastLoad = 0; + return () => { + const now = readCpuTimes(); + if (last) { + const dTotal = now.total - last.total; + const dBusy = now.busy - last.busy; + // dTotal can be 0 if called twice within a jiffy; keep the last value. + if (dTotal > 0) lastLoad = Math.min(100, Math.max(0, (dBusy / dTotal) * 100)); + } else if (now.total > 0) { + lastLoad = (now.busy / now.total) * 100; + } + last = now; + return lastLoad; + }; +} + +// --------------------------------------------------------------------------- +// Memory (bytes). `available` is what the OS would hand to a new allocation +// without swapping (MemAvailable / ullAvailPhys); `free` is untouched pages. +// --------------------------------------------------------------------------- +export interface MemoryStats { + total: number; + free: number; + available: number; +} + +const memValue = (lines: string[], key: string): number => { + const line = lines.find(l => l.startsWith(key + ':')); + if (!line) return NaN; + const value = parseInt(line.slice(key.length + 1).trim(), 10); + return isNaN(value) ? NaN : value * 1024; // /proc/meminfo is in kB +}; + +export async function readMemory(): Promise { + if (isLinux) { + // Node's os.freemem() on Linux is MemAvailable on current libuv but MemFree + // on older ones; reading /proc/meminfo gives both unambiguously. + try { + const lines = (await fs.promises.readFile('/proc/meminfo', 'utf8')).split('\n'); + const total = memValue(lines, 'MemTotal'); + const free = memValue(lines, 'MemFree'); + let available = memValue(lines, 'MemAvailable'); + if (isNaN(available)) { + // Pre-3.14 kernels: same estimate systeminformation uses + available = free + (memValue(lines, 'Buffers') || 0) + (memValue(lines, 'Cached') || 0); + } + if (!isNaN(total) && !isNaN(free)) { + return { total, free, available: isNaN(available) ? free : available }; + } + } catch { + // fall through to os.* + } + } else if (isMac) { + // os.freemem() on macOS is only the truly-free page count and badly + // undercounts what's usable (inactive/purgeable pages). systeminformation + // gets the real number from vm_stat; process creation on macOS is + // posix_spawn (no page-table copy), so this is cheap there. + try { + const mem = await si.mem(); + return { total: mem.total, free: mem.free, available: mem.available }; + } catch { + // fall through to os.* + } + } + // Windows (and anything else): os.freemem() is GlobalMemoryStatusEx + // ullAvailPhys, i.e. exactly "available", which is also what + // systeminformation reports for both free and available there. + const total = os.totalmem(); + const free = os.freemem(); + return { total, free, available: free }; +} + +// --------------------------------------------------------------------------- +// Temperature (°C, or null if unknown) +// --------------------------------------------------------------------------- + +// Resolved once: which sysfs file(s) to read, and how to combine them. +type LinuxTempSource = { files: string[]; mode: 'single' | 'mean' }; +let linuxTempSource: LinuxTempSource | null | undefined; // undefined = not scanned yet + +const millideg = (raw: string): number | null => { + const v = parseInt(raw, 10); + return isNaN(v) ? null : Math.round(v / 100) / 10; +}; + +/** + * Walks /sys/class/hwmon looking for the same labels systeminformation picks + * out of `sensors` (Tctl / Tdie on AMD, "Package id N" / "Physical id N" on + * Intel, else the mean of the Core N readings), then falls back to a CPU-ish + * thermal zone. Returns null when nothing looks like a CPU sensor. + */ +async function scanLinuxTempSource(): Promise { + const hwmonRoot = '/sys/class/hwmon'; + const monitors = await fs.promises.readdir(hwmonRoot).catch(() => [] as string[]); + const labeled: { label: string; file: string }[] = []; + for (const mon of monitors) { + const dir = path.join(hwmonRoot, mon); + const files = await fs.promises.readdir(dir).catch(() => [] as string[]); + for (const file of files) { + if (!/^temp\d+_label$/.test(file)) continue; + const label = (await fs.promises.readFile(path.join(dir, file), 'utf8').catch(() => '')).trim(); + if (!label) continue; + labeled.push({ label, file: path.join(dir, file.replace(/_label$/, '_input')) }); + } + } + const byLabel = (pred: (l: string) => boolean) => labeled.filter(e => pred(e.label.toLowerCase())); + const tctl = byLabel(l => l === 'tctl'); + if (tctl.length) return { files: [tctl[0].file], mode: 'single' }; + const tdie = byLabel(l => l === 'tdie'); + if (tdie.length) return { files: [tdie[0].file], mode: 'single' }; + const pkg = byLabel(l => l.includes('package') || l.includes('physical') || l === 'tccd1'); + if (pkg.length) return { files: [pkg[0].file], mode: 'single' }; + const cores = byLabel(l => l.startsWith('core')); + if (cores.length) return { files: cores.map(c => c.file), mode: 'mean' }; + + // No labeled hwmon sensor — try the generic thermal zones (ARM SBCs, some + // laptops expose only these). + const thermalRoot = '/sys/class/thermal'; + const zones = (await fs.promises.readdir(thermalRoot).catch(() => [] as string[])).filter(z => + z.startsWith('thermal_zone'), + ); + for (const zone of zones) { + const type = (await fs.promises.readFile(path.join(thermalRoot, zone, 'type'), 'utf8').catch(() => '')) + .trim() + .toLowerCase(); + if (type === 'x86_pkg_temp' || type.includes('cpu') || type.includes('soc')) { + return { files: [path.join(thermalRoot, zone, 'temp')], mode: 'single' }; + } + } + return null; +} + +async function readLinuxTemp(): Promise { + if (linuxTempSource === undefined) { + linuxTempSource = await scanLinuxTempSource(); + } + const source = linuxTempSource; + if (!source) return null; + const values: number[] = []; + for (const file of source.files) { + const raw = await fs.promises.readFile(file, 'utf8').catch(() => null); + if (raw === null) { + // Sensor went away (module unloaded / renumbered) — rescan next time. + linuxTempSource = undefined; + return null; + } + const v = millideg(raw); + if (v !== null) values.push(v); + } + if (values.length === 0) return null; + if (source.mode === 'mean') return Math.round(values.reduce((a, b) => a + b, 0) / values.length); + return values[0]; +} + +/** + * On Linux this is a couple of sysfs reads. Elsewhere it defers to + * systeminformation (osx-temperature-sensor on macOS — an in-process native + * module; PowerShell/WMI on Windows, where process creation doesn't fork and + * so doesn't scale with our heap). Callers throttle this; it is not meant to + * run every tick. + */ +export async function readCpuTemperature(): Promise { + if (isLinux) { + const t = await readLinuxTemp(); + if (t !== null) return t; + // A known sensor that failed to read: rescan on the next call, don't + // shell out. Only when sysfs has nothing at all is `sensors` (via + // systeminformation) worth a try. + if (linuxTempSource !== null) return null; + } + try { + const t = await si.cpuTemperature(); + return typeof t.main === 'number' && !isNaN(t.main) ? t.main : null; + } catch { + return null; + } +} diff --git a/ui/src/server/monitor.ts b/ui/src/server/monitor.ts index d22867b..8431be9 100644 --- a/ui/src/server/monitor.ts +++ b/ui/src/server/monitor.ts @@ -3,6 +3,7 @@ import { promisify } from 'util'; import os from 'os'; import si from 'systeminformation'; import { loadMacstats } from '@/server/macstats'; +import { createLoadSampler, readCpuTemperature, readMemory } from '@/server/cpuStats'; import { CpuInfo, GpuInfo, GPUApiResponse, MonitorHistoryPoint, MonitorInit, MonitorSample } from '@/types'; import { historyPointFromSample, MONITOR_HISTORY_LENGTH, MONITOR_TICK_MS } from '@/utils/monitorSample'; @@ -105,6 +106,8 @@ class SystemMonitor { private cpuTempInFlight = false; private lastCpuTempAt = 0; private cpuStatic: { name: string; cores: number } | null = null; + // Differences os.cpus() times between ticks; no child process involved. + private sampleLoad = createLoadSampler(); start(): void { if (this.isMac) { @@ -213,10 +216,10 @@ class SystemMonitor { totalMemory: ramData.total / (1024 * 1024), availableMemory: ramData.free / (1024 * 1024), freeMemory: ramData.free / (1024 * 1024), - currentLoad: (await si.currentLoad()).currentLoad || 0, + currentLoad: this.sampleLoad(), }; } catch { - // Fallback to systeminformation if macstats fails + // Fallback to the generic path if macstats fails } } @@ -224,7 +227,10 @@ class SystemMonitor { // tick skip beats, so it refreshes concurrently and we use the cached // value (at most a tick or two old). this.refreshCpuTemp(); - const [memoryData, load] = await Promise.all([si.mem(), si.currentLoad()]); + // Nothing below spawns a process. Every child process forks this server + // first, and forking a multi-GB Node heap freezes the event loop for + // hundreds of ms — at tick cadence that stalled every request in the UI. + const memoryData = await readMemory(); return { name: cpuStatic.name, cores: cpuStatic.cores, @@ -232,20 +238,21 @@ class SystemMonitor { totalMemory: memoryData.total / (1024 * 1024), availableMemory: memoryData.available / (1024 * 1024), freeMemory: memoryData.free / (1024 * 1024), - currentLoad: load.currentLoad || 0, + currentLoad: this.sampleLoad(), }; } private refreshCpuTemp(): void { - // si.cpuTemperature() execs `sensors`, which can take ~1s of slow SMBus + // On Linux this is a sysfs read; elsewhere it may still shell out (via + // systeminformation), and e.g. `sensors` can take ~1s of slow SMBus // polling — refreshed back-to-back it becomes a permanently resident // child. Every few seconds is plenty for a temperature. if (this.cpuTempInFlight || Date.now() - this.lastCpuTempAt < CPU_TEMP_REFRESH_MS) return; this.lastCpuTempAt = Date.now(); this.cpuTempInFlight = true; - si.cpuTemperature() + readCpuTemperature() .then(t => { - this.lastCpuTemp = t.main || 0; + this.lastCpuTemp = t ?? 0; }) .catch(() => { // keep the previous value