Inprove cpu data acuisition on backend

This commit is contained in:
Jaret Burkett
2026-08-16 08:42:36 -06:00
parent 2cbc2bb097
commit 61310c6397
2 changed files with 241 additions and 7 deletions

227
ui/src/server/cpuStats.ts Normal file
View File

@@ -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<MemoryStats> {
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<LinuxTempSource | null> {
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<number | null> {
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<number | null> {
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;
}
}

View File

@@ -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