Files
Codeman/test/host-sensors.test.ts
T
Codeman maintainer 7783fcd0ce fix: apply the review items for #692 at merge
host-sensors: never await the PowerShell RAM probe on a read; keep the resolved total and show the memory in use alone until it answers (test with a held PowerShell)
test: rename the Windows sampler test that said the GPU counters are added "for good"
test: make host-sensors.test.ts's @fileoverview describe what that file covers
host-sensors: make the nvidiaSkipUntil JSDoc describe the back-off deadline
docs: Settings Reference says the host readings live inside System Stats and nothing is fetched while it is off

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-24 14:15:22 +02:00

289 lines
12 KiB
TypeScript

/**
* @fileoverview The pure parsers behind the opt-in header readings (nvidia-smi, the amdgpu clock table,
* cpufreq and /proc/cpuinfo, typeperf), readHostSensors reading nothing unless a reading is asked for, the
* typeperf counter stream's life cycle (first sample, recycling, idle stop, back-off after a failed start,
* no wait on a cold sampler by default, a late row from a replaced sampler), and the Windows sampler that
* every browser shares. 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 with one restart on a machine with no native GPU reader, and keeps them while needed', 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 });
});
});