mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Merge pull request #692: feat(header): opt-in GPU and CPU clock readings after CPU and MEM
Conflict in config/test-suites.ts resolved by keeping both sides: #689's header-divider and #692's header-host-sensors browser tests are both listed in BROWSER_TEST_GLOBS. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
c7b6226cb0
19 files changed
+1389
-17
No files matched your search
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* @fileoverview The opt-in header readings (GPU, VRAM, GPU MHz, CPU MHz): all off by default, requested from
|
||||
* the server only while their setting is on, and shown only when the host reported them.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const read = (name: string) => readFileSync(join(process.cwd(), 'src/web/public', name), 'utf-8');
|
||||
|
||||
type El = {
|
||||
hidden: boolean;
|
||||
textContent: string;
|
||||
style: Record<string, string>;
|
||||
classList: ReturnType<typeof classList>;
|
||||
};
|
||||
const classList = () => {
|
||||
const set = new Set<string>();
|
||||
return {
|
||||
set,
|
||||
add: (c: string) => set.add(c),
|
||||
remove: (c: string) => set.delete(c),
|
||||
toggle: (c: string, force?: boolean) => {
|
||||
const on = force ?? !set.has(c);
|
||||
if (on) set.add(c);
|
||||
else set.delete(c);
|
||||
return on;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
window: {},
|
||||
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 },
|
||||
setInterval: () => 0,
|
||||
clearInterval: () => {},
|
||||
console,
|
||||
});
|
||||
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
|
||||
const app = new (context.CodemanApp as new () => Record<string, (...a: unknown[]) => unknown>)();
|
||||
Object.assign(app, {
|
||||
loadAppSettingsFromStorage: () => settings,
|
||||
$: (id: string) => {
|
||||
if (!els.has(id))
|
||||
els.set(id, {
|
||||
hidden: true,
|
||||
textContent: '',
|
||||
style: { setProperty: () => {} } as unknown as Record<string, string>,
|
||||
classList: classList(),
|
||||
});
|
||||
return els.get(id);
|
||||
},
|
||||
});
|
||||
return { app, els, fetched, release };
|
||||
}
|
||||
|
||||
describe('header host sensors', () => {
|
||||
it('requests nothing extra from the server when every setting is off (the default)', async () => {
|
||||
const { app, fetched } = harness({});
|
||||
await app.fetchSystemStats.call(app);
|
||||
expect(fetched).toEqual(['/api/system/stats']);
|
||||
});
|
||||
|
||||
it('asks for exactly the readings whose setting is on', async () => {
|
||||
const { app, fetched } = harness({ showGpuStats: true, showCpuMhz: true });
|
||||
await app.fetchSystemStats.call(app);
|
||||
expect(fetched).toEqual(['/api/system/stats?gpu=1&cpumhz=1']);
|
||||
});
|
||||
|
||||
it('shows an item only when its setting is on and the host reported it', () => {
|
||||
const stats = {
|
||||
gpu: { percent: 42, memPercent: 50, memUsedMB: 4096, memTotalMB: 8192, mhz: 1800 },
|
||||
cpuMhz: 3400,
|
||||
};
|
||||
const on = harness({ showGpuStats: true, showCpuMhz: true });
|
||||
on.app._updateHostSensors(stats);
|
||||
for (const id of ['statGpuItem', 'statVramItem', 'statCpuMhzItem', 'statGpuMhzItem']) {
|
||||
expect(on.els.get(id)?.hidden, id).toBe(false);
|
||||
}
|
||||
expect(on.els.get('statGpu')?.textContent).toBe('42%');
|
||||
expect(on.els.get('statVram')?.textContent).toBe('4.0G');
|
||||
expect(on.els.get('statCpuMhz')?.textContent).toBe('3400 MHz');
|
||||
expect(on.els.get('statGpuMhz')?.textContent).toBe('1800 MHz');
|
||||
|
||||
const off = harness({});
|
||||
off.app._updateHostSensors(stats);
|
||||
for (const id of ['statGpuItem', 'statVramItem', 'statCpuMhzItem', 'statGpuMhzItem']) {
|
||||
expect(off.els.get(id)?.hidden, id).toBe(true);
|
||||
}
|
||||
|
||||
// the GPU clock belongs to GPU Stats; with only CPU MHz on it stays hidden
|
||||
const cpuOnly = harness({ showCpuMhz: true });
|
||||
cpuOnly.app._updateHostSensors(stats);
|
||||
expect(cpuOnly.els.get('statCpuMhzItem')?.hidden).toBe(false);
|
||||
expect(cpuOnly.els.get('statGpuMhzItem')?.hidden).toBe(true);
|
||||
const gpuOnly = harness({ showGpuStats: true });
|
||||
gpuOnly.app._updateHostSensors(stats);
|
||||
expect(gpuOnly.els.get('statGpuMhzItem')?.hidden).toBe(false);
|
||||
expect(gpuOnly.els.get('statCpuMhzItem')?.hidden).toBe(true);
|
||||
|
||||
const noHardware = harness({ showGpuStats: true, showCpuMhz: true });
|
||||
noHardware.app._updateHostSensors({});
|
||||
expect(noHardware.els.get('statGpuItem')?.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('shows graphics memory in use even when its total is unknown, with no ring', () => {
|
||||
const { app, els } = harness({ showGpuStats: true });
|
||||
app._updateHostSensors({ gpu: { percent: 5, memUsedMB: 1536 } });
|
||||
expect(els.get('statVramItem')?.hidden).toBe(false);
|
||||
expect(els.get('statVram')?.textContent).toBe('1.5G');
|
||||
expect(els.get('statVramRing')?.style.display).toBe('none');
|
||||
app._updateHostSensors({ gpu: { percent: 5, memUsedMB: 1536, memTotalMB: 8192, memPercent: 19 } });
|
||||
expect(els.get('statVramRing')?.style.display).toBe('');
|
||||
});
|
||||
|
||||
it('keeps polling the header stats with no session open, so CPU, MEM and the readings are never stuck at --', () => {
|
||||
const appJs = read('app.js');
|
||||
expect(appJs).toContain('if (!this.systemStatsInterval) this.startSystemStatsPolling();');
|
||||
expect(appJs).not.toMatch(/sessions\.size === 0\) this\.stopSystemStatsPolling\(\)/);
|
||||
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']) {
|
||||
expect(html).toMatch(new RegExp(`<input type="checkbox" id="${id}">`));
|
||||
}
|
||||
const settingsUi = read('settings-ui.js');
|
||||
expect(settingsUi).toContain('showGpuStats ?? defaults.showGpuStats ?? false');
|
||||
expect(settingsUi).toContain('showCpuMhz ?? defaults.showCpuMhz ?? false');
|
||||
// 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,');
|
||||
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,48 @@
|
||||
/**
|
||||
* Real Chromium, real header markup and styles.css: the opt-in host readings stay out of the header until they
|
||||
* are switched on and reported, under every header-stats style, and a reported one is drawn with its value.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
|
||||
const dir = resolve(import.meta.dirname, '../src/web/public');
|
||||
const styles = readFileSync(resolve(dir, 'styles.css'), 'utf8');
|
||||
const header = /<header class="header">[\s\S]*?<\/header>/.exec(readFileSync(resolve(dir, 'index.html'), 'utf8'))![0];
|
||||
const ITEMS = ['statGpuItem', 'statVramItem', 'statCpuMhzItem', 'statGpuMhzItem'];
|
||||
|
||||
describe('opt-in host readings in the header (real Chromium)', () => {
|
||||
let browser: Browser;
|
||||
let p: Page;
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
for (const style of ['compact', 'tiles', 'classic']) {
|
||||
it(`draws none of them until reported, and the reported one with its value, in the ${style} style`, async () => {
|
||||
p = await browser.newPage({ viewport: { width: 1500, height: 400 } });
|
||||
await p.setContent(
|
||||
`<!doctype html><html data-header-stats="${style}" data-tab-orientation="horizontal" data-session-list="header"><head><style>${styles}</style></head><body>${header}</body></html>`
|
||||
);
|
||||
const shown = () =>
|
||||
p.evaluate(
|
||||
(ids) => ids.filter((id) => getComputedStyle(document.getElementById(id)!).display !== 'none'),
|
||||
ITEMS
|
||||
);
|
||||
expect(await shown()).toEqual([]);
|
||||
await p.evaluate(() => {
|
||||
document.getElementById('statGpuItem')!.hidden = false;
|
||||
document.getElementById('statGpu')!.textContent = '42%';
|
||||
});
|
||||
expect(await shown()).toEqual(['statGpuItem']);
|
||||
const box = await p.locator('#statGpuItem').boundingBox();
|
||||
expect(box!.width).toBeGreaterThan(20);
|
||||
expect(await p.locator('#statGpuItem').innerText()).toContain('42%');
|
||||
await p.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 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, neither a cold Windows sampler nor
|
||||
* the PowerShell RAM probe is 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,
|
||||
/** When set, PowerShell does not answer until the test calls the callbacks kept here. */
|
||||
holdPowershell: false,
|
||||
heldPowershell: [] as Array<() => void>,
|
||||
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 });
|
||||
if (state.holdPowershell && cmd.endsWith('powershell.exe')) {
|
||||
state.heldPowershell.push(() => {
|
||||
const held = state.powershell;
|
||||
if (held instanceof Error) cb(held, '');
|
||||
else cb(null, held);
|
||||
});
|
||||
return;
|
||||
}
|
||||
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.holdPowershell = false;
|
||||
state.heldPowershell = [];
|
||||
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 PowerShell RAM probe', () => {
|
||||
it('is never waited for: the memory in use shows at once, and the total once Windows has answered', async () => {
|
||||
state.nvidia = new Error('no nvidia');
|
||||
state.holdPowershell = true;
|
||||
const { readHostSensors, stopHostSensors } = await load();
|
||||
await readHostSensors({ gpu: true }); // starts the sampler
|
||||
(state.spawnCalls[0].stdout as PassThrough).write(HEADER + ROW);
|
||||
await tick();
|
||||
vi.setSystemTime(Date.now() + 2500);
|
||||
// PowerShell has not answered (and never will unless the test lets it): the read must not wait for it
|
||||
const first = await Promise.race([
|
||||
readHostSensors({ gpu: true }),
|
||||
new Promise<'waited'>((r) => setTimeout(() => r('waited'), 1000)),
|
||||
]);
|
||||
expect(first).not.toBe('waited');
|
||||
expect((first as { gpu?: object }).gpu).toEqual({ percent: 20, memUsedMB: 1024 });
|
||||
expect(counted('powershell.exe')).toBe(1);
|
||||
// a poll while the probe is still running starts no second PowerShell
|
||||
vi.setSystemTime(Date.now() + 2500);
|
||||
(state.spawnCalls[0].stdout as PassThrough).write(ROW);
|
||||
await tick();
|
||||
expect((await readHostSensors({ gpu: true })).gpu?.memTotalMB).toBeUndefined();
|
||||
expect(counted('powershell.exe')).toBe(1);
|
||||
// Windows answers 16 GiB: an integrated GPU may use half of it
|
||||
state.powershell = '17179869184\r\n';
|
||||
state.heldPowershell.forEach((answer) => answer());
|
||||
await tick();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
vi.setSystemTime(Date.now() + 2500);
|
||||
(state.spawnCalls[0].stdout as PassThrough).write(ROW);
|
||||
await tick();
|
||||
const out = await readHostSensors({ gpu: true });
|
||||
expect(out.gpu).toEqual({ percent: 20, memUsedMB: 1024, memTotalMB: 8192, memPercent: 13 });
|
||||
}
|
||||
// the answer is kept: PowerShell is not asked again
|
||||
expect(counted('powershell.exe')).toBe(1);
|
||||
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)]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* @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 });
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
|
||||
|
||||
const hostSensors = vi.hoisted(() => ({ readHostSensors: vi.fn(async (_request: unknown) => ({})) }));
|
||||
vi.mock('../../src/host-sensors.js', () => hostSensors);
|
||||
|
||||
// ── Mocks ──────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
@@ -326,6 +329,20 @@ describe('system-routes', () => {
|
||||
expect(body).toHaveProperty('cpu');
|
||||
expect(body).toHaveProperty('memory');
|
||||
});
|
||||
|
||||
it('adds no GPU or clock readings unless they are asked for', async () => {
|
||||
const body = JSON.parse((await harness.app.inject({ method: 'GET', url: '/api/system/stats' })).body);
|
||||
expect(body).not.toHaveProperty('gpu');
|
||||
expect(body).not.toHaveProperty('cpuMhz');
|
||||
});
|
||||
|
||||
it('passes only the requested readings on to the sensor reader', async () => {
|
||||
hostSensors.readHostSensors.mockClear();
|
||||
hostSensors.readHostSensors.mockResolvedValueOnce({ cpuMhz: 3200 });
|
||||
const res = await harness.app.inject({ method: 'GET', url: '/api/system/stats?cpumhz=1&gpu=0' });
|
||||
expect(hostSensors.readHostSensors).toHaveBeenCalledWith({ gpu: false, cpuMhz: true });
|
||||
expect(JSON.parse(res.body).cpuMhz).toBe(3200);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/cleanup-state ==========
|
||||
|
||||
@@ -57,8 +57,12 @@ async function send(method: string, path: string, headers: Record<string, string
|
||||
async function settleSettings(predicate: (s: Record<string, unknown>) => boolean): Promise<Record<string, unknown>> {
|
||||
let last: Record<string, unknown> = {};
|
||||
for (let i = 0; i < 40; i++) {
|
||||
last = JSON.parse(await readSettingsBytes());
|
||||
if (predicate(last)) return last;
|
||||
try {
|
||||
last = JSON.parse(await readSettingsBytes());
|
||||
if (predicate(last)) return last;
|
||||
} catch {
|
||||
// the write is not atomic: a read that lands between its truncate and its data is empty, so poll again
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
return last;
|
||||
|
||||
Reference in new issue
Block a user