mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(host-sensors): never wait on a cold sampler, back off failing readers, one stats request at a time
This commit is contained in:
1 parent
f15afe1de3
commit
23a9d78cdd
7 files changed
+270
-28
No files matched your search
@@ -30,9 +30,11 @@ const classList = () => {
|
||||
};
|
||||
};
|
||||
|
||||
function harness(settings: Record<string, unknown>) {
|
||||
function harness(settings: Record<string, unknown>, options: { hold?: boolean } = {}) {
|
||||
const els = new Map<string, El>();
|
||||
const fetched: string[] = [];
|
||||
let release: () => void = () => undefined;
|
||||
const gate = options.hold ? new Promise<void>((resolve) => (release = resolve)) : null;
|
||||
class CodemanApp {}
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
@@ -40,6 +42,7 @@ function harness(settings: Record<string, unknown>) {
|
||||
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 },
|
||||
@@ -62,7 +65,7 @@ function harness(settings: Record<string, unknown>) {
|
||||
return els.get(id);
|
||||
},
|
||||
});
|
||||
return { app, els, fetched };
|
||||
return { app, els, fetched, release };
|
||||
}
|
||||
|
||||
describe('header host sensors', () => {
|
||||
@@ -131,6 +134,17 @@ describe('header host sensors', () => {
|
||||
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']) {
|
||||
@@ -142,6 +156,8 @@ describe('header host sensors', () => {
|
||||
// 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,');
|
||||
expect(readFileSync(join(process.cwd(), 'src/web/schemas.ts'), 'utf-8')).not.toContain('showCpuMhz');
|
||||
const schemas = readFileSync(join(process.cwd(), 'src/web/schemas.ts'), 'utf-8');
|
||||
expect(schemas).not.toContain('showCpuMhz');
|
||||
expect(schemas).not.toContain('showGpuStats');
|
||||
});
|
||||
});
|
||||
@@ -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)]);
|
||||
});
|
||||
});
|
||||
@@ -171,6 +171,26 @@ describe('createCounterStream', () => {
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user