mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-13 18:29:32 +01:00
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>
289 lines
12 KiB
TypeScript
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 });
|
|
});
|
|
});
|