ccdeck 1.43.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,619 @@
1
+ // Machine-wide CPU and memory, sampled on our own timer so every open tab reads
2
+ // the same numbers.
3
+ //
4
+ // WHY THE SERVER SAMPLES INSTEAD OF ANSWERING ON DEMAND. CPU utilisation is not
5
+ // a value you can read; it is a ratio between two readings. `os.cpus()` returns
6
+ // cumulative tick counters, so a percentage only exists relative to a previous
7
+ // sample. If the sample were taken when a request arrived, two browser tabs
8
+ // polling half a second apart would compute their deltas from different
9
+ // baselines and print different percentages for the same machine. One timer in
10
+ // one process is the only arrangement where that cannot happen — and it is what
11
+ // lets `/api/system` hand back a real 60-second history rather than whatever a
12
+ // single tab has managed to collect since it was opened.
13
+ //
14
+ // WHY THIS NEVER TOUCHES pushEvent. Every event that goes through the deck's
15
+ // stream is persisted to events.jsonl and held in the 2000-entry ring buffer. A
16
+ // three-second sampler would put 1200 entries an hour into both, evicting real
17
+ // tool calls from the replay a reconnecting tab receives, and making an ambient
18
+ // readout the loudest producer in the application. So this is a plain poll
19
+ // endpoint, exactly like /api/quota and /api/codex-usage already are.
20
+ import { spawn } from "node:child_process";
21
+ import { readFile } from "node:fs/promises";
22
+ import os from "node:os";
23
+
24
+ /** CPU is the metric with spikes, so it is sampled often enough to catch one. */
25
+ const CPU_INTERVAL_MS = 3_000;
26
+ /** Memory moves on the scale of minutes. Sampling it at the CPU cadence would
27
+ * print the same number twenty times and, on macOS, cost a subprocess to do
28
+ * it — see readMemory. */
29
+ const MEM_INTERVAL_MS = 30_000;
30
+ /** 20 samples x 3s = the 60 seconds the sparkline draws. */
31
+ const HISTORY = 20;
32
+
33
+ let cpuTimer = null;
34
+ let memTimer = null;
35
+ let prevTicks = null;
36
+ let prevCoreTicks = null;
37
+ let cores = null;
38
+ let swap = null;
39
+ /** Newest last. Seeded empty; the first tick produces no percentage because a
40
+ * delta needs two readings. */
41
+ const cpuHistory = [];
42
+ let memory = null;
43
+ let memInFlight = false;
44
+
45
+ /** Total and idle jiffies across every core, as one pair. */
46
+ function readTicks() {
47
+ let idle = 0;
48
+ let total = 0;
49
+ for (const cpu of os.cpus()) {
50
+ for (const [kind, ms] of Object.entries(cpu.times)) {
51
+ total += ms;
52
+ if (kind === "idle") idle += ms;
53
+ }
54
+ }
55
+ return { idle, total };
56
+ }
57
+
58
+ /** The same pair, per core, in `os.cpus()` order. */
59
+ function readCoreTicks() {
60
+ return os.cpus().map(cpu => {
61
+ let idle = 0;
62
+ let total = 0;
63
+ for (const [kind, ms] of Object.entries(cpu.times)) {
64
+ total += ms;
65
+ if (kind === "idle") idle += ms;
66
+ }
67
+ return { idle, total };
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Busy percentage per core since the previous reading.
73
+ *
74
+ * The aggregate figure the topbar draws hides the shape of the load, and the
75
+ * shape is what tells a saturated machine from a machine running one hot
76
+ * single-threaded job. Same delta arithmetic as `cpuPercent`, one row per core,
77
+ * and the same refusal to invent a number before there are two readings.
78
+ */
79
+ function corePercents() {
80
+ const now = readCoreTicks();
81
+ const prev = prevCoreTicks;
82
+ prevCoreTicks = now;
83
+ if (!prev || prev.length !== now.length) return null;
84
+ return now.map((c, i) => {
85
+ const dTotal = c.total - prev[i].total;
86
+ const dIdle = c.idle - prev[i].idle;
87
+ if (dTotal <= 0) return 0;
88
+ return Math.max(0, Math.min(100, Math.round((1 - dIdle / dTotal) * 1000) / 10));
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Busy percentage across all cores since the previous reading, 0-100.
94
+ *
95
+ * Aggregate rather than per-core, and normalised rather than macOS's
96
+ * 0-to-cores*100 convention, because it has to mean the same thing on all three
97
+ * platforms and because a bar needs an end. The cost is that it saturates: a
98
+ * machine at load 12 and a machine at load 18 both read 100. `loadavg` is what
99
+ * carries that difference, which is why it rides along below on the platforms
100
+ * that report it.
101
+ */
102
+ function cpuPercent() {
103
+ const now = readTicks();
104
+ if (!prevTicks) { prevTicks = now; return null; }
105
+ const dTotal = now.total - prevTicks.total;
106
+ const dIdle = now.idle - prevTicks.idle;
107
+ prevTicks = now;
108
+ // A tick counter that did not move says nothing; it does not say "idle".
109
+ if (dTotal <= 0) return null;
110
+ const pct = (1 - dIdle / dTotal) * 100;
111
+ return Math.max(0, Math.min(100, Math.round(pct * 10) / 10));
112
+ }
113
+
114
+ /**
115
+ * The locale every child of this module is parsed in.
116
+ *
117
+ * Not a preference — a correctness requirement. Every command spawned here has
118
+ * its output read by a regex, and every one of those regexes reads a number
119
+ * with a `.` in it. `ps` and `sysctl` honour LC_NUMERIC, so on a machine set to
120
+ * de_DE, fr_FR, ru_RU or pt_BR — comma is the decimal separator for most of
121
+ * Europe and Latin America — the same commands print:
122
+ *
123
+ * ps 1 0,2 0,0 /sbin/launchd
124
+ * sysctl total = 8192,00M used = 7189,75M free = 1002,25M
125
+ *
126
+ * and the parsers matched nothing at all. Not partially: `parsePsProcesses`
127
+ * `continue`s on every row, so the process panel was permanently empty, and
128
+ * `swapFromSysctl` returned null, so the macOS swap meter was permanently
129
+ * blank. Silently, with nothing in the log, on a machine where everything else
130
+ * worked.
131
+ *
132
+ * Forcing the locale rather than teaching the parsers to read a comma is the
133
+ * fix that scales: it makes the OUTPUT invariant, which is what every parser
134
+ * here was written against and what every parser added later will assume. The
135
+ * comma tolerance below is defence in depth for the case where a sandbox strips
136
+ * the environment, not the primary answer.
137
+ *
138
+ * Meaningless on Windows, where the branches are PowerShell piped through
139
+ * ConvertTo-Json and already culture-invariant — and harmless there for the
140
+ * same reason.
141
+ */
142
+ const C_LOCALE = { LC_ALL: "C", LANG: "C" };
143
+
144
+ /** Run a command and resolve its stdout, or null. Never rejects, never inherits
145
+ * a shell, never inherits a locale, and is killed rather than allowed to hang
146
+ * the sampler. */
147
+ function run(file, args, timeoutMs = 2_000) {
148
+ return new Promise(resolve => {
149
+ let child;
150
+ try { child = spawn(file, args, { windowsHide: true, env: { ...process.env, ...C_LOCALE } }); }
151
+ catch { return resolve(null); }
152
+ let out = "";
153
+ const timer = setTimeout(() => { try { child.kill(); } catch {} resolve(null); }, timeoutMs);
154
+ child.stdout?.on("data", d => { out += d; });
155
+ child.on("error", () => { clearTimeout(timer); resolve(null); });
156
+ child.on("close", code => { clearTimeout(timer); resolve(code === 0 ? out : null); });
157
+ });
158
+ }
159
+
160
+ /**
161
+ * Bytes of memory a new process could actually get, per platform.
162
+ *
163
+ * `os.freemem()` is the obvious call and it is the wrong one on two of the three
164
+ * platforms, because "free" and "available" are different questions. Pages
165
+ * holding cached files or inactive anonymous memory are not free, but the kernel
166
+ * will hand them over the moment something asks. Reporting them as used is what
167
+ * makes the naive `(total - free) / total` read 99.5% on an idle 32 GB Mac — a
168
+ * number that would send the reader straight to Activity Monitor, which is the
169
+ * one outcome this readout exists to prevent.
170
+ *
171
+ * linux /proc/meminfo MemAvailable — the kernel's own answer, a file read
172
+ * win32 os.freemem() already reports available physical memory
173
+ * darwin vm_stat, because nothing in Node exposes the page classes
174
+ *
175
+ * Only darwin costs a subprocess, and only at MEM_INTERVAL_MS.
176
+ */
177
+ /**
178
+ * Available bytes out of `/proc/meminfo` text, or null when the field is absent.
179
+ *
180
+ * Pure and exported for the same reason codexHome() takes a platform: a Linux
181
+ * answer has to be checkable from a Mac, and the only alternative is trusting
182
+ * that a regex nobody has run is right.
183
+ */
184
+ export function availableFromMeminfo(text) {
185
+ const m = /^MemAvailable:\s+(\d+)\s*kB/m.exec(String(text ?? ""));
186
+ return m ? Number(m[1]) * 1024 : null;
187
+ }
188
+
189
+ /**
190
+ * Available bytes out of `vm_stat` output, or null when it does not parse.
191
+ *
192
+ * Everything the kernel can hand over without swapping: genuinely free pages,
193
+ * read-ahead it can drop, inactive anonymous pages, and purgeable caches. This
194
+ * is the number `os.freemem()` is missing — it reports only the first of the
195
+ * four, which is why the naive formula reads ~99% on an idle 32 GB Mac.
196
+ */
197
+ export function availableFromVmStat(text, total) {
198
+ const out = String(text ?? "");
199
+ const pageSize = Number(/page size of (\d+) bytes/.exec(out)?.[1]) || 4096;
200
+ const pages = name => {
201
+ const m = new RegExp(`^Pages ${name}:\\s+(\\d+)`, "m").exec(out);
202
+ return m ? Number(m[1]) : 0;
203
+ };
204
+ const reclaimable = pages("free") + pages("speculative")
205
+ + pages("inactive") + pages("purgeable");
206
+ if (reclaimable <= 0) return null;
207
+ const avail = reclaimable * pageSize;
208
+ return total != null && avail > total ? null : avail;
209
+ }
210
+
211
+ async function readAvailable(platform = process.platform) {
212
+ const total = os.totalmem();
213
+
214
+ if (platform === "linux") {
215
+ try {
216
+ const parsed = availableFromMeminfo(await readFile("/proc/meminfo", "utf8"));
217
+ if (parsed != null) return parsed;
218
+ } catch { /* fall through to freemem */ }
219
+ return os.freemem();
220
+ }
221
+
222
+ if (platform === "darwin") {
223
+ const out = await run("vm_stat", []);
224
+ const parsed = out ? availableFromVmStat(out, total) : null;
225
+ return parsed ?? os.freemem();
226
+ }
227
+
228
+ return os.freemem();
229
+ }
230
+
231
+ /**
232
+ * Swap out of macOS `sysctl -n vm.swapusage`, which prints
233
+ * `total = 14336.00M used = 12876.00M free = 1460.00M (encrypted)`.
234
+ *
235
+ * Swap is the reading a percentage cannot give you. A machine at "64% memory
236
+ * used" that is quietly paging 12 GB to disk is not the same machine as one at
237
+ * 64% with an empty swap file, and the difference is the one you can feel.
238
+ */
239
+ export function swapFromSysctl(text) {
240
+ const unit = s => {
241
+ // `,` as well as `.`: C_LOCALE should mean this never arrives, and a parser
242
+ // that fails closed on a whole continent's default is not a thing to leave
243
+ // resting on one environment variable. Safe to accept both here because
244
+ // sysctl formats with printf's %f, which never groups thousands — so a
245
+ // comma in this field can only ever be the decimal point.
246
+ const m = /^([\d.,]+)([KMG])?$/i.exec(s);
247
+ if (!m) return null;
248
+ const mult = { k: 1024, m: 1024 ** 2, g: 1024 ** 3 }[(m[2] || "M").toLowerCase()] ?? 1;
249
+ return Math.round(Number(m[1].replace(",", ".")) * mult);
250
+ };
251
+ const total = unit(/total\s*=\s*(\S+)/i.exec(String(text ?? ""))?.[1] ?? "");
252
+ const used = unit(/used\s*=\s*(\S+)/i.exec(String(text ?? ""))?.[1] ?? "");
253
+ if (total == null || used == null) return null;
254
+ return { total, used };
255
+ }
256
+
257
+ /** Swap out of `/proc/meminfo`, where it is two fields rather than one line. */
258
+ export function swapFromMeminfo(text) {
259
+ const s = String(text ?? "");
260
+ const total = /^SwapTotal:\s+(\d+)\s*kB/m.exec(s);
261
+ const free = /^SwapFree:\s+(\d+)\s*kB/m.exec(s);
262
+ if (!total || !free) return null;
263
+ const t = Number(total[1]) * 1024;
264
+ return { total: t, used: Math.max(0, t - Number(free[1]) * 1024) };
265
+ }
266
+
267
+ /**
268
+ * Windows has no swap file in the Unix sense; the comparable pressure signal is
269
+ * commit charge, which `Win32_OperatingSystem` reports as total and free
270
+ * virtual memory in KB. Labelled "commit" in the UI rather than "swap", because
271
+ * calling it swap would be borrowing a word for a different mechanism.
272
+ */
273
+ export function swapFromWmicJson(json) {
274
+ try {
275
+ const o = typeof json === "string" ? JSON.parse(json) : json;
276
+ const total = Number(o?.TotalVirtualMemorySize) * 1024;
277
+ const free = Number(o?.FreeVirtualMemory) * 1024;
278
+ if (!Number.isFinite(total) || !Number.isFinite(free) || total <= 0) return null;
279
+ return { total, used: Math.max(0, total - free) };
280
+ } catch { return null; }
281
+ }
282
+
283
+ async function readSwap(platform = process.platform) {
284
+ if (platform === "darwin") {
285
+ const out = await run("sysctl", ["-n", "vm.swapusage"]);
286
+ return out ? swapFromSysctl(out) : null;
287
+ }
288
+ if (platform === "linux") {
289
+ try { return swapFromMeminfo(await readFile("/proc/meminfo", "utf8")); }
290
+ catch { return null; }
291
+ }
292
+ if (platform === "win32") {
293
+ const out = await run("powershell.exe", [
294
+ "-NoProfile", "-NonInteractive", "-Command",
295
+ "Get-CimInstance Win32_OperatingSystem | Select-Object TotalVirtualMemorySize,FreeVirtualMemory | ConvertTo-Json -Compress",
296
+ ], 4_000);
297
+ return out ? swapFromWmicJson(out.trim()) : null;
298
+ }
299
+ return null;
300
+ }
301
+
302
+ /** How many rows the process list keeps. Enough to see what is eating the
303
+ * machine, few enough that the panel never becomes a scroll. */
304
+ const TOP_N = 8;
305
+
306
+ /**
307
+ * The `ps` argument list, which is not the same list on both Unixes.
308
+ *
309
+ * `-r` was shipped for both and means two different things. On BSD it sorts the
310
+ * output by current CPU, which is the ordering the panel is built around. On
311
+ * Linux procps it is *"restrict the selection to only running processes"* — a
312
+ * filter on state `R`, applied in PID order. A Linux deck therefore listed
313
+ * whatever happened to be on a CPU at the instant of the sample: usually one or
314
+ * two rows on an idle machine, and never the busiest ones, since a process
315
+ * pinning a core while blocked on I/O sits in `D` and one merely burning CPU
316
+ * over time is normally caught in `S`. Nothing errored and nothing was empty,
317
+ * which is why it survived two releases (#492).
318
+ *
319
+ * `--sort=-pcpu` is procps' own way to say what `-r` says on BSD. The column
320
+ * order is deliberately identical on both so one parser reads both, and `comm`
321
+ * stays last so a name containing a space survives intact.
322
+ *
323
+ * Keyed on linux rather than on darwin, because linux is the platform that is
324
+ * wrong: `-r` sorts on every BSD, while `--sort` is a procps long option that
325
+ * would make FreeBSD and OpenBSD exit non-zero. This way the only branch that
326
+ * changes is the one that was broken.
327
+ *
328
+ * Pure and exported for the same reason the parsers are: the command
329
+ * construction is the part that differs per platform, and a fixture cannot
330
+ * prove which flags were passed to produce it.
331
+ */
332
+ export function psArgs(platform = process.platform) {
333
+ // procps: an explicit CPU sort, and `comm` in `-o` is what keeps argv — and
334
+ // any prompt or token on it — out of the panel. Linux `comm` comes from
335
+ // /proc/<pid>/comm and is capped at 15 characters.
336
+ if (platform === "linux") return ["-eo", "pid,pcpu,pmem,comm", "--sort=-pcpu"];
337
+ // BSD/macOS: `-c` prints the accounting name rather than the argument vector,
338
+ // and `-r` sorts by current CPU.
339
+ return ["-Aceo", "pid,pcpu,pmem,comm", "-r"];
340
+ }
341
+
342
+ /**
343
+ * Rows out of `ps -o pid,pcpu,pmem,comm`, in that column order on both Unixes —
344
+ * see psArgs for how each platform is asked for it.
345
+ *
346
+ * `pcpu` is a percentage of ONE core on both, so a multi-threaded process runs
347
+ * past 100 and that is information rather than an error: 157 is one and a half
348
+ * cores. cpuFromDeltas puts the Windows column on this same scale.
349
+ *
350
+ * There is no row limit in the query because neither `ps` has one and `run`
351
+ * deliberately never inherits a shell, so there is no `| head` to pipe into.
352
+ * The loop below stops at `limit` instead, which costs one parse of a string
353
+ * we have already paid to read.
354
+ */
355
+ export function parsePsProcesses(text, limit = TOP_N) {
356
+ const lines = String(text ?? "").trim().split("\n");
357
+ const out = [];
358
+ for (const line of lines.slice(1)) { // drop the header row
359
+ // Both separators, for the reason swapFromSysctl gives: C_LOCALE is the
360
+ // fix, and this is what keeps a stripped environment from emptying the
361
+ // panel. `%CPU` and `%MEM` are percentages printed with %.1f, so a comma in
362
+ // either can only be the decimal point.
363
+ const m = /^\s*(\d+)\s+([\d.,]+)\s+([\d.,]+)\s+(.+?)\s*$/.exec(line);
364
+ if (!m) continue;
365
+ const num = v => Number(v.replace(",", "."));
366
+ out.push({ pid: Number(m[1]), cpu: num(m[2]), mem: num(m[3]), name: m[4] });
367
+ if (out.length >= limit) break;
368
+ }
369
+ return out;
370
+ }
371
+
372
+ /**
373
+ * Rows out of `Get-Process`.
374
+ *
375
+ * NOT `Win32_PerfFormattedData_PerfProc_Process`, which is what this used and
376
+ * which is not a class you may assume exists. It is published by perflib, and
377
+ * perflib is deregistered often enough to matter — a corporate image, a bad
378
+ * in-place upgrade, a half-run `lodctr`. On a machine reported from the field
379
+ * the class was simply absent (`Get-CimInstance: Invalid class`), and `typeperf`
380
+ * failed identically, which places the fault below WMI rather than in it. WMI
381
+ * was only mirroring what perflib had stopped publishing.
382
+ *
383
+ * `Get-Process` reads through NtQuerySystemInformation instead, so it depends on
384
+ * nothing that can be unregistered. The cost is that its `CPU` is total
385
+ * processor SECONDS since the process started, not a rate — so a percentage has
386
+ * to be derived from two readings, exactly the way the machine-wide figure is
387
+ * already derived from two tick samples. Reliability is worth one extra poll:
388
+ * an instant number from a class that may not exist is worth nothing at all.
389
+ */
390
+ export function parseGetProcessJson(json, totalMem) {
391
+ let rows;
392
+ try { rows = typeof json === "string" ? JSON.parse(json) : json; }
393
+ catch { return []; }
394
+ if (!rows) return [];
395
+ if (!Array.isArray(rows)) rows = [rows];
396
+ return rows
397
+ .filter(r => r && r.ProcessName)
398
+ .map(r => ({
399
+ pid: Number(r.Id) || 0,
400
+ name: String(r.ProcessName),
401
+ // Null rather than 0 when the process denies the read: a system process
402
+ // we cannot query has an unknown CPU time, and calling that zero would
403
+ // rank it as idle.
404
+ cpuSec: typeof r.CPU === "number" ? r.CPU : null,
405
+ mem: totalMem > 0
406
+ ? Math.round((Number(r.WorkingSetPrivate) || 0) / totalMem * 1000) / 10
407
+ : 0,
408
+ }));
409
+ }
410
+
411
+ /**
412
+ * Turn two `Get-Process` readings into a percentage per process.
413
+ *
414
+ * `prev` maps pid to the cpuSec of the previous reading. A pid absent from it —
415
+ * a process that started since — has no delta and reports null rather than a
416
+ * number invented from its whole lifetime, which would rank a freshly spawned
417
+ * compiler as though it had been burning a core since boot.
418
+ *
419
+ * Per core, NOT per machine, because that is what the column beside it means:
420
+ * `ps -o pcpu` is a percentage of one core on both Unixes and is reported
421
+ * unmodified, so a row reading 157 there is a process using one and a half
422
+ * cores. This used to divide by the core count and clamp to 100 on the reasoning
423
+ * that Unix reported 0-100 — it does not, and never did, so the normalisation
424
+ * corrected a scale that already matched and introduced the mismatch it was
425
+ * written to prevent: on a 12-core machine six busy cores read 600 on macOS and
426
+ * 50 on Windows (#493). One CPU-second burned per wall-second is 100 here, on
427
+ * every platform.
428
+ *
429
+ * Core count is deliberately not a parameter any more. The aggregate meter's
430
+ * 0-100 convention (see cpuPercent) is a different question with a different
431
+ * answer, and the only way this drifts back is if a core count is in reach.
432
+ */
433
+ export function cpuFromDeltas(rows, prev, elapsedMs, limit = TOP_N) {
434
+ const secs = elapsedMs / 1000;
435
+ const out = rows.map(r => {
436
+ const before = prev instanceof Map ? prev.get(r.pid) : undefined;
437
+ let cpu = null;
438
+ if (r.cpuSec != null && before != null && secs > 0) {
439
+ const d = r.cpuSec - before;
440
+ // A counter that went backwards means the pid was reused by a different
441
+ // process; report nothing rather than a negative or a wild number.
442
+ if (d >= 0) cpu = Math.round((d / secs) * 1000) / 10;
443
+ }
444
+ return { pid: r.pid, cpu, mem: r.mem, name: r.name };
445
+ });
446
+ // Until the second reading lands there is no CPU to sort on, so the list is
447
+ // ordered by memory — which is a real answer to "what is this machine doing",
448
+ // not a placeholder.
449
+ const haveCpu = out.some(r => r.cpu != null);
450
+ out.sort(haveCpu
451
+ ? (a, b) => (b.cpu ?? -1) - (a.cpu ?? -1)
452
+ : (a, b) => b.mem - a.mem);
453
+ return out.slice(0, limit);
454
+ }
455
+
456
+ /** Previous Windows reading, so the next one can be a rate. Cleared with the
457
+ * rest of the sampler state. */
458
+ let prevProcCpu = null;
459
+ let prevProcAt = 0;
460
+
461
+ /** The run producing the next list, and the last one that finished. */
462
+ let procInFlight = null;
463
+ let procLast = null; // { at, procs }
464
+
465
+ /**
466
+ * How old a finished reading may be and still answer a caller.
467
+ *
468
+ * Well under SystemMeter's PROC_POLL_MS of 4000, so the panel that this exists
469
+ * for never once gets a cached list; long enough that a second tab, a second
470
+ * browser, or anything else arriving between two of those polls is handed the
471
+ * list the first tab is already looking at rather than starting its own child.
472
+ */
473
+ const PROC_MIN_GAP_MS = 1_500;
474
+
475
+ /**
476
+ * The process list, on demand only — never on the ambient timer, and never more
477
+ * than one child at a time.
478
+ *
479
+ * #544: /api/system/processes is a GET with no cache, no dedupe and no
480
+ * throttle, so the number of `powershell.exe Get-Process` children — about six
481
+ * seconds each — was whatever the caller asked for. That is the cheap half of
482
+ * the problem. The expensive half is that concurrent readers also overwrote
483
+ * each other's baseline: cpuFromDeltas needs the PREVIOUS reading's cpuSec per
484
+ * pid, prevProcCpu/prevProcAt are one shared pair, and two readers each stored
485
+ * theirs over the other's, so the CPU column came back computed against a
486
+ * baseline that belonged to somebody else's reading. That is a wrong number on
487
+ * screen, not merely wasted work, and it needed no attacker at all: one
488
+ * Get-Process takes longer than the panel's four-second poll, so a single tab
489
+ * on Windows already overlapped itself.
490
+ *
491
+ * One in-flight run fixes both, because one reader means one baseline. Callers
492
+ * that arrive while a run is going share its promise; callers that arrive just
493
+ * after one finished are served that reading.
494
+ */
495
+ export async function readProcesses(platform = process.platform) {
496
+ const now = Date.now();
497
+ if (procLast && now - procLast.at < PROC_MIN_GAP_MS) return procLast.procs;
498
+ if (procInFlight) return procInFlight;
499
+ // Only a real reading is remembered. Every failure inside readProcessesNow —
500
+ // a spawn that never started, a non-zero exit, the timeout — resolves to an
501
+ // empty array, and no machine has nothing running on it, so an empty list is
502
+ // a failure by construction. Serving one for the next 1.5s would turn a
503
+ // single hiccup into a blank panel that outlives it; the next caller retries
504
+ // instead. The in-flight share still applies, so a burst arriving during a
505
+ // failing read is one failing child, not a burst of them.
506
+ procInFlight = readProcessesNow(platform)
507
+ .then(procs => {
508
+ if (procs.length) procLast = { at: Date.now(), procs };
509
+ return procs;
510
+ })
511
+ .finally(() => { procInFlight = null; });
512
+ return procInFlight;
513
+ }
514
+
515
+ async function readProcessesNow(platform) {
516
+ if (platform === "win32") {
517
+ const out = await run("powershell.exe", [
518
+ "-NoProfile", "-NonInteractive", "-Command",
519
+ "Get-Process | Select-Object Id,ProcessName,CPU,@{n='WorkingSetPrivate';e={$_.PrivateMemorySize64}} | ConvertTo-Json -Compress",
520
+ ], 6_000);
521
+ if (!out) return [];
522
+ const rows = parseGetProcessJson(out.trim(), os.totalmem());
523
+ const now = Date.now();
524
+ const result = cpuFromDeltas(rows, prevProcCpu, now - prevProcAt);
525
+ prevProcCpu = new Map(rows.filter(r => r.cpuSec != null).map(r => [r.pid, r.cpuSec]));
526
+ prevProcAt = now;
527
+ return result;
528
+ }
529
+ const out = await run("ps", psArgs(platform), 4_000);
530
+ return out ? parsePsProcesses(out) : [];
531
+ }
532
+
533
+ async function sampleMemory() {
534
+ if (memInFlight) return;
535
+ memInFlight = true;
536
+ try {
537
+ const total = os.totalmem();
538
+ const available = await readAvailable();
539
+ memory = {
540
+ total,
541
+ available,
542
+ usedPct: Math.max(0, Math.min(100, Math.round(((total - available) / total) * 1000) / 10)),
543
+ };
544
+ // Same 30s cadence as memory, and for the same reason: it moves in minutes
545
+ // and costs a subprocess on two of the three platforms.
546
+ swap = await readSwap();
547
+ } catch { /* keep the previous reading rather than blanking the meter */ }
548
+ finally { memInFlight = false; }
549
+ }
550
+
551
+ function sampleCpu() {
552
+ const per = corePercents();
553
+ const pct = cpuPercent();
554
+ if (pct == null) return;
555
+ cores = per;
556
+ cpuHistory.push(pct);
557
+ while (cpuHistory.length > HISTORY) cpuHistory.shift();
558
+ }
559
+
560
+ /**
561
+ * Begin sampling. Idempotent, and both timers are unref'd so this can never be
562
+ * the reason the process stays alive.
563
+ */
564
+ export function startSystemMetrics() {
565
+ if (cpuTimer) return;
566
+ prevTicks = readTicks(); // baseline, so the first tick has a delta
567
+ prevCoreTicks = readCoreTicks();
568
+ sampleMemory();
569
+ cpuTimer = setInterval(sampleCpu, CPU_INTERVAL_MS);
570
+ memTimer = setInterval(sampleMemory, MEM_INTERVAL_MS);
571
+ cpuTimer.unref?.();
572
+ memTimer.unref?.();
573
+ }
574
+
575
+ export function stopSystemMetrics() {
576
+ if (cpuTimer) clearInterval(cpuTimer);
577
+ if (memTimer) clearInterval(memTimer);
578
+ cpuTimer = memTimer = null;
579
+ prevTicks = null;
580
+ prevCoreTicks = null;
581
+ cpuHistory.length = 0;
582
+ memory = null;
583
+ cores = null;
584
+ swap = null;
585
+ prevProcCpu = null;
586
+ prevProcAt = 0;
587
+ // The last list goes with the baseline it was computed against. A sampler
588
+ // that stopped and started again must not answer the first caller with a
589
+ // reading from before the stop.
590
+ procLast = null;
591
+ }
592
+
593
+ /**
594
+ * What /api/system answers.
595
+ *
596
+ * `cpu` is null until two samples exist — the meter draws its track and no fill
597
+ * rather than printing a zero it has not measured. `loadavg` is omitted on
598
+ * Windows, where the API returns [0, 0, 0]: three zeros are not a reading, and
599
+ * showing them as one would be the same lie in a different place.
600
+ */
601
+ export function systemSnapshot() {
602
+ const cpu = cpuHistory.length ? cpuHistory[cpuHistory.length - 1] : null;
603
+ const load = os.loadavg();
604
+ const hasLoad = process.platform !== "win32" && load.some(n => n > 0);
605
+ return {
606
+ ok: true,
607
+ cpu,
608
+ cpuHistory: [...cpuHistory],
609
+ cores: os.cpus().length,
610
+ memory,
611
+ swap,
612
+ perCore: cores,
613
+ uptimeSec: Math.round(os.uptime()),
614
+ platform: process.platform,
615
+ loadavg: hasLoad ? load.map(n => Math.round(n * 100) / 100) : null,
616
+ intervalMs: CPU_INTERVAL_MS,
617
+ sampledAt: Date.now(),
618
+ };
619
+ }