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.
- package/README.md +210 -12
- package/bin/agent-dag.js +505 -0
- package/bin/deck.js +1051 -0
- package/dist/web/assets/index-Bdl1LX0-.css +1 -0
- package/dist/web/assets/index-jXBjwwZC.js +89 -0
- package/dist/web/index.html +49 -0
- package/hook/hook.js +496 -0
- package/package.json +42 -9
- package/src/server/args.mjs +156 -0
- package/src/server/brand.mjs +16 -0
- package/src/server/ccusage.mjs +1052 -0
- package/src/server/claude-accounts.mjs +653 -0
- package/src/server/claude-dir.mjs +213 -0
- package/src/server/codex-auth.mjs +388 -0
- package/src/server/codex-dir.mjs +171 -0
- package/src/server/codex-quota.mjs +445 -0
- package/src/server/codex-usage.mjs +411 -0
- package/src/server/cswap-admin.mjs +1034 -0
- package/src/server/cswap-auto.mjs +595 -0
- package/src/server/cswap-install.mjs +583 -0
- package/src/server/exec.mjs +938 -0
- package/src/server/index.mjs +5109 -0
- package/src/server/installer.mjs +763 -0
- package/src/server/invoked-as.mjs +144 -0
- package/src/server/log-writer.mjs +283 -0
- package/src/server/npx.mjs +259 -0
- package/src/server/quota.mjs +684 -0
- package/src/server/reset-label.mjs +78 -0
- package/src/server/retire-sound-hook.mjs +315 -0
- package/src/server/self-update.mjs +1331 -0
- package/src/server/supervisor.mjs +302 -0
- package/src/server/system-metrics.mjs +619 -0
- package/src/server/term.mjs +570 -0
- package/src/server/uv-bootstrap.mjs +301 -0
- package/bin/ccdeck.js +0 -194
|
@@ -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
|
+
}
|