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
+54
-21
@@ -48,6 +48,10 @@ export interface SensorRequest {
|
||||
const CACHE_MS = 2000;
|
||||
/** A counter sample older than this (a few sampling intervals) is no longer a live reading. */
|
||||
const FRESH_MS = 10000;
|
||||
/** After a reader fails, leave it alone this long: nvidia-smi absent or timing out, PowerShell failing. */
|
||||
const READER_BACKOFF_MS = 60000;
|
||||
/** The GPU counters stay in the sampler this long after the last GPU read that needed them. */
|
||||
const GPU_COUNTERS_HOLD_MS = 120000;
|
||||
const EXEC_TIMEOUT_MS = 1500;
|
||||
const SYS_DRM = '/sys/class/drm';
|
||||
const SYS_CPU = '/sys/devices/system/cpu';
|
||||
@@ -191,16 +195,24 @@ function inWsl(): boolean {
|
||||
}
|
||||
|
||||
let windowsRamMB: Promise<number | undefined> | undefined;
|
||||
let ramRetryAt = 0;
|
||||
|
||||
/** Windows' installed RAM, asked once (PowerShell takes about a second) and remembered when it answers. */
|
||||
/**
|
||||
* Windows' installed RAM, asked once (PowerShell takes about a second) and remembered when it answers. A
|
||||
* failure is not retried on every GPU poll (8 s timeout each): it waits READER_BACKOFF_MS first.
|
||||
*/
|
||||
function readWindowsRamMB(): Promise<number | undefined> {
|
||||
if (!windowsRamMB && Date.now() < ramRetryAt) return Promise.resolve(undefined);
|
||||
windowsRamMB ??= run(
|
||||
POWERSHELL,
|
||||
['-NoProfile', '-NonInteractive', '-Command', '(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory'],
|
||||
8000
|
||||
).then((out) => {
|
||||
const bytes = out ? finite(Number(out.trim())) : undefined;
|
||||
if (!bytes || bytes <= 0) windowsRamMB = undefined;
|
||||
if (!bytes || bytes <= 0) {
|
||||
windowsRamMB = undefined;
|
||||
ramRetryAt = Date.now() + READER_BACKOFF_MS;
|
||||
}
|
||||
return bytes && bytes > 0 ? Math.round(bytes / MB) : undefined;
|
||||
});
|
||||
return windowsRamMB;
|
||||
@@ -212,7 +224,11 @@ export type SamplerChild = Pick<ChildProcess, 'stdout' | 'kill' | 'once'>;
|
||||
export interface CounterStreamOptions {
|
||||
start: (counters: string[]) => SamplerChild;
|
||||
now?: () => number;
|
||||
/** How long the first read waits for the first sample. */
|
||||
/**
|
||||
* How long a read waits for a sample when it has none fresh (0, the default, never waits: the read
|
||||
* answers undefined and the next poll shows the reading). The stats route awaits the sensors before it
|
||||
* answers CPU and MEM, so a cold sampler must not hold it up.
|
||||
*/
|
||||
firstSampleMs?: number;
|
||||
/** A sampler older than this is replaced, so counters of processes that started since are picked up. */
|
||||
recycleMs?: number;
|
||||
@@ -230,7 +246,7 @@ export interface CounterStreamOptions {
|
||||
*/
|
||||
export function createCounterStream(options: CounterStreamOptions) {
|
||||
const now = options.now ?? Date.now;
|
||||
const firstSampleMs = options.firstSampleMs ?? 8000;
|
||||
const firstSampleMs = options.firstSampleMs ?? 0;
|
||||
const recycleMs = options.recycleMs ?? 30000;
|
||||
const idleMs = options.idleMs ?? 30000;
|
||||
const backoffMs = options.backoffMs ?? 60000;
|
||||
@@ -307,7 +323,7 @@ export function createCounterStream(options: CounterStreamOptions) {
|
||||
};
|
||||
|
||||
return {
|
||||
/** The newest sample for these counters; the first read waits briefly for one. */
|
||||
/** The newest fresh sample for these counters, or undefined (a cold sampler is not waited for unless `firstSampleMs` says so). */
|
||||
async read(counters: string[]): Promise<WindowsCounters | undefined> {
|
||||
lastRead = now();
|
||||
if (now() < blockedUntil) return undefined;
|
||||
@@ -315,10 +331,12 @@ export function createCounterStream(options: CounterStreamOptions) {
|
||||
if (!child || key !== wanted || now() - startedAt > recycleMs) begin(counters);
|
||||
if (!latest || key !== wanted || now() - latest.at >= FRESH_MS) {
|
||||
if (!child) return undefined;
|
||||
await new Promise<void>((resolve) => {
|
||||
waiting.push(resolve);
|
||||
setTimeout(resolve, firstSampleMs).unref();
|
||||
});
|
||||
if (firstSampleMs > 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
waiting.push(resolve);
|
||||
setTimeout(resolve, firstSampleMs).unref();
|
||||
});
|
||||
}
|
||||
}
|
||||
return latest && now() - latest.at < FRESH_MS ? latest.value : undefined;
|
||||
},
|
||||
@@ -342,21 +360,28 @@ const CPU_COUNTERS = ['\\Processor Information(_Total)\\Processor Frequency'];
|
||||
|
||||
/**
|
||||
* The counters one sampler reads. They do not depend on which reading a browser asked for (those are per
|
||||
* device, so two browsers can differ): the CPU clock is always sampled, and the GPU counters are added, once
|
||||
* and for good, on a machine with no native GPU reader. A request then only picks its fields from the sample.
|
||||
* device, so two browsers can differ): the CPU clock is always sampled, and the GPU counters are added on a
|
||||
* machine with no native GPU reader (and dropped again once nothing has needed them for a while). A request then only picks its fields from the sample.
|
||||
*/
|
||||
export function windowsCounterList(needGpu: boolean): string[] {
|
||||
return needGpu ? [...GPU_COUNTERS, ...CPU_COUNTERS] : [...CPU_COUNTERS];
|
||||
}
|
||||
|
||||
export function createWindowsSource(stream: Pick<ReturnType<typeof createCounterStream>, 'read' | 'stop'>) {
|
||||
let needGpu = false;
|
||||
export function createWindowsSource(
|
||||
stream: Pick<ReturnType<typeof createCounterStream>, 'read' | 'stop'>,
|
||||
now: () => number = Date.now
|
||||
) {
|
||||
let gpuUntil = 0;
|
||||
return {
|
||||
/** A GPU reading is wanted and no native reader answered: sample the GPU counters too. */
|
||||
/**
|
||||
* A GPU reading is wanted and no native reader answered: sample the GPU counters too, for
|
||||
* GPU_COUNTERS_HOLD_MS after the last such read. A single failed nvidia-smi therefore costs a sampler
|
||||
* restart, not a GPU sampler for the life of the server.
|
||||
*/
|
||||
needGpuCounters() {
|
||||
needGpu = true;
|
||||
gpuUntil = now() + GPU_COUNTERS_HOLD_MS;
|
||||
},
|
||||
read: () => stream.read(windowsCounterList(needGpu)),
|
||||
read: () => stream.read(windowsCounterList(now() < gpuUntil)),
|
||||
stop: () => stream.stop(),
|
||||
};
|
||||
}
|
||||
@@ -399,12 +424,20 @@ async function readAmdGpu(): Promise<GpuReading | undefined> {
|
||||
}
|
||||
|
||||
/** NVIDIA, then AMD: the readers that need nothing from Windows. */
|
||||
let nvidiaSkipUntil = 0;
|
||||
|
||||
/** NVIDIA, then AMD. A host without a working nvidia-smi is not asked again on every poll. */
|
||||
async function readNativeGpu(): Promise<GpuReading | undefined> {
|
||||
const out = await run('nvidia-smi', [
|
||||
'--query-gpu=utilization.gpu,memory.used,memory.total,clocks.gr',
|
||||
'--format=csv,noheader,nounits',
|
||||
]);
|
||||
return (out && parseNvidiaSmi(out)) || readAmdGpu();
|
||||
if (Date.now() >= nvidiaSkipUntil) {
|
||||
const out = await run('nvidia-smi', [
|
||||
'--query-gpu=utilization.gpu,memory.used,memory.total,clocks.gr',
|
||||
'--format=csv,noheader,nounits',
|
||||
]);
|
||||
const reading = out ? parseNvidiaSmi(out) : undefined;
|
||||
if (reading) return reading;
|
||||
nvidiaSkipUntil = Date.now() + READER_BACKOFF_MS;
|
||||
}
|
||||
return readAmdGpu();
|
||||
}
|
||||
|
||||
/** A GPU reading from the Windows counters (WSL only): load and memory in use, but no clock. */
|
||||
|
||||
@@ -194,11 +194,11 @@
|
||||
</div>
|
||||
<span class="stat-value" id="statVram">--%</span>
|
||||
</div>
|
||||
<div class="stat-item stat-mhz" id="statGpuMhzItem" hidden>
|
||||
<div class="stat-item" id="statGpuMhzItem" hidden>
|
||||
<span class="stat-label">GPU</span>
|
||||
<span class="stat-value" id="statGpuMhz">-- MHz</span>
|
||||
</div>
|
||||
<div class="stat-item stat-mhz" id="statCpuMhzItem" hidden>
|
||||
<div class="stat-item" id="statCpuMhzItem" hidden>
|
||||
<span class="stat-label">CPU</span>
|
||||
<span class="stat-value" id="statCpuMhz">-- MHz</span>
|
||||
</div>
|
||||
|
||||
@@ -6716,6 +6716,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Skip polling when system stats display is hidden
|
||||
const statsEl = document.getElementById('headerSystemStats');
|
||||
if (!statsEl || statsEl.style.display === 'none') return;
|
||||
// One request at a time: the poll fires every 2 s, and a slow answer (a cold WSL sampler, a hung
|
||||
// server) must not pile up requests behind it.
|
||||
if (this._systemStatsPending) return;
|
||||
this._systemStatsPending = true;
|
||||
|
||||
try {
|
||||
// Opt-in readings are only requested while their header setting is on (default off).
|
||||
@@ -6723,11 +6727,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
const wanted = ['gpu', settings.showGpuStats, 'cpumhz', settings.showCpuMhz];
|
||||
const query = [];
|
||||
for (let i = 0; i < wanted.length; i += 2) if (wanted[i + 1] === true) query.push(`${wanted[i]}=1`);
|
||||
const res = await fetch('/api/system/stats' + (query.length ? '?' + query.join('&') : ''));
|
||||
const res = await fetch('/api/system/stats' + (query.length ? '?' + query.join('&') : ''), {
|
||||
signal:
|
||||
typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
|
||||
? AbortSignal.timeout(8000)
|
||||
: undefined,
|
||||
});
|
||||
const stats = await res.json();
|
||||
this.updateSystemStatsDisplay(stats.data);
|
||||
} catch (err) {
|
||||
// Silently fail - system stats are not critical
|
||||
} finally {
|
||||
this._systemStatsPending = false;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in new issue
Block a user