agent-dag 3.22.1 → 3.22.4
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 +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-CJYsv0lr.css +0 -1
- package/dist/web/assets/index-Ifm23DDC.js +0 -270
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -398
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- package/src/server/uv-bootstrap.mjs +0 -337
|
@@ -1,1825 +0,0 @@
|
|
|
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 { readdir, 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
|
-
let thermal = null;
|
|
45
|
-
let thermalTimer = null;
|
|
46
|
-
let thermalInFlight = false;
|
|
47
|
-
/** Consecutive readings that came back with nothing. See THERMAL_GIVE_UP. */
|
|
48
|
-
let thermalMisses = 0;
|
|
49
|
-
/** Whether this machine has EVER answered. See sampleThermal. */
|
|
50
|
-
let thermalEverAnswered = false;
|
|
51
|
-
/** Minute buckets, oldest first, for every section that keeps a history.
|
|
52
|
-
* See HISTORY_MINUTES. */
|
|
53
|
-
const history = [];
|
|
54
|
-
/** When sampling started, so a modal can say what "since" means. */
|
|
55
|
-
let historySince = 0;
|
|
56
|
-
|
|
57
|
-
/** Total and idle jiffies across every core, as one pair. */
|
|
58
|
-
function readTicks() {
|
|
59
|
-
let idle = 0;
|
|
60
|
-
let total = 0;
|
|
61
|
-
for (const cpu of os.cpus()) {
|
|
62
|
-
for (const [kind, ms] of Object.entries(cpu.times)) {
|
|
63
|
-
total += ms;
|
|
64
|
-
if (kind === "idle") idle += ms;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return { idle, total };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** The same pair, per core, in `os.cpus()` order. */
|
|
71
|
-
function readCoreTicks() {
|
|
72
|
-
return os.cpus().map(cpu => {
|
|
73
|
-
let idle = 0;
|
|
74
|
-
let total = 0;
|
|
75
|
-
for (const [kind, ms] of Object.entries(cpu.times)) {
|
|
76
|
-
total += ms;
|
|
77
|
-
if (kind === "idle") idle += ms;
|
|
78
|
-
}
|
|
79
|
-
return { idle, total };
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Busy percentage per core since the previous reading.
|
|
85
|
-
*
|
|
86
|
-
* The aggregate figure the topbar draws hides the shape of the load, and the
|
|
87
|
-
* shape is what tells a saturated machine from a machine running one hot
|
|
88
|
-
* single-threaded job. Same delta arithmetic as `cpuPercent`, one row per core,
|
|
89
|
-
* and the same refusal to invent a number before there are two readings.
|
|
90
|
-
*/
|
|
91
|
-
function corePercents() {
|
|
92
|
-
const now = readCoreTicks();
|
|
93
|
-
const prev = prevCoreTicks;
|
|
94
|
-
prevCoreTicks = now;
|
|
95
|
-
if (!prev || prev.length !== now.length) return null;
|
|
96
|
-
return now.map((c, i) => {
|
|
97
|
-
const dTotal = c.total - prev[i].total;
|
|
98
|
-
const dIdle = c.idle - prev[i].idle;
|
|
99
|
-
if (dTotal <= 0) return 0;
|
|
100
|
-
return Math.max(0, Math.min(100, Math.round((1 - dIdle / dTotal) * 1000) / 10));
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Busy percentage across all cores since the previous reading, 0-100.
|
|
106
|
-
*
|
|
107
|
-
* Aggregate rather than per-core, and normalised rather than macOS's
|
|
108
|
-
* 0-to-cores*100 convention, because it has to mean the same thing on all three
|
|
109
|
-
* platforms and because a bar needs an end. The cost is that it saturates: a
|
|
110
|
-
* machine at load 12 and a machine at load 18 both read 100. `loadavg` is what
|
|
111
|
-
* carries that difference, which is why it rides along below on the platforms
|
|
112
|
-
* that report it.
|
|
113
|
-
*/
|
|
114
|
-
function cpuPercent() {
|
|
115
|
-
const now = readTicks();
|
|
116
|
-
if (!prevTicks) { prevTicks = now; return null; }
|
|
117
|
-
const dTotal = now.total - prevTicks.total;
|
|
118
|
-
const dIdle = now.idle - prevTicks.idle;
|
|
119
|
-
prevTicks = now;
|
|
120
|
-
// A tick counter that did not move says nothing; it does not say "idle".
|
|
121
|
-
if (dTotal <= 0) return null;
|
|
122
|
-
const pct = (1 - dIdle / dTotal) * 100;
|
|
123
|
-
return Math.max(0, Math.min(100, Math.round(pct * 10) / 10));
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* The locale every child of this module runs in — and only the part of it that
|
|
128
|
-
* had to be forced.
|
|
129
|
-
*
|
|
130
|
-
* Not a preference. Every command spawned here has its output read by a regex
|
|
131
|
-
* and every one of those regexes reads a number with a `.` in it. `ps` and
|
|
132
|
-
* `sysctl` honour LC_NUMERIC, so on a machine set to de_DE, fr_FR, ru_RU or
|
|
133
|
-
* pt_BR — comma is the decimal separator for most of Europe and Latin America —
|
|
134
|
-
* the same commands print:
|
|
135
|
-
*
|
|
136
|
-
* ps 1 0,2 0,0 /sbin/launchd
|
|
137
|
-
* sysctl total = 8192,00M used = 7189,75M free = 1002,25M
|
|
138
|
-
*
|
|
139
|
-
* and the parsers matched nothing at all. Not partially: `parsePsProcesses`
|
|
140
|
-
* `continue`s on every row, so the process panel was permanently empty, and
|
|
141
|
-
* `swapFromSysctl` returned null, so the macOS swap meter was permanently
|
|
142
|
-
* blank. Silently, on a machine where everything else worked.
|
|
143
|
-
*
|
|
144
|
-
* THIS USED TO FORCE THE WHOLE LOCALE, and forcing the whole locale also forces
|
|
145
|
-
* the CHARACTER SET. Under `LC_ALL=C`, `ps` escapes every byte it cannot render
|
|
146
|
-
* as ASCII, so an application named in Cyrillic came back as
|
|
147
|
-
*
|
|
148
|
-
* M-PM-/M-PM-=M-PM-4M-PM-5M-PM-:M-QM^A M-PM^\M-QM^CM-PM-7M-QM^KM-PM-:M-PM-0
|
|
149
|
-
*
|
|
150
|
-
* where the name is `Яндекс Музыка`. The panel truncates at 164px so it read as
|
|
151
|
-
* noise; the process list added in #738 has room for all ninety-one characters
|
|
152
|
-
* of it, which is how it was found. Every reader with a non-Latin application
|
|
153
|
-
* on their machine was being shown that.
|
|
154
|
-
*
|
|
155
|
-
* So only the numeric is forced now, and the character set is inherited. All
|
|
156
|
-
* four measured on this machine:
|
|
157
|
-
*
|
|
158
|
-
* LC_ALL=C name mangled, 0.7
|
|
159
|
-
* LC_ALL="" LC_NUMERIC=C Яндекс Музыка, 0.7
|
|
160
|
-
* LANG=de_DE + our override Яндекс Музыка, 0.7 ← the comma case
|
|
161
|
-
* LC_ALL=de_DE + our empty LC_ALL 0.7 ← a hostile env
|
|
162
|
-
*
|
|
163
|
-
* The empty `LC_ALL` is doing real work in the last of those: POSIX ignores an
|
|
164
|
-
* empty LC_ALL, so clearing it is what stops a user's own LC_ALL from
|
|
165
|
-
* outranking the LC_NUMERIC below. Setting LC_NUMERIC alone would not survive
|
|
166
|
-
* it.
|
|
167
|
-
*
|
|
168
|
-
* A reader whose own locale is C still sees the escaped form — but so does
|
|
169
|
-
* their terminal, so that is their machine being consistent rather than this
|
|
170
|
-
* module choosing for them.
|
|
171
|
-
*
|
|
172
|
-
* Meaningless on Windows, where the branches are PowerShell piped through
|
|
173
|
-
* ConvertTo-Json and already culture-invariant — and harmless there for the
|
|
174
|
-
* same reason. The comma tolerance in the parsers stays as defence in depth for
|
|
175
|
-
* a sandbox that strips the environment, not as the primary answer.
|
|
176
|
-
*/
|
|
177
|
-
const C_LOCALE = { LC_ALL: "", LC_NUMERIC: "C" };
|
|
178
|
-
|
|
179
|
-
/** Run a command and resolve its stdout, or null. Never rejects, never inherits
|
|
180
|
-
* a shell, never inherits a locale, and is killed rather than allowed to hang
|
|
181
|
-
* the sampler. */
|
|
182
|
-
function run(file, args, timeoutMs = 2_000) {
|
|
183
|
-
return new Promise(resolve => {
|
|
184
|
-
let child;
|
|
185
|
-
try {
|
|
186
|
-
child = spawn(file, args, {
|
|
187
|
-
windowsHide: true,
|
|
188
|
-
env: { ...process.env, ...C_LOCALE },
|
|
189
|
-
// stderr is PIPED AND NEVER READ, which is a deadlock waiting for a
|
|
190
|
-
// chatty child: a pipe nobody drains fills at 64 KB and the writer
|
|
191
|
-
// blocks there until this function's own deadline kills it. Nothing
|
|
192
|
-
// here has ever looked at it — `run` resolves on stdout or null — so
|
|
193
|
-
// the honest arrangement is not to open it.
|
|
194
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
catch { return resolve(null); }
|
|
198
|
-
let out = "";
|
|
199
|
-
const timer = setTimeout(() => { try { child.kill(); } catch {} resolve(null); }, timeoutMs);
|
|
200
|
-
// DECODE THE STREAM, NOT EACH CHUNK. `out += d` on a Buffer calls toString()
|
|
201
|
-
// per chunk, and a chunk boundary falls wherever the pipe happened to break
|
|
202
|
-
// — measured on `ps` output as three chunks of 8192/8192/5718 — so a
|
|
203
|
-
// multi-byte character split across two of them became two replacement
|
|
204
|
-
// characters. An application called `Яндекс Музыка` in the process list
|
|
205
|
-
// rendered as `Ян��екс Музыка`. setEncoding carries the partial sequence
|
|
206
|
-
// across the boundary, which is the whole reason it exists.
|
|
207
|
-
child.stdout?.setEncoding?.("utf8");
|
|
208
|
-
child.stdout?.on("data", d => { out += d; });
|
|
209
|
-
child.on("error", () => { clearTimeout(timer); resolve(null); });
|
|
210
|
-
child.on("close", code => { clearTimeout(timer); resolve(code === 0 ? out : null); });
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Bytes of memory a new process could actually get, per platform.
|
|
216
|
-
*
|
|
217
|
-
* `os.freemem()` is the obvious call and it is the wrong one on two of the three
|
|
218
|
-
* platforms, because "free" and "available" are different questions. Pages
|
|
219
|
-
* holding cached files or inactive anonymous memory are not free, but the kernel
|
|
220
|
-
* will hand them over the moment something asks. Reporting them as used is what
|
|
221
|
-
* makes the naive `(total - free) / total` read 99.5% on an idle 32 GB Mac — a
|
|
222
|
-
* number that would send the reader straight to Activity Monitor, which is the
|
|
223
|
-
* one outcome this readout exists to prevent.
|
|
224
|
-
*
|
|
225
|
-
* linux /proc/meminfo MemAvailable — the kernel's own answer, a file read
|
|
226
|
-
* win32 os.freemem() already reports available physical memory
|
|
227
|
-
* darwin vm_stat, because nothing in Node exposes the page classes
|
|
228
|
-
*
|
|
229
|
-
* Only darwin costs a subprocess, and only at MEM_INTERVAL_MS.
|
|
230
|
-
*/
|
|
231
|
-
/**
|
|
232
|
-
* Available bytes out of `/proc/meminfo` text, or null when the field is absent.
|
|
233
|
-
*
|
|
234
|
-
* Pure and exported for the same reason codexHome() takes a platform: a Linux
|
|
235
|
-
* answer has to be checkable from a Mac, and the only alternative is trusting
|
|
236
|
-
* that a regex nobody has run is right.
|
|
237
|
-
*/
|
|
238
|
-
export function availableFromMeminfo(text) {
|
|
239
|
-
const m = /^MemAvailable:\s+(\d+)\s*kB/m.exec(String(text ?? ""));
|
|
240
|
-
return m ? Number(m[1]) * 1024 : null;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Available bytes out of `vm_stat` output, or null when it does not parse.
|
|
245
|
-
*
|
|
246
|
-
* Everything the kernel can hand over without swapping: genuinely free pages,
|
|
247
|
-
* read-ahead it can drop, inactive anonymous pages, and purgeable caches. This
|
|
248
|
-
* is the number `os.freemem()` is missing — it reports only the first of the
|
|
249
|
-
* four, which is why the naive formula reads ~99% on an idle 32 GB Mac.
|
|
250
|
-
*/
|
|
251
|
-
export function availableFromVmStat(text, total) {
|
|
252
|
-
const out = String(text ?? "");
|
|
253
|
-
const pageSize = Number(/page size of (\d+) bytes/.exec(out)?.[1]) || 4096;
|
|
254
|
-
const pages = name => {
|
|
255
|
-
const m = new RegExp(`^Pages ${name}:\\s+(\\d+)`, "m").exec(out);
|
|
256
|
-
return m ? Number(m[1]) : 0;
|
|
257
|
-
};
|
|
258
|
-
const reclaimable = pages("free") + pages("speculative")
|
|
259
|
-
+ pages("inactive") + pages("purgeable");
|
|
260
|
-
if (reclaimable <= 0) return null;
|
|
261
|
-
const avail = reclaimable * pageSize;
|
|
262
|
-
return total != null && avail > total ? null : avail;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* How much memory is really available, or NULL when this machine could not be
|
|
267
|
-
* asked.
|
|
268
|
-
*
|
|
269
|
-
* `os.freemem()` USED TO BE THE FALLBACK ON BOTH REAL PLATFORMS, and it is the
|
|
270
|
-
* one number this function exists to avoid (#789). The header above says why:
|
|
271
|
-
* counting only genuinely free pages makes the naive `(total - free) / total`
|
|
272
|
-
* read 99.5% on an idle 32 GB Mac — "a number that would send the reader
|
|
273
|
-
* straight to Activity Monitor, which is the one outcome this readout exists to
|
|
274
|
-
* prevent". So a failed measurement produced exactly the reading the module was
|
|
275
|
-
* written to suppress.
|
|
276
|
-
*
|
|
277
|
-
* And it did not merely flicker. `record` folds into the minute bucket by
|
|
278
|
-
* MAXIMUM, so one failed poll painted a red 99% peak on the memory chart that
|
|
279
|
-
* survived every good sample for the next twenty-four hours. The failure is
|
|
280
|
-
* ordinary: `run` resolves null on a spawn error (EAGAIN/EMFILE under fork
|
|
281
|
-
* pressure — a deck watching many agents is exactly that), on a non-zero exit,
|
|
282
|
-
* and on its own 2s deadline. 2,880 chances a day.
|
|
283
|
-
*
|
|
284
|
-
* Null instead, and the caller keeps the previous reading and records nothing.
|
|
285
|
-
* A gap in the chart is honest; a 99% peak is not.
|
|
286
|
-
*
|
|
287
|
-
* The last branch still answers `freemem()` because on Windows there is no
|
|
288
|
-
* better source to have failed — it is the measurement, not a substitute for
|
|
289
|
-
* one.
|
|
290
|
-
*/
|
|
291
|
-
async function readAvailable(platform = process.platform) {
|
|
292
|
-
const total = os.totalmem();
|
|
293
|
-
|
|
294
|
-
if (platform === "linux") {
|
|
295
|
-
try {
|
|
296
|
-
const parsed = availableFromMeminfo(await readFile("/proc/meminfo", "utf8"));
|
|
297
|
-
if (parsed != null) return parsed;
|
|
298
|
-
} catch { /* unreadable /proc — say so rather than guessing */ }
|
|
299
|
-
return null;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
if (platform === "darwin") {
|
|
303
|
-
const out = await run("vm_stat", []);
|
|
304
|
-
return (out ? availableFromVmStat(out, total) : null) ?? null;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
return os.freemem();
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Swap out of macOS `sysctl -n vm.swapusage`, which prints
|
|
312
|
-
* `total = 14336.00M used = 12876.00M free = 1460.00M (encrypted)`.
|
|
313
|
-
*
|
|
314
|
-
* Swap is the reading a percentage cannot give you. A machine at "64% memory
|
|
315
|
-
* used" that is quietly paging 12 GB to disk is not the same machine as one at
|
|
316
|
-
* 64% with an empty swap file, and the difference is the one you can feel.
|
|
317
|
-
*/
|
|
318
|
-
export function swapFromSysctl(text) {
|
|
319
|
-
const unit = s => {
|
|
320
|
-
// `,` as well as `.`: C_LOCALE should mean this never arrives, and a parser
|
|
321
|
-
// that fails closed on a whole continent's default is not a thing to leave
|
|
322
|
-
// resting on one environment variable. Safe to accept both here because
|
|
323
|
-
// sysctl formats with printf's %f, which never groups thousands — so a
|
|
324
|
-
// comma in this field can only ever be the decimal point.
|
|
325
|
-
const m = /^([\d.,]+)([KMG])?$/i.exec(s);
|
|
326
|
-
if (!m) return null;
|
|
327
|
-
const mult = { k: 1024, m: 1024 ** 2, g: 1024 ** 3 }[(m[2] || "M").toLowerCase()] ?? 1;
|
|
328
|
-
return Math.round(Number(m[1].replace(",", ".")) * mult);
|
|
329
|
-
};
|
|
330
|
-
const total = unit(/total\s*=\s*(\S+)/i.exec(String(text ?? ""))?.[1] ?? "");
|
|
331
|
-
const used = unit(/used\s*=\s*(\S+)/i.exec(String(text ?? ""))?.[1] ?? "");
|
|
332
|
-
if (total == null || used == null) return null;
|
|
333
|
-
return { total, used };
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/** Swap out of `/proc/meminfo`, where it is two fields rather than one line. */
|
|
337
|
-
export function swapFromMeminfo(text) {
|
|
338
|
-
const s = String(text ?? "");
|
|
339
|
-
const total = /^SwapTotal:\s+(\d+)\s*kB/m.exec(s);
|
|
340
|
-
const free = /^SwapFree:\s+(\d+)\s*kB/m.exec(s);
|
|
341
|
-
if (!total || !free) return null;
|
|
342
|
-
const t = Number(total[1]) * 1024;
|
|
343
|
-
return { total: t, used: Math.max(0, t - Number(free[1]) * 1024) };
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* Windows has no swap file in the Unix sense; the comparable pressure signal is
|
|
348
|
-
* commit charge, which `Win32_OperatingSystem` reports as total and free
|
|
349
|
-
* virtual memory in KB. Labelled "commit" in the UI rather than "swap", because
|
|
350
|
-
* calling it swap would be borrowing a word for a different mechanism.
|
|
351
|
-
*/
|
|
352
|
-
export function swapFromWmicJson(json) {
|
|
353
|
-
try {
|
|
354
|
-
const o = typeof json === "string" ? JSON.parse(json) : json;
|
|
355
|
-
const total = Number(o?.TotalVirtualMemorySize) * 1024;
|
|
356
|
-
const free = Number(o?.FreeVirtualMemory) * 1024;
|
|
357
|
-
if (!Number.isFinite(total) || !Number.isFinite(free) || total <= 0) return null;
|
|
358
|
-
return { total, used: Math.max(0, total - free) };
|
|
359
|
-
} catch { return null; }
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
async function readSwap(platform = process.platform) {
|
|
363
|
-
if (platform === "darwin") {
|
|
364
|
-
const out = await run("sysctl", ["-n", "vm.swapusage"]);
|
|
365
|
-
return out ? swapFromSysctl(out) : null;
|
|
366
|
-
}
|
|
367
|
-
if (platform === "linux") {
|
|
368
|
-
try { return swapFromMeminfo(await readFile("/proc/meminfo", "utf8")); }
|
|
369
|
-
catch { return null; }
|
|
370
|
-
}
|
|
371
|
-
if (platform === "win32") {
|
|
372
|
-
const out = await run("powershell.exe", [
|
|
373
|
-
"-NoProfile", "-NonInteractive", "-Command",
|
|
374
|
-
"Get-CimInstance Win32_OperatingSystem | Select-Object TotalVirtualMemorySize,FreeVirtualMemory | ConvertTo-Json -Compress",
|
|
375
|
-
], 4_000);
|
|
376
|
-
return out ? swapFromWmicJson(out.trim()) : null;
|
|
377
|
-
}
|
|
378
|
-
return null;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/**
|
|
382
|
-
* How far down each ranking the payload reaches.
|
|
383
|
-
*
|
|
384
|
-
* The panel draws eight rows and decides their order on the client (#739), so
|
|
385
|
-
* whichever column it ranks by has to be rankable from the rows it was handed.
|
|
386
|
-
* `ps` returns a CPU-sorted list, and cutting that at eight and then sorting
|
|
387
|
-
* those eight by memory produced a table that was honest about its rows and
|
|
388
|
-
* wrong about its question: the machine's heaviest memory consumer need never
|
|
389
|
-
* have appeared in a CPU top eight at all. Same shape as #492 — a list that is
|
|
390
|
-
* never empty, never errors, and is not the rows being asked for.
|
|
391
|
-
*
|
|
392
|
-
* So what goes over the wire is a candidate SET rather than a ranking.
|
|
393
|
-
* pickCandidates takes this many by CPU and this many by memory and sends the
|
|
394
|
-
* union, which makes the true top eight of either column present by
|
|
395
|
-
* construction. Wide enough that drawing more rows later cannot quietly
|
|
396
|
-
* re-break that, small enough that the whole thing stays a few kilobytes of
|
|
397
|
-
* four-field rows, fetched only while the panel is open.
|
|
398
|
-
*/
|
|
399
|
-
const CANDIDATE_N = 40;
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* The rows worth sending, given that the ordering happens on the client.
|
|
403
|
-
*
|
|
404
|
-
* A union of two rankings, deduplicated by object identity rather than by pid:
|
|
405
|
-
* parseGetProcessJson falls back to pid 0 for a row whose `Id` did not parse,
|
|
406
|
-
* more than one row can do that, and a pid-keyed set would drop real processes
|
|
407
|
-
* in order to deduplicate a placeholder.
|
|
408
|
-
*
|
|
409
|
-
* An unknown CPU sorts last here, for the same reason the column prints a dash
|
|
410
|
-
* rather than a zero — a Windows first reading has no percentage yet, and
|
|
411
|
-
* unknown is not idle and is not busiest either. Such a row still reaches the
|
|
412
|
-
* payload through the memory half, which is the reading that does exist on that
|
|
413
|
-
* pass.
|
|
414
|
-
*/
|
|
415
|
-
export function pickCandidates(rows, limit = CANDIDATE_N) {
|
|
416
|
-
const byCpu = [...rows].sort((a, b) => (b.cpu ?? -1) - (a.cpu ?? -1) || b.mem - a.mem);
|
|
417
|
-
const byMem = [...rows].sort((a, b) => b.mem - a.mem || (b.cpu ?? -1) - (a.cpu ?? -1));
|
|
418
|
-
const out = byCpu.slice(0, limit);
|
|
419
|
-
const seen = new Set(out);
|
|
420
|
-
for (const r of byMem.slice(0, limit)) if (!seen.has(r)) out.push(r);
|
|
421
|
-
return out;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* The `ps` argument list, which is not the same list on both Unixes.
|
|
426
|
-
*
|
|
427
|
-
* `-r` was shipped for both and means two different things. On BSD it sorts the
|
|
428
|
-
* output by current CPU, which is the ordering the panel is built around. On
|
|
429
|
-
* Linux procps it is *"restrict the selection to only running processes"* — a
|
|
430
|
-
* filter on state `R`, applied in PID order. A Linux deck therefore listed
|
|
431
|
-
* whatever happened to be on a CPU at the instant of the sample: usually one or
|
|
432
|
-
* two rows on an idle machine, and never the busiest ones, since a process
|
|
433
|
-
* pinning a core while blocked on I/O sits in `D` and one merely burning CPU
|
|
434
|
-
* over time is normally caught in `S`. Nothing errored and nothing was empty,
|
|
435
|
-
* which is why it survived two releases (#492).
|
|
436
|
-
*
|
|
437
|
-
* `--sort=-pcpu` is procps' own way to say what `-r` says on BSD. The column
|
|
438
|
-
* order is deliberately identical on both so one parser reads both, and `comm`
|
|
439
|
-
* stays last so a name containing a space survives intact.
|
|
440
|
-
*
|
|
441
|
-
* Keyed on linux rather than on darwin, because linux is the platform that is
|
|
442
|
-
* wrong: `-r` sorts on every BSD, while `--sort` is a procps long option that
|
|
443
|
-
* would make FreeBSD and OpenBSD exit non-zero. This way the only branch that
|
|
444
|
-
* changes is the one that was broken.
|
|
445
|
-
*
|
|
446
|
-
* Pure and exported for the same reason the parsers are: the command
|
|
447
|
-
* construction is the part that differs per platform, and a fixture cannot
|
|
448
|
-
* prove which flags were passed to produce it.
|
|
449
|
-
*/
|
|
450
|
-
export function psArgs(platform = process.platform) {
|
|
451
|
-
// SEVEN FIELDS, THE SAME SEVEN ON BOTH, and `args` last because it is the one
|
|
452
|
-
// that contains spaces. `etime` is `[[DD-]HH:]MM:SS` on both and `rss` is
|
|
453
|
-
// kibibytes on both, so one parser still reads both — which is why the thread
|
|
454
|
-
// count is fetched separately (readThreads) rather than as an eighth column:
|
|
455
|
-
// procps spells it `nlwp` and BSD has no keyword for it at all, and one
|
|
456
|
-
// divergent column would have cost the shared parser this list is built on.
|
|
457
|
-
//
|
|
458
|
-
// `args` REPLACES `comm`, which is a change with a cost, and redactCommand is
|
|
459
|
-
// the payment. `comm` was chosen so that argv — and any token on it — could
|
|
460
|
-
// not reach the panel; that made every `node`, `dotnet` and `python` on the
|
|
461
|
-
// machine indistinguishable from every other one, which is most of what a
|
|
462
|
-
// reader opens this list to tell apart. The argument vector is redacted and
|
|
463
|
-
// capped before it leaves this module, and the short name is derived from
|
|
464
|
-
// argv[0] rather than asked for a second time.
|
|
465
|
-
if (platform === "linux") return ["-eo", "pid,pcpu,pmem,rss,etime,user:24,comm", "--sort=-pcpu"];
|
|
466
|
-
// BSD/macOS: `-c` prints the accounting name rather than the argument vector,
|
|
467
|
-
// and `-r` sorts by current CPU.
|
|
468
|
-
return ["-Aceo", "pid,pcpu,pmem,rss,etime,user,comm", "-r"];
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* The second call: how many threads each candidate has, and what it was
|
|
473
|
-
* actually launched with.
|
|
474
|
-
*
|
|
475
|
-
* Two facts, one child, and it has to be a second call for two separate
|
|
476
|
-
* reasons. The thread count has no shared spelling — procps says `nlwp` and BSD
|
|
477
|
-
* has no keyword for it at all, only the `-M` listing — so an eighth column
|
|
478
|
-
* would have cost the single parser psArgs is built around. And `args` cannot
|
|
479
|
-
* sit beside `comm`: only one field in an `-o` list may contain spaces, because
|
|
480
|
-
* only the last one is unambiguous.
|
|
481
|
-
*
|
|
482
|
-
* Scoped to the candidate pids rather than to the machine, which is what makes
|
|
483
|
-
* it cheap: measured here, `ps -M -p` over forty pids is 0.15s against 0.10s
|
|
484
|
-
* for the whole-machine call it follows. `top -stats th,ports` would have
|
|
485
|
-
* answered both in one go and takes 4.25s on an idle machine — see readThreads'
|
|
486
|
-
* note on why ports is not a column at all.
|
|
487
|
-
*/
|
|
488
|
-
export function psDetailArgs(pids, platform = process.platform) {
|
|
489
|
-
const list = pids.join(",");
|
|
490
|
-
// procps: `nlwp` is free here, and `args` is last as ever.
|
|
491
|
-
if (platform === "linux") return ["-o", "pid,nlwp,args", "-p", list];
|
|
492
|
-
// BSD: `-M` lists each process followed by one line per thread, and the
|
|
493
|
-
// process line carries the untruncated argument vector. Counting the lines is
|
|
494
|
-
// the thread count; the process line is the argv. One child, both answers.
|
|
495
|
-
return ["-M", "-p", list];
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* `ps -M` output: a process line, then one line per thread, per pid.
|
|
500
|
-
*
|
|
501
|
-
* The two kinds are told apart by column one. A process line begins with the
|
|
502
|
-
* owning user and a thread line begins with spaces — `ps` leaves USER, TT and
|
|
503
|
-
* COMMAND blank on a thread because a thread has none of its own. So the count
|
|
504
|
-
* is "lines mentioning this pid, less the one that named it", and the argv is
|
|
505
|
-
* whatever the named line ended with.
|
|
506
|
-
*
|
|
507
|
-
* A kernel thread and an exiting process print a bracketed command
|
|
508
|
-
* (`[kworker/0:1]`, `(ccusage)`); that is `ps` reporting a state and it is left
|
|
509
|
-
* exactly as it came.
|
|
510
|
-
*/
|
|
511
|
-
export function parsePsThreadsBsd(text) {
|
|
512
|
-
const out = new Map();
|
|
513
|
-
for (const line of String(text ?? "").split("\n")) {
|
|
514
|
-
if (!line.trim()) continue;
|
|
515
|
-
// USER PID TT %CPU STAT PRI STIME UTIME COMMAND — eight fixed fields, and
|
|
516
|
-
// COMMAND is everything after them.
|
|
517
|
-
const proc = /^(\S+)\s+(\d+)\s+\S+\s+[\d.,]+\s+\S+\s+\S+\s+\S+\s+\S+\s*(.*)$/.exec(line);
|
|
518
|
-
if (proc) {
|
|
519
|
-
if (proc[1] === "USER") continue; // the header
|
|
520
|
-
const pid = Number(proc[2]);
|
|
521
|
-
const row = out.get(pid) ?? { threads: 0, cmd: "" };
|
|
522
|
-
row.cmd = proc[3].trim();
|
|
523
|
-
out.set(pid, row);
|
|
524
|
-
continue;
|
|
525
|
-
}
|
|
526
|
-
const thread = /^\s+(\d+)\s/.exec(line);
|
|
527
|
-
if (!thread) continue;
|
|
528
|
-
const pid = Number(thread[1]);
|
|
529
|
-
const row = out.get(pid) ?? { threads: 0, cmd: "" };
|
|
530
|
-
row.threads += 1;
|
|
531
|
-
out.set(pid, row);
|
|
532
|
-
}
|
|
533
|
-
return out;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/** `ps -o pid,nlwp,args` output, which is two numbers and then the rest. */
|
|
537
|
-
export function parsePsThreadsProcps(text) {
|
|
538
|
-
const out = new Map();
|
|
539
|
-
for (const line of String(text ?? "").split("\n")) {
|
|
540
|
-
const m = /^\s*(\d+)\s+(\d+)\s*(.*)$/.exec(line);
|
|
541
|
-
if (!m) continue;
|
|
542
|
-
out.set(Number(m[1]), { threads: Number(m[2]), cmd: m[3].trim() });
|
|
543
|
-
}
|
|
544
|
-
return out;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
/**
|
|
548
|
-
* The pieces of a `ps` ELAPSED field, which is `[[DD-]HH:]MM:SS` on both Unixes.
|
|
549
|
-
*
|
|
550
|
-
* Returns seconds, or null for anything that is not that shape — a header line
|
|
551
|
-
* that slipped through, a locale doing something unexpected. Null prints as a
|
|
552
|
-
* dash rather than as "0s", because a process that has been up for no time and
|
|
553
|
-
* a process whose uptime could not be read are different facts.
|
|
554
|
-
*/
|
|
555
|
-
export function elapsedSeconds(text) {
|
|
556
|
-
const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(String(text ?? "").trim());
|
|
557
|
-
if (!m) return null;
|
|
558
|
-
const [, d, h, min, sec] = m;
|
|
559
|
-
return (Number(d ?? 0) * 86400) + (Number(h ?? 0) * 3600) + (Number(min) * 60) + Number(sec);
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/**
|
|
563
|
-
* An argument vector with the parts that must not be looked at taken out, and
|
|
564
|
-
* the parts nobody reads cut off.
|
|
565
|
-
*
|
|
566
|
-
* THIS IS A BLOCKLIST AND A BLOCKLIST LEAKS. It is written down here rather
|
|
567
|
-
* than implied, because the alternative — `comm`, which is what this replaced —
|
|
568
|
-
* leaked nothing and told the reader nothing either. What it removes is the
|
|
569
|
-
* shapes a secret actually takes on a command line; what it cannot remove is a
|
|
570
|
-
* secret that looks like an ordinary word, and no rule here pretends otherwise.
|
|
571
|
-
*
|
|
572
|
-
* The cap is the other half, and it is not a nicety. Measured on this machine:
|
|
573
|
-
* one Chrome helper's argv is 906 characters of `--field-trial-handle`,
|
|
574
|
-
* base64 `--gpu-preferences` and shared-memory handles. Forty of those is 36 KB
|
|
575
|
-
* on the wire every four seconds to render a column nobody can read. What
|
|
576
|
-
* identifies a process is its first few arguments — `ng serve admin-portal
|
|
577
|
-
* --port 44440` — and that is what fits.
|
|
578
|
-
*/
|
|
579
|
-
export const CMD_MAX = 180;
|
|
580
|
-
|
|
581
|
-
const SECRET_FLAG =
|
|
582
|
-
/(-{1,2}[\w.-]*(?:token|password|passwd|secret|api[-_]?key|apikey|auth|credential|bearer|cookie|session[-_]?id|private[-_]?key)[\w.-]*)(=|\s+)(\S+)/gi;
|
|
583
|
-
// The shapes that are a secret on their own, with no flag in front of them.
|
|
584
|
-
const BARE_SECRET =
|
|
585
|
-
/\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{12,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,})/g;
|
|
586
|
-
// user:password@host, in any URL. The password goes; the rest is a location.
|
|
587
|
-
const URL_CREDENTIALS = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:@/]+):[^\s@/]+@/gi;
|
|
588
|
-
|
|
589
|
-
export function redactCommand(argv, home = "") {
|
|
590
|
-
let s = String(argv ?? "").trim();
|
|
591
|
-
if (!s) return "";
|
|
592
|
-
s = s.replace(SECRET_FLAG, (_, flag, sep, value) => `${flag}${sep === "=" ? "=" : " "}\u2022\u2022\u2022`);
|
|
593
|
-
s = s.replace(URL_CREDENTIALS, "$1:\u2022\u2022\u2022@");
|
|
594
|
-
s = s.replace(BARE_SECRET, "\u2022\u2022\u2022");
|
|
595
|
-
// `~` last, so a home path inside a redacted value is already gone. Only a
|
|
596
|
-
// whole path segment, so a user called `con` cannot rewrite the middle of an
|
|
597
|
-
// unrelated word.
|
|
598
|
-
if (home && home.length > 1) s = s.split(home + "/").join("~/").split(home + " ").join("~ ");
|
|
599
|
-
if (s.length > CMD_MAX) s = s.slice(0, CMD_MAX - 1).trimEnd() + "\u2026";
|
|
600
|
-
return s;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/**
|
|
604
|
-
* The ARGUMENTS, with the executable that carries them taken off the front.
|
|
605
|
-
*
|
|
606
|
-
* The name column already says `Google Chrome Helper`; repeating
|
|
607
|
-
* `/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome
|
|
608
|
-
* Framework.framework/Versions/152.0.7977.76/Helpers/Google Chrome
|
|
609
|
-
* Helper.app/Contents/MacOS/Google Chrome Helper` beside it spends the entire
|
|
610
|
-
* 180-character budget on a path the reader can already see the end of, and
|
|
611
|
-
* cuts off `--type=renderer`, which is the part that says WHICH helper this is.
|
|
612
|
-
*
|
|
613
|
-
* argv[0] cannot be found by splitting on whitespace — on macOS the executable
|
|
614
|
-
* path routinely contains spaces, and `/Applications/Google Chrome.app/…` would
|
|
615
|
-
* be cut at "Google". The name from the first `ps` call is what resolves it:
|
|
616
|
-
* argv begins with a path whose last segment IS that name.
|
|
617
|
-
*
|
|
618
|
-
* "Last segment" is the whole of the rule, and the first version of this got it
|
|
619
|
-
* wrong in a way only the render showed. A plain `indexOf` cuts at the FIRST
|
|
620
|
-
* occurrence, and a macOS bundle contains the name twice — `…/Helpers/Google
|
|
621
|
-
* Chrome Helper.app/Contents/MacOS/Google Chrome Helper` — so every browser row
|
|
622
|
-
* read `.app/Contents/MacOS/Google Chrome Helper --type=gpu-process`, having
|
|
623
|
-
* spent the budget on the half of the path it was supposed to remove. A plain
|
|
624
|
-
* `lastIndexOf` is wrong the other way: `node /srv/node_modules/x` would cut
|
|
625
|
-
* inside `node_modules`.
|
|
626
|
-
*
|
|
627
|
-
* So: the first occurrence that a path segment actually ends at — preceded by a
|
|
628
|
-
* separator or nothing, followed by whitespace or nothing. `node /srv/app.js
|
|
629
|
-
* --port 3000` leaves `/srv/app.js --port 3000`; `/usr/local/bin/dotnet exec
|
|
630
|
-
* --runtimeconfig …` leaves `exec --runtimeconfig …`, which is what identifies
|
|
631
|
-
* it.
|
|
632
|
-
*
|
|
633
|
-
* When the name is not in argv at all — a process that rewrote argv[0], or a
|
|
634
|
-
* `comm` that Linux truncated at 15 characters — the first whitespace token
|
|
635
|
-
* goes only if it looks like a path, because dropping the first word of
|
|
636
|
-
* something already short would take the only word there was.
|
|
637
|
-
*/
|
|
638
|
-
export function commandTail(argv, name) {
|
|
639
|
-
const s = String(argv ?? "").trim();
|
|
640
|
-
if (!s) return "";
|
|
641
|
-
const n = String(name ?? "").trim();
|
|
642
|
-
if (n) {
|
|
643
|
-
for (let at = s.indexOf(n); at >= 0; at = s.indexOf(n, at + 1)) {
|
|
644
|
-
const before = at === 0 ? "" : s[at - 1];
|
|
645
|
-
const after = s[at + n.length] ?? "";
|
|
646
|
-
if ((before === "" || before === "/" || before === "\\" || /\s/.test(before))
|
|
647
|
-
&& (after === "" || /\s/.test(after))) {
|
|
648
|
-
return s.slice(at + n.length).trim();
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
const first = s.split(/\s+/)[0] ?? "";
|
|
653
|
-
return first.includes("/") ? s.slice(first.length).trim() : s;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
/**
|
|
657
|
-
* Rows out of `ps -o pid,pcpu,pmem,comm`, in that column order on both Unixes —
|
|
658
|
-
* see psArgs for how each platform is asked for it.
|
|
659
|
-
*
|
|
660
|
-
* `pcpu` is a percentage of ONE core on both, so a multi-threaded process runs
|
|
661
|
-
* past 100 and that is information rather than an error: 157 is one and a half
|
|
662
|
-
* cores. cpuFromDeltas puts the Windows column on this same scale.
|
|
663
|
-
*
|
|
664
|
-
* There is no row limit in the query because neither `ps` has one and `run`
|
|
665
|
-
* deliberately never inherits a shell, so there is no `| head` to pipe into.
|
|
666
|
-
*
|
|
667
|
-
* And there is none by default here either, which is a change: the loop used to
|
|
668
|
-
* stop at eight, and stopping at eight is what made the memory ranking a lie
|
|
669
|
-
* once the panel could ask for one (#739). Selection belongs to pickCandidates,
|
|
670
|
-
* which cannot rank a column out of rows this function has already thrown away.
|
|
671
|
-
* The cost is a regex over every line of `ps` — a few hundred on a busy
|
|
672
|
-
* machine, once every four seconds and only while the panel is open — against a
|
|
673
|
-
* string that has already been read and allocated. `limit` stays for callers
|
|
674
|
-
* that do want a truncation, which is now only the tests.
|
|
675
|
-
*/
|
|
676
|
-
export function parsePsProcesses(text, limit = Infinity) {
|
|
677
|
-
const lines = String(text ?? "").trim().split("\n");
|
|
678
|
-
const out = [];
|
|
679
|
-
for (const line of lines.slice(1)) { // drop the header row
|
|
680
|
-
// Both separators, for the reason swapFromSysctl gives: C_LOCALE is the
|
|
681
|
-
// fix, and this is what keeps a stripped environment from emptying the
|
|
682
|
-
// panel. `%CPU` and `%MEM` are percentages printed with %.1f, so a comma in
|
|
683
|
-
// either can only be the decimal point.
|
|
684
|
-
//
|
|
685
|
-
// Seven fields, and only the last of them may contain a space — which is
|
|
686
|
-
// why `comm` is last and why `args` is not here at all (psDetailArgs).
|
|
687
|
-
// `rss` is an integer of kibibytes, `etime` and `user` cannot contain
|
|
688
|
-
// whitespace, so the shape stays unambiguous with three more columns in it.
|
|
689
|
-
const m = /^\s*(\d+)\s+([\d.,]+)\s+([\d.,]+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.+?)\s*$/.exec(line);
|
|
690
|
-
if (!m) continue;
|
|
691
|
-
const num = v => Number(v.replace(",", "."));
|
|
692
|
-
out.push({
|
|
693
|
-
pid: Number(m[1]),
|
|
694
|
-
cpu: num(m[2]),
|
|
695
|
-
mem: num(m[3]),
|
|
696
|
-
// RSS IS NOT WHAT ACTIVITY MONITOR CALLS MEMORY, and on macOS it is not
|
|
697
|
-
// close. Measured here against `top`'s MEM on the same processes at the
|
|
698
|
-
// same moment: `dotnet` 37 MB against 534, `rider` 1300 against 3833,
|
|
699
|
-
// one Brave renderer 240 against 153 — ratios from 0.02 to 1.57. The
|
|
700
|
-
// column Activity Monitor draws is `phys_footprint`, which no
|
|
701
|
-
// unprivileged one-shot command reports; `top -stats mem` has it and
|
|
702
|
-
// takes 4.25 seconds on an idle machine, against 0.10 for this call.
|
|
703
|
-
// So the figure is resident set size, the panel says so, and it is the
|
|
704
|
-
// same measurement on all three platforms rather than a different one
|
|
705
|
-
// per OS. On Linux and Windows it is also the figure their own tools
|
|
706
|
-
// show.
|
|
707
|
-
rssBytes: Number(m[4]) * 1024,
|
|
708
|
-
uptimeSec: elapsedSeconds(m[5]),
|
|
709
|
-
user: m[6],
|
|
710
|
-
name: m[7],
|
|
711
|
-
});
|
|
712
|
-
if (out.length >= limit) break;
|
|
713
|
-
}
|
|
714
|
-
return out;
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
/**
|
|
718
|
-
* Rows out of `Get-Process`.
|
|
719
|
-
*
|
|
720
|
-
* NOT `Win32_PerfFormattedData_PerfProc_Process`, which is what this used and
|
|
721
|
-
* which is not a class you may assume exists. It is published by perflib, and
|
|
722
|
-
* perflib is deregistered often enough to matter — a corporate image, a bad
|
|
723
|
-
* in-place upgrade, a half-run `lodctr`. On a machine reported from the field
|
|
724
|
-
* the class was simply absent (`Get-CimInstance: Invalid class`), and `typeperf`
|
|
725
|
-
* failed identically, which places the fault below WMI rather than in it. WMI
|
|
726
|
-
* was only mirroring what perflib had stopped publishing.
|
|
727
|
-
*
|
|
728
|
-
* `Get-Process` reads through NtQuerySystemInformation instead, so it depends on
|
|
729
|
-
* nothing that can be unregistered. The cost is that its `CPU` is total
|
|
730
|
-
* processor SECONDS since the process started, not a rate — so a percentage has
|
|
731
|
-
* to be derived from two readings, exactly the way the machine-wide figure is
|
|
732
|
-
* already derived from two tick samples. Reliability is worth one extra poll:
|
|
733
|
-
* an instant number from a class that may not exist is worth nothing at all.
|
|
734
|
-
*/
|
|
735
|
-
export function parseGetProcessJson(json, totalMem) {
|
|
736
|
-
let rows;
|
|
737
|
-
try { rows = typeof json === "string" ? JSON.parse(json) : json; }
|
|
738
|
-
catch { return []; }
|
|
739
|
-
if (!rows) return [];
|
|
740
|
-
if (!Array.isArray(rows)) rows = [rows];
|
|
741
|
-
return rows
|
|
742
|
-
.filter(r => r && r.ProcessName)
|
|
743
|
-
.map(r => ({
|
|
744
|
-
pid: Number(r.Id) || 0,
|
|
745
|
-
name: String(r.ProcessName),
|
|
746
|
-
// Null rather than 0 when the process denies the read: a system process
|
|
747
|
-
// we cannot query has an unknown CPU time, and calling that zero would
|
|
748
|
-
// rank it as idle.
|
|
749
|
-
cpuSec: typeof r.CPU === "number" ? r.CPU : null,
|
|
750
|
-
mem: totalMem > 0
|
|
751
|
-
? Math.round((Number(r.WorkingSetPrivate) || 0) / totalMem * 1000) / 10
|
|
752
|
-
: 0,
|
|
753
|
-
// The same three optional fields the POSIX branch attaches, so one client
|
|
754
|
-
// shape covers both. Absent rather than zero when the value did not come
|
|
755
|
-
// back: `StartTime` throws for a process this session may not open, and
|
|
756
|
-
// `Threads.Count` is undefined on a row that failed to project.
|
|
757
|
-
...(Number.isFinite(Number(r.Threads)) && Number(r.Threads) > 0 ? { threads: Number(r.Threads) } : {}),
|
|
758
|
-
...(Number.isFinite(Number(r.StartedAt)) ? { uptimeSec: Number(r.StartedAt) } : {}),
|
|
759
|
-
rssBytes: Number(r.WorkingSet) || 0,
|
|
760
|
-
}));
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
/**
|
|
764
|
-
* Turn two `Get-Process` readings into a percentage per process.
|
|
765
|
-
*
|
|
766
|
-
* `prev` maps pid to the cpuSec of the previous reading. A pid absent from it —
|
|
767
|
-
* a process that started since — has no delta and reports null rather than a
|
|
768
|
-
* number invented from its whole lifetime, which would rank a freshly spawned
|
|
769
|
-
* compiler as though it had been burning a core since boot.
|
|
770
|
-
*
|
|
771
|
-
* Per core, NOT per machine, because that is what the column beside it means:
|
|
772
|
-
* `ps -o pcpu` is a percentage of one core on both Unixes and is reported
|
|
773
|
-
* unmodified, so a row reading 157 there is a process using one and a half
|
|
774
|
-
* cores. This used to divide by the core count and clamp to 100 on the reasoning
|
|
775
|
-
* that Unix reported 0-100 — it does not, and never did, so the normalisation
|
|
776
|
-
* corrected a scale that already matched and introduced the mismatch it was
|
|
777
|
-
* written to prevent: on a 12-core machine six busy cores read 600 on macOS and
|
|
778
|
-
* 50 on Windows (#493). One CPU-second burned per wall-second is 100 here, on
|
|
779
|
-
* every platform.
|
|
780
|
-
*
|
|
781
|
-
* Core count is deliberately not a parameter any more. The aggregate meter's
|
|
782
|
-
* 0-100 convention (see cpuPercent) is a different question with a different
|
|
783
|
-
* answer, and the only way this drifts back is if a core count is in reach.
|
|
784
|
-
*/
|
|
785
|
-
export function cpuFromDeltas(rows, prev, elapsedMs, limit = Infinity) {
|
|
786
|
-
const secs = elapsedMs / 1000;
|
|
787
|
-
const out = rows.map(r => {
|
|
788
|
-
const before = prev instanceof Map ? prev.get(r.pid) : undefined;
|
|
789
|
-
let cpu = null;
|
|
790
|
-
if (r.cpuSec != null && before != null && secs > 0) {
|
|
791
|
-
const d = r.cpuSec - before;
|
|
792
|
-
// A counter that went backwards means the pid was reused by a different
|
|
793
|
-
// process; report nothing rather than a negative or a wild number.
|
|
794
|
-
if (d >= 0) cpu = Math.round((d / secs) * 1000) / 10;
|
|
795
|
-
}
|
|
796
|
-
return { pid: r.pid, cpu, mem: r.mem, name: r.name };
|
|
797
|
-
});
|
|
798
|
-
// Until the second reading lands there is no CPU to sort on, so the list is
|
|
799
|
-
// ordered by memory — which is a real answer to "what is this machine doing",
|
|
800
|
-
// not a placeholder.
|
|
801
|
-
const haveCpu = out.some(r => r.cpu != null);
|
|
802
|
-
out.sort(haveCpu
|
|
803
|
-
? (a, b) => (b.cpu ?? -1) - (a.cpu ?? -1)
|
|
804
|
-
: (a, b) => b.mem - a.mem);
|
|
805
|
-
return out.slice(0, limit);
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
/** Previous Windows reading, so the next one can be a rate. Cleared with the
|
|
809
|
-
* rest of the sampler state. */
|
|
810
|
-
let prevProcCpu = null;
|
|
811
|
-
let prevProcAt = 0;
|
|
812
|
-
|
|
813
|
-
/** The run producing the next list, and the last one that finished. */
|
|
814
|
-
let procInFlight = null;
|
|
815
|
-
let procLast = null; // { at, read: { procs, total }, detail }
|
|
816
|
-
let procInFlightDetail = false;
|
|
817
|
-
|
|
818
|
-
/**
|
|
819
|
-
* How old a finished reading may be and still answer a caller.
|
|
820
|
-
*
|
|
821
|
-
* Well under MachinePanel's PROC_POLL_MS of 4000, so the panel that this exists
|
|
822
|
-
* for never once gets a cached list; long enough that a second tab, a second
|
|
823
|
-
* browser, or anything else arriving between two of those polls is handed the
|
|
824
|
-
* list the first tab is already looking at rather than starting its own child.
|
|
825
|
-
*/
|
|
826
|
-
const PROC_MIN_GAP_MS = 1_500;
|
|
827
|
-
|
|
828
|
-
/**
|
|
829
|
-
* The process list, on demand only — never on the ambient timer, and never more
|
|
830
|
-
* than one child at a time.
|
|
831
|
-
*
|
|
832
|
-
* #544: /api/system/processes is a GET with no cache, no dedupe and no
|
|
833
|
-
* throttle, so the number of `powershell.exe Get-Process` children — about six
|
|
834
|
-
* seconds each — was whatever the caller asked for. That is the cheap half of
|
|
835
|
-
* the problem. The expensive half is that concurrent readers also overwrote
|
|
836
|
-
* each other's baseline: cpuFromDeltas needs the PREVIOUS reading's cpuSec per
|
|
837
|
-
* pid, prevProcCpu/prevProcAt are one shared pair, and two readers each stored
|
|
838
|
-
* theirs over the other's, so the CPU column came back computed against a
|
|
839
|
-
* baseline that belonged to somebody else's reading. That is a wrong number on
|
|
840
|
-
* screen, not merely wasted work, and it needed no attacker at all: one
|
|
841
|
-
* Get-Process takes longer than the panel's four-second poll, so a single tab
|
|
842
|
-
* on Windows already overlapped itself.
|
|
843
|
-
*
|
|
844
|
-
* One in-flight run fixes both, because one reader means one baseline. Callers
|
|
845
|
-
* that arrive while a run is going share its promise; callers that arrive just
|
|
846
|
-
* after one finished are served that reading.
|
|
847
|
-
*/
|
|
848
|
-
export async function readProcesses(platform = process.platform, detail = false) {
|
|
849
|
-
const now = Date.now();
|
|
850
|
-
// A cached reading serves a caller that wants LESS than it holds, never one
|
|
851
|
-
// that wants more: the panel is happy with a detailed reading, and the modal
|
|
852
|
-
// opening onto a plain one would show four empty columns for up to a poll.
|
|
853
|
-
if (procLast && now - procLast.at < PROC_MIN_GAP_MS && (procLast.detail || !detail)) return procLast.read;
|
|
854
|
-
if (procInFlight && (procInFlightDetail || !detail)) return procInFlight;
|
|
855
|
-
// Only a real reading is remembered. Every failure inside readProcessesNow —
|
|
856
|
-
// a spawn that never started, a non-zero exit, the timeout — resolves to an
|
|
857
|
-
// empty array, and no machine has nothing running on it, so an empty list is
|
|
858
|
-
// a failure by construction. Serving one for the next 1.5s would turn a
|
|
859
|
-
// single hiccup into a blank panel that outlives it; the next caller retries
|
|
860
|
-
// instead. The in-flight share still applies, so a burst arriving during a
|
|
861
|
-
// failing read is one failing child, not a burst of them.
|
|
862
|
-
procInFlightDetail = detail;
|
|
863
|
-
procInFlight = readProcessesNow(platform, detail)
|
|
864
|
-
.then(read => {
|
|
865
|
-
if (read.procs.length) procLast = { at: Date.now(), read, detail };
|
|
866
|
-
return read;
|
|
867
|
-
})
|
|
868
|
-
.finally(() => { procInFlight = null; procInFlightDetail = false; });
|
|
869
|
-
return procInFlight;
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
async function readProcessesNow(platform, detail = false) {
|
|
873
|
-
if (platform === "win32") {
|
|
874
|
-
const out = await run("powershell.exe", [
|
|
875
|
-
"-NoProfile", "-NonInteractive", "-Command",
|
|
876
|
-
// Threads and StartTime ride along on the call that was already being
|
|
877
|
-
// made — `Get-Process` has both, so the two columns cost nothing here.
|
|
878
|
-
// `WorkingSet64` joins `PrivateMemorySize64` rather than replacing it:
|
|
879
|
-
// the percentage has always been computed from private bytes and moving
|
|
880
|
-
// it would change a number that is on screen today, while the new column
|
|
881
|
-
// wants the resident figure Task Manager itself shows.
|
|
882
|
-
// NOT `-IncludeUserName`: it requires an elevated session, and no part
|
|
883
|
-
// of this deck may ask for one. The user column is simply absent on
|
|
884
|
-
// Windows, which is the same rule the thermal section keeps.
|
|
885
|
-
"Get-Process | Select-Object Id,ProcessName,CPU,@{n='Threads';e={$_.Threads.Count}},@{n='StartedAt';e={if($_.StartTime){[int]((Get-Date)-$_.StartTime).TotalSeconds}else{$null}}},@{n='WorkingSet';e={$_.WorkingSet64}},@{n='WorkingSetPrivate';e={$_.PrivateMemorySize64}} | ConvertTo-Json -Compress",
|
|
886
|
-
], 6_000);
|
|
887
|
-
// The same shape the POSIX branch below returns, and not a bare array: the
|
|
888
|
-
// caller reads `read.procs.length` to decide whether this was a real
|
|
889
|
-
// reading, so an array meant a TypeError rather than an empty list. Every
|
|
890
|
-
// Windows failure — a non-zero exit, a spawn that never started, the 6s
|
|
891
|
-
// deadline this module's own comment says the runtime sits right on —
|
|
892
|
-
// therefore answered `/api/system/processes` with a 500 and a logged stack,
|
|
893
|
-
// every four seconds for as long as the machine panel was open.
|
|
894
|
-
if (!out) return { procs: [], total: 0 };
|
|
895
|
-
const rows = parseGetProcessJson(out.trim(), os.totalmem());
|
|
896
|
-
const now = Date.now();
|
|
897
|
-
const ranked = cpuFromDeltas(rows, prevProcCpu, now - prevProcAt);
|
|
898
|
-
prevProcCpu = new Map(rows.filter(r => r.cpuSec != null).map(r => [r.pid, r.cpuSec]));
|
|
899
|
-
prevProcAt = now;
|
|
900
|
-
return { procs: pickCandidates(ranked), total: ranked.length };
|
|
901
|
-
}
|
|
902
|
-
const out = await run("ps", psArgs(platform), 4_000);
|
|
903
|
-
if (!out) return { procs: [], total: 0 };
|
|
904
|
-
const all = parsePsProcesses(out);
|
|
905
|
-
const procs = pickCandidates(all);
|
|
906
|
-
if (detail) await attachDetail(procs, platform);
|
|
907
|
-
return { procs, total: all.length };
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
/**
|
|
911
|
-
* Fill in threads and the command tail, for the candidates and no further.
|
|
912
|
-
*
|
|
913
|
-
* AFTER pickCandidates, deliberately: this is a per-pid listing, and asking it
|
|
914
|
-
* about every process on the machine would be several hundred pids to answer a
|
|
915
|
-
* question about forty. The candidates are what any surface can draw, so they
|
|
916
|
-
* are what gets the second child.
|
|
917
|
-
*
|
|
918
|
-
* A failure here is not a failure of the reading. `ps -M` can lose a race with
|
|
919
|
-
* a process that exited between the two calls, a hardened environment can
|
|
920
|
-
* refuse it, and neither is a reason to blank a list whose CPU and memory
|
|
921
|
-
* columns are already correct. The fields are simply absent, and the columns
|
|
922
|
-
* that read them print a dash — the same rule the rest of this module keeps:
|
|
923
|
-
* an unknown is never rendered as a zero.
|
|
924
|
-
*/
|
|
925
|
-
async function attachDetail(procs, platform) {
|
|
926
|
-
if (!procs.length) return;
|
|
927
|
-
const pids = procs.map(p => p.pid).filter(n => Number.isInteger(n) && n > 0);
|
|
928
|
-
if (!pids.length) return;
|
|
929
|
-
let out;
|
|
930
|
-
try { out = await run("ps", psDetailArgs(pids, platform), 4_000); }
|
|
931
|
-
catch { return; }
|
|
932
|
-
if (!out) return;
|
|
933
|
-
const detail = platform === "linux" ? parsePsThreadsProcps(out) : parsePsThreadsBsd(out);
|
|
934
|
-
const home = os.homedir?.() ?? "";
|
|
935
|
-
for (const p of procs) {
|
|
936
|
-
const d = detail.get(p.pid);
|
|
937
|
-
if (!d) continue;
|
|
938
|
-
// Zero threads is not a reading. Every process has at least one, so a zero
|
|
939
|
-
// here means the listing named the pid and gave no thread lines for it —
|
|
940
|
-
// a race with an exit — and a `0` in that column would be a claim.
|
|
941
|
-
if (d.threads > 0) p.threads = d.threads;
|
|
942
|
-
// REDACTED BEFORE IT LEAVES THIS MODULE, not on the way to the screen.
|
|
943
|
-
// /api/system/processes is served to anything that can reach the loopback
|
|
944
|
-
// port, and a token that only the client hides is a token that shipped.
|
|
945
|
-
const cmd = redactCommand(commandTail(d.cmd, p.name), home);
|
|
946
|
-
if (cmd) p.cmd = cmd;
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
// ---------------------------------------------------------------------------
|
|
951
|
-
// Thermal: is this machine getting hot, and is it being held back for it.
|
|
952
|
-
//
|
|
953
|
-
// The section the load average cannot answer. A saturated machine that is cool
|
|
954
|
-
// is a machine doing work; a saturated machine that is thermally limited is one
|
|
955
|
-
// where the next agent you launch makes everything slower, and `67.27 82.98
|
|
956
|
-
// 74.19` reads identically in both cases.
|
|
957
|
-
//
|
|
958
|
-
// THREE PLATFORMS ANSWER THREE DIFFERENT QUESTIONS, and on one of them the
|
|
959
|
-
// honest answer is not a temperature at all. Everything below was measured on
|
|
960
|
-
// the machines available rather than taken from documentation, and the negative
|
|
961
|
-
// results are recorded here because they are the reason the shape is what it
|
|
962
|
-
// is:
|
|
963
|
-
//
|
|
964
|
-
// Linux /sys/class/hwmon/hwmon*/temp*_input, millidegrees Celsius, with
|
|
965
|
-
// the chip in `name` and the sensor in `temp*_label`. A plain file
|
|
966
|
-
// read, exactly like /proc/meminfo — no subprocess, and the chip
|
|
967
|
-
// publishes its own `temp*_max` and `temp*_crit`, so the warning
|
|
968
|
-
// bands are the hardware's rather than ones invented here.
|
|
969
|
-
// /sys/class/thermal/thermal_zone*/ is the coarser fallback.
|
|
970
|
-
//
|
|
971
|
-
// macOS No CPU degrees without root, verified: `powermetrics --samplers
|
|
972
|
-
// smc` answers "powermetrics must be invoked as the superuser", and
|
|
973
|
-
// `ioreg -c AppleSMC -r -d 1` publishes no temperature key at all to
|
|
974
|
-
// an unprivileged process. Asking a dashboard for a password every
|
|
975
|
-
// ten seconds is not an option, and this deck does not ship a
|
|
976
|
-
// kernel driver.
|
|
977
|
-
//
|
|
978
|
-
// But the GPU driver does publish one, and nothing said so: the
|
|
979
|
-
// accelerator's PerformanceStatistics carries "Temperature(C)"
|
|
980
|
-
// beside its clock, its activity and its power. Read live on an
|
|
981
|
-
// Intel Mac with an AMD card — 60, 60, 61 over four seconds, from
|
|
982
|
-
// `ioreg -r -k PerformanceStatistics` in 51ms. Apple Silicon's
|
|
983
|
-
// AGXAccelerator publishes the same dictionary WITHOUT that key, so
|
|
984
|
-
// there the parser finds nothing and no row is drawn, which is the
|
|
985
|
-
// correct outcome rather than a special case.
|
|
986
|
-
//
|
|
987
|
-
// And `pmset -g therm` is unprivileged, instant, and present on
|
|
988
|
-
// both architectures. What it reports is not heat but the
|
|
989
|
-
// consequence of heat: CPU_Speed_Limit, the share of the CPU's speed
|
|
990
|
-
// the thermal manager is currently allowing. That is arguably the
|
|
991
|
-
// more useful of the two readings — a temperature is a number you
|
|
992
|
-
// have to interpret, a speed limit is the thing you were trying to
|
|
993
|
-
// interpret it into.
|
|
994
|
-
//
|
|
995
|
-
// `sysctl machdep.xcpm.cpu_thermal_level` is deliberately unused. It
|
|
996
|
-
// is live (33, then 42, then 41 over three seconds) but it is
|
|
997
|
-
// Intel-only and an undocumented scale, and printing it as though it
|
|
998
|
-
// were degrees would be exactly the lie this module refuses.
|
|
999
|
-
//
|
|
1000
|
-
// Windows The performance counter `\Thermal Zone Information(*)\High
|
|
1001
|
-
// Precision Temperature`, in TENTHS OF A KELVIN, read through
|
|
1002
|
-
// Get-Counter.
|
|
1003
|
-
//
|
|
1004
|
-
// It is read through a counter rather than through WMI for one
|
|
1005
|
-
// reason, and it is the reason this section never worked on Windows
|
|
1006
|
-
// for anybody: MSAcpi_ThermalZoneTemperature lives in root\wmi and
|
|
1007
|
-
// that namespace REQUIRES ADMINISTRATOR. A deck started from an
|
|
1008
|
-
// ordinary terminal — which is every deck, since v1 must never need
|
|
1009
|
-
// admin rights — got Access Denied, three times, and then gave up
|
|
1010
|
-
// for the life of the process. The section was not missing because
|
|
1011
|
-
// the hardware was silent; it was missing because we were asking
|
|
1012
|
-
// somewhere we were not allowed to look.
|
|
1013
|
-
//
|
|
1014
|
-
// MSAcpi is still asked, second, because a deck that IS elevated can
|
|
1015
|
-
// read it and because the two do not always agree — some boards
|
|
1016
|
-
// publish a zone to ACPI and no counter.
|
|
1017
|
-
//
|
|
1018
|
-
// Genuinely absent on a large share of machines either way: a
|
|
1019
|
-
// desktop board with no zone, and every virtual machine, which has
|
|
1020
|
-
// no thermal hardware to report. Measured on a QEMU/SeaBIOS guest:
|
|
1021
|
-
// the counter set is registered and answers "The specified instance
|
|
1022
|
-
// is not present", and MSAcpi answers "Not supported" even to an
|
|
1023
|
-
// administrator. Absent is ordinary here rather than an error.
|
|
1024
|
-
//
|
|
1025
|
-
// THE COUNTER PATH IS LOCALISED. `Thermal Zone Information` is the
|
|
1026
|
-
// English name and a German or French Windows publishes its own, so
|
|
1027
|
-
// this reaches the counter on an English install and falls through
|
|
1028
|
-
// to MSAcpi elsewhere. Translating it means resolving a numeric
|
|
1029
|
-
// index through the registry, which is a change with no way to be
|
|
1030
|
-
// tested from here — see #747.
|
|
1031
|
-
//
|
|
1032
|
-
// NEVER INVENT A READING. No sensor means no row, and no rows at all means the
|
|
1033
|
-
// section is not rendered: not 0°C, not a dash, not a grey empty bar. Same rule
|
|
1034
|
-
// that keeps `cpu` null until two samples exist.
|
|
1035
|
-
|
|
1036
|
-
/** How often the thermal reading is refreshed. Heat moves on the scale of
|
|
1037
|
-
* seconds, and the two platforms that cost a subprocess to ask cost 51ms
|
|
1038
|
-
* (`ioreg`) and rather less (`pmset`), measured. Linux costs a file read. */
|
|
1039
|
-
const THERMAL_INTERVAL_MS = 10_000;
|
|
1040
|
-
|
|
1041
|
-
/**
|
|
1042
|
-
* How many consecutive empty readings before this machine is left alone.
|
|
1043
|
-
*
|
|
1044
|
-
* The reason is Windows, where MSAcpi_ThermalZoneTemperature is absent on a
|
|
1045
|
-
* large share of desktops: without this, every one of those machines would pay
|
|
1046
|
-
* a `Get-CimInstance` child every ten seconds, forever, to render a section it
|
|
1047
|
-
* can never render. Three rather than one because a single failure can be a
|
|
1048
|
-
* hiccup — a timeout, a machine mid-wake — and giving up on a hiccup would lose
|
|
1049
|
-
* a reading the machine does have.
|
|
1050
|
-
*
|
|
1051
|
-
* Not persisted. A restart asks again, which is what should happen after the
|
|
1052
|
-
* user installs a driver or changes a firmware setting.
|
|
1053
|
-
*/
|
|
1054
|
-
const THERMAL_GIVE_UP = 3;
|
|
1055
|
-
|
|
1056
|
-
/** Bands used where the hardware publishes none of its own. Linux sensors
|
|
1057
|
-
* carry `temp*_max` and `temp*_crit` and those win: a laptop package sensor
|
|
1058
|
-
* and an NVMe drive do not share a comfortable range, and one scale for both
|
|
1059
|
-
* would be a number this module made up. */
|
|
1060
|
-
const WARN_C = 75;
|
|
1061
|
-
const CRIT_C = 90;
|
|
1062
|
-
|
|
1063
|
-
/**
|
|
1064
|
-
* How much history the panel keeps, and why it is bucketed by minute.
|
|
1065
|
-
*
|
|
1066
|
-
* Each section answers "what is it now"; the chart behind it answers "what did
|
|
1067
|
-
* it do while that build was running", which is a different question and the
|
|
1068
|
-
* reason a section opens one at all. 1440 minutes is a day, which covers "since
|
|
1069
|
-
* the deck started" for every session anybody actually has.
|
|
1070
|
-
*
|
|
1071
|
-
* A bucket holds the MAXIMUM of its minute, never the mean, and that choice is
|
|
1072
|
-
* the same one for every series here. A machine that touched 94°C for twenty
|
|
1073
|
-
* seconds and sat at 60 for the rest of the minute averages to 66 and reads as
|
|
1074
|
-
* calm; a load average that spiked to 114 between two quiet stretches averages
|
|
1075
|
-
* away entirely. The spike is what somebody opens a chart to find.
|
|
1076
|
-
*
|
|
1077
|
-
* Kept out of systemSnapshot deliberately. That endpoint is polled every three
|
|
1078
|
-
* seconds by a topbar meter that draws none of this; a day of buckets on every
|
|
1079
|
-
* one of those responses would be the largest thing the deck sends, for charts
|
|
1080
|
-
* that are usually closed. It has its own route, like the process list.
|
|
1081
|
-
*/
|
|
1082
|
-
const HISTORY_MINUTES = 1440;
|
|
1083
|
-
const BUCKET_MS = 60_000;
|
|
1084
|
-
|
|
1085
|
-
/** A reading that is not a temperature is not a misparse to be shown anyway.
|
|
1086
|
-
* Silicon does not run below freezing or above 130°C, and both ends of that
|
|
1087
|
-
* have been produced by reading the right file with the wrong unit. */
|
|
1088
|
-
const plausible = c => Number.isFinite(c) && c > 0 && c < 130;
|
|
1089
|
-
|
|
1090
|
-
/**
|
|
1091
|
-
* Millidegrees Celsius out of a hwmon `temp*_input`, or null.
|
|
1092
|
-
*
|
|
1093
|
-
* The kernel writes an integer; the divide is the whole conversion. Exported
|
|
1094
|
-
* and pure for the reason every parser here is: a Linux answer has to be
|
|
1095
|
-
* checkable from a Mac.
|
|
1096
|
-
*/
|
|
1097
|
-
export function celsiusFromMilli(text) {
|
|
1098
|
-
const n = Number(String(text ?? "").trim());
|
|
1099
|
-
const c = Math.round(n / 1000);
|
|
1100
|
-
return plausible(c) ? c : null;
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
/**
|
|
1104
|
-
* Which of a machine's sensors the panel names, out of every sensor found.
|
|
1105
|
-
*
|
|
1106
|
-
* A real machine publishes a lot of them: the package, one per core, the NVMe
|
|
1107
|
-
* drive, the wireless card, the chipset. Two rows is what the panel has room
|
|
1108
|
-
* for and two rows is what somebody watching a build wants, so this picks the
|
|
1109
|
-
* CPU and the GPU by the chip that published them and leaves the rest alone.
|
|
1110
|
-
*
|
|
1111
|
-
* Preference inside a chip matters as much as the chip does. coretemp exposes
|
|
1112
|
-
* `Package id 0` beside `Core 0`..`Core N`, and the package is the reading
|
|
1113
|
-
* — a single core's number is noisier and lower than the die it sits on.
|
|
1114
|
-
* k10temp exposes `Tctl` and, on parts that have it, `Tdie`: Tctl is Tdie plus
|
|
1115
|
-
* a vendor offset that exists for fan control, so Tdie is the temperature and
|
|
1116
|
-
* Tctl is the fallback. amdgpu's `edge` is the die edge and `junction` is the
|
|
1117
|
-
* hotspot; edge is what every other tool calls the GPU temperature.
|
|
1118
|
-
*
|
|
1119
|
-
* Where a chip publishes nothing recognisable, the hottest of its sensors is
|
|
1120
|
-
* taken, because the question is "is it getting hot" and the hottest sensor is
|
|
1121
|
-
* the one that answers it.
|
|
1122
|
-
*/
|
|
1123
|
-
const CPU_CHIPS = ["coretemp", "k10temp", "zenpower", "cpu_thermal", "soc_thermal"];
|
|
1124
|
-
const GPU_CHIPS = ["amdgpu", "nouveau", "i915", "xe", "radeon"];
|
|
1125
|
-
|
|
1126
|
-
export function pickThermalRows(sensors) {
|
|
1127
|
-
const hottest = rows => rows.reduce((a, b) => (b.celsius > a.celsius ? b : a));
|
|
1128
|
-
const pick = (chips, prefer, label) => {
|
|
1129
|
-
const mine = (sensors ?? []).filter(s => chips.includes(s.chip) && plausible(s.celsius));
|
|
1130
|
-
if (!mine.length) return null;
|
|
1131
|
-
for (const re of prefer) {
|
|
1132
|
-
const hit = mine.find(s => re.test(s.label ?? ""));
|
|
1133
|
-
if (hit) return { ...hit, label };
|
|
1134
|
-
}
|
|
1135
|
-
return { ...hottest(mine), label };
|
|
1136
|
-
};
|
|
1137
|
-
return [
|
|
1138
|
-
pick(CPU_CHIPS, [/^package id/i, /^tdie$/i, /^tctl$/i], "CPU"),
|
|
1139
|
-
pick(GPU_CHIPS, [/^edge$/i, /^junction$/i], "GPU"),
|
|
1140
|
-
].filter(Boolean);
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
/**
|
|
1144
|
-
* Every temperature sensor under /sys/class/hwmon, with the chip that owns it
|
|
1145
|
-
* and the bands that chip publishes for it.
|
|
1146
|
-
*
|
|
1147
|
-
* `root` is a parameter so this can be pointed at a tree on disk. There is no
|
|
1148
|
-
* Linux machine here and no container runtime, so the alternative would be a
|
|
1149
|
-
* directory walk nobody has ever run — and a walk is exactly the kind of code
|
|
1150
|
-
* that a fixture of its OUTPUT cannot check, because the walk is the part that
|
|
1151
|
-
* is wrong.
|
|
1152
|
-
*/
|
|
1153
|
-
export async function readHwmon(root = "/sys/class/hwmon", deps = {}) {
|
|
1154
|
-
const dir = deps.readdir ?? readdir;
|
|
1155
|
-
const file = deps.readFile ?? readFile;
|
|
1156
|
-
const read = async path => { try { return String(await file(path, "utf8")).trim(); } catch { return null; } };
|
|
1157
|
-
let chips;
|
|
1158
|
-
try { chips = await dir(root); } catch { return []; }
|
|
1159
|
-
const out = [];
|
|
1160
|
-
for (const hwmon of chips) {
|
|
1161
|
-
const base = `${root}/${hwmon}`;
|
|
1162
|
-
const chip = (await read(`${base}/name`)) ?? hwmon;
|
|
1163
|
-
let entries;
|
|
1164
|
-
try { entries = await dir(base); } catch { continue; }
|
|
1165
|
-
for (const entry of entries) {
|
|
1166
|
-
const m = /^(temp\d+)_input$/.exec(entry);
|
|
1167
|
-
if (!m) continue;
|
|
1168
|
-
const celsius = celsiusFromMilli(await read(`${base}/${entry}`));
|
|
1169
|
-
if (celsius == null) continue;
|
|
1170
|
-
out.push({
|
|
1171
|
-
chip,
|
|
1172
|
-
label: await read(`${base}/${m[1]}_label`),
|
|
1173
|
-
celsius,
|
|
1174
|
-
// The hardware's own bands where it has them. `max` is where the chip
|
|
1175
|
-
// says it is unhappy and `crit` is where it says it will act.
|
|
1176
|
-
warnAt: celsiusFromMilli(await read(`${base}/${m[1]}_max`)) ?? WARN_C,
|
|
1177
|
-
critAt: celsiusFromMilli(await read(`${base}/${m[1]}_crit`)) ?? CRIT_C,
|
|
1178
|
-
});
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
return out;
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
/**
|
|
1185
|
-
* The coarser Linux fallback, for a machine whose sensors have no hwmon driver.
|
|
1186
|
-
*
|
|
1187
|
-
* One row, and it is labelled with the zone's own `type` rather than "CPU",
|
|
1188
|
-
* because a thermal zone is not a claim about what was measured. `acpitz` is
|
|
1189
|
-
* the motherboard's idea of ambient on a lot of hardware and calling that the
|
|
1190
|
-
* CPU would be the same lie in a different place.
|
|
1191
|
-
*/
|
|
1192
|
-
const ZONE_ORDER = ["x86_pkg_temp", "cpu-thermal", "cpu_thermal", "soc_thermal"];
|
|
1193
|
-
|
|
1194
|
-
export async function readThermalZones(root = "/sys/class/thermal", deps = {}) {
|
|
1195
|
-
const dir = deps.readdir ?? readdir;
|
|
1196
|
-
const file = deps.readFile ?? readFile;
|
|
1197
|
-
const read = async path => { try { return String(await file(path, "utf8")).trim(); } catch { return null; } };
|
|
1198
|
-
let zones;
|
|
1199
|
-
try { zones = (await dir(root)).filter(n => /^thermal_zone\d+$/.test(n)); } catch { return []; }
|
|
1200
|
-
const found = [];
|
|
1201
|
-
for (const zone of zones) {
|
|
1202
|
-
const celsius = celsiusFromMilli(await read(`${root}/${zone}/temp`));
|
|
1203
|
-
if (celsius == null) continue;
|
|
1204
|
-
found.push({ label: (await read(`${root}/${zone}/type`)) ?? zone, celsius, warnAt: WARN_C, critAt: CRIT_C });
|
|
1205
|
-
}
|
|
1206
|
-
if (!found.length) return [];
|
|
1207
|
-
const known = found.find(z => ZONE_ORDER.includes(z.label));
|
|
1208
|
-
return [known ?? found.reduce((a, b) => (b.celsius > a.celsius ? b : a))];
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
/**
|
|
1212
|
-
* GPU degrees out of `ioreg -r -k PerformanceStatistics`.
|
|
1213
|
-
*
|
|
1214
|
-
* The macOS reading nothing documented: the accelerator publishes
|
|
1215
|
-
* "Temperature(C)" in the same dictionary as its clock and its power. The
|
|
1216
|
-
* maximum across accelerators, because a machine with two cards is asking
|
|
1217
|
-
* whether it is getting hot, and the hotter card is the answer.
|
|
1218
|
-
*/
|
|
1219
|
-
export function gpuFromIoreg(text) {
|
|
1220
|
-
let best = null;
|
|
1221
|
-
for (const m of String(text ?? "").matchAll(/"Temperature\(C\)"\s*=\s*(-?\d+)/g)) {
|
|
1222
|
-
const c = Number(m[1]);
|
|
1223
|
-
if (plausible(c) && (best == null || c > best)) best = c;
|
|
1224
|
-
}
|
|
1225
|
-
return best;
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
/**
|
|
1229
|
-
* The share of the CPU's speed the thermal manager is allowing, out of
|
|
1230
|
-
* `pmset -g therm`, or null when this Mac has never recorded one.
|
|
1231
|
-
*
|
|
1232
|
-
* `CPU_Scheduler_Limit` sits beside it and is deliberately not read: it limits
|
|
1233
|
-
* scheduling rather than clock, so folding the two into one percentage would
|
|
1234
|
-
* produce a number that is neither. If scheduler throttling turns out to matter
|
|
1235
|
-
* it is a second row, not a redefinition of this one.
|
|
1236
|
-
*/
|
|
1237
|
-
export function throttleFromPmset(text) {
|
|
1238
|
-
const m = /CPU_Speed_Limit\s*=\s*(\d+)/.exec(String(text ?? ""));
|
|
1239
|
-
if (!m) return null;
|
|
1240
|
-
const pct = Number(m[1]);
|
|
1241
|
-
if (!Number.isFinite(pct) || pct < 0 || pct > 100) return null;
|
|
1242
|
-
return { speedLimit: pct };
|
|
1243
|
-
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
/**
|
|
1247
|
-
* Windows thermal zones out of the `Thermal Zone Information` counter set.
|
|
1248
|
-
*
|
|
1249
|
-
* `High Precision Temperature` is in TENTHS OF A KELVIN, which is the single
|
|
1250
|
-
* detail this branch turns on — the same unit MSAcpi uses below, and reading it
|
|
1251
|
-
* as anything else gives a number that is plausible-looking and wrong.
|
|
1252
|
-
*
|
|
1253
|
-
* This is the source that works WITHOUT ADMINISTRATOR, which is the whole point
|
|
1254
|
-
* of it: root\wmi needs elevation and a deck never has it. See the note at the
|
|
1255
|
-
* top of this section.
|
|
1256
|
-
*
|
|
1257
|
-
* Shape is `[{ i: instanceName, v: cookedValue }]` — the projection the
|
|
1258
|
-
* PowerShell one-liner makes, so this parser never has to know what a
|
|
1259
|
-
* CounterSample looks like.
|
|
1260
|
-
*/
|
|
1261
|
-
export const WIN_THERMAL_PS = [
|
|
1262
|
-
"$r = [ordered]@{}",
|
|
1263
|
-
// Get-Counter, not Get-CimInstance: this is the half that works unelevated.
|
|
1264
|
-
"try { $r.perf = @((Get-Counter -Counter '\\Thermal Zone Information(*)\\High Precision Temperature' -EA Stop).CounterSamples | ForEach-Object { @{ i = $_.InstanceName; v = $_.CookedValue } }) } catch {}",
|
|
1265
|
-
"if (-not $r.perf) { try { $r.acpi = @(Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature -EA Stop | ForEach-Object { @{ i = $_.InstanceName; v = $_.CurrentTemperature } }) } catch {} }",
|
|
1266
|
-
// Depth matters: the default of 2 turns the inner hashtables into the string
|
|
1267
|
-
// "System.Collections.Hashtable" and this parser would see nothing at all.
|
|
1268
|
-
"$r | ConvertTo-Json -Compress -Depth 4",
|
|
1269
|
-
].join("; ");
|
|
1270
|
-
|
|
1271
|
-
/**
|
|
1272
|
-
* Whichever of the two Windows sources answered, as thermal rows.
|
|
1273
|
-
*
|
|
1274
|
-
* The shape is `{ perf: [...] }` or `{ acpi: [...] }` or `{}` — the PowerShell
|
|
1275
|
-
* above only ever fills one, and fills neither on the machines where there is
|
|
1276
|
-
* nothing to fill it with. Both lists carry the same two fields and the same
|
|
1277
|
-
* unit, so the only thing that differs is which key they arrived under.
|
|
1278
|
-
*/
|
|
1279
|
-
export function parseWinThermal(json) {
|
|
1280
|
-
let r;
|
|
1281
|
-
try { r = typeof json === "string" ? JSON.parse(json) : json; }
|
|
1282
|
-
catch { return []; }
|
|
1283
|
-
if (!r || typeof r !== "object") return [];
|
|
1284
|
-
const perf = tempFromPerfCounterJson(r.perf ?? []);
|
|
1285
|
-
if (perf.length) return perf;
|
|
1286
|
-
return tempFromPerfCounterJson(
|
|
1287
|
-
// MSAcpi's projection uses the same two keys, so the one parser reads both.
|
|
1288
|
-
Array.isArray(r.acpi) ? r.acpi : (r.acpi ? [r.acpi] : []),
|
|
1289
|
-
);
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
export function tempFromPerfCounterJson(json) {
|
|
1293
|
-
let rows;
|
|
1294
|
-
try { rows = typeof json === "string" ? JSON.parse(json) : json; }
|
|
1295
|
-
catch { return []; }
|
|
1296
|
-
if (!rows) return [];
|
|
1297
|
-
if (!Array.isArray(rows)) rows = [rows];
|
|
1298
|
-
const found = [];
|
|
1299
|
-
for (const r of rows) {
|
|
1300
|
-
const tenths = Number(r?.v);
|
|
1301
|
-
if (!Number.isFinite(tenths)) continue;
|
|
1302
|
-
const celsius = Math.round(tenths / 10 - 273.15);
|
|
1303
|
-
if (!plausible(celsius)) continue;
|
|
1304
|
-
found.push({ label: zoneLabel(r?.i), celsius, warnAt: WARN_C, critAt: CRIT_C });
|
|
1305
|
-
}
|
|
1306
|
-
if (found.length === 1) found[0].label = "Thermal zone";
|
|
1307
|
-
return found.slice(0, 2);
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
/**
|
|
1311
|
-
* A thermal zone's name, out of whatever spelling the source used.
|
|
1312
|
-
*
|
|
1313
|
-
* The counter names its instances `\_tz.tz00` and WMI names the same zone
|
|
1314
|
-
* `ACPI\ThermalZone\TZ00_0`, so the tail after the last separator is the only
|
|
1315
|
-
* part the two agree on. Upper-cased because the counter lower-cases it and a
|
|
1316
|
-
* panel that showed `tz00` beside a `TZ01` from the other source would be
|
|
1317
|
-
* showing one machine as two.
|
|
1318
|
-
*/
|
|
1319
|
-
export function zoneLabel(raw) {
|
|
1320
|
-
const tail = String(raw ?? "").split(/[\\.]/).pop() ?? "";
|
|
1321
|
-
const name = tail.replace(/_\d+$/, "").toUpperCase();
|
|
1322
|
-
return name || "Thermal zone";
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
/**
|
|
1327
|
-
* The macOS rows, from whatever the three sources answered.
|
|
1328
|
-
*
|
|
1329
|
-
* Pure, and exported, for the reason sampleThermal's `deps.read` is: the branch
|
|
1330
|
-
* that matters only fires on a machine that answers with nothing, and the
|
|
1331
|
-
* machine this was written on answers with something. There is no other way to
|
|
1332
|
-
* run it.
|
|
1333
|
-
*
|
|
1334
|
-
* The ordering rule is the whole content. ioreg's GPU degrees and pmset's
|
|
1335
|
-
* throttle are what macOS itself gives up, and they win — they cost one cheap
|
|
1336
|
-
* subprocess each and they are the same numbers this deck has always shown.
|
|
1337
|
-
* macmon is consulted only when both were silent, and then its CPU row comes
|
|
1338
|
-
* first, because on the machine that reaches here the CPU is the reading
|
|
1339
|
-
* somebody opened the panel for.
|
|
1340
|
-
*/
|
|
1341
|
-
export function darwinThermal({ gpuC = null, throttle = null, macmon = {} } = {}) {
|
|
1342
|
-
const celsius = [];
|
|
1343
|
-
if (gpuC != null) celsius.push({ label: "GPU", celsius: gpuC, warnAt: WARN_C, critAt: CRIT_C });
|
|
1344
|
-
else {
|
|
1345
|
-
if (macmon.cpu != null) celsius.push({ label: "CPU", celsius: macmon.cpu, warnAt: WARN_C, critAt: CRIT_C });
|
|
1346
|
-
if (macmon.gpu != null) celsius.push({ label: "GPU", celsius: macmon.gpu, warnAt: WARN_C, critAt: CRIT_C });
|
|
1347
|
-
}
|
|
1348
|
-
return celsius.length || throttle ? { celsius, throttle } : null;
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
/**
|
|
1352
|
-
* What /api/system carries, or null when this machine says nothing at all.
|
|
1353
|
-
*
|
|
1354
|
-
* Two fields rather than one list, because they are two different readings and
|
|
1355
|
-
* collapsing them would let a throttle percentage be drawn under a °C heading
|
|
1356
|
-
* — the thing the label rule exists to prevent. `swapLabel` earned that rule
|
|
1357
|
-
* once already.
|
|
1358
|
-
*/
|
|
1359
|
-
export async function readThermal(platform = process.platform) {
|
|
1360
|
-
if (platform === "linux") {
|
|
1361
|
-
const sensors = await readHwmon();
|
|
1362
|
-
const celsius = pickThermalRows(sensors);
|
|
1363
|
-
const rows = celsius.length ? celsius : await readThermalZones();
|
|
1364
|
-
return rows.length ? { celsius: rows, throttle: null } : null;
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
if (platform === "darwin") {
|
|
1368
|
-
// Scoped by key rather than dumped whole: `ioreg -l` is 217KB and just
|
|
1369
|
-
// under two seconds on this machine, `-r -k PerformanceStatistics` is 83KB
|
|
1370
|
-
// and 51ms for the same number.
|
|
1371
|
-
const [gpu, therm] = await Promise.all([
|
|
1372
|
-
run("ioreg", ["-r", "-k", "PerformanceStatistics", "-w", "0"], 3_000),
|
|
1373
|
-
run("pmset", ["-g", "therm"]),
|
|
1374
|
-
]);
|
|
1375
|
-
const gpuC = gpu ? gpuFromIoreg(gpu) : null;
|
|
1376
|
-
const throttle = therm ? throttleFromPmset(therm) : null;
|
|
1377
|
-
|
|
1378
|
-
// Nothing from either is every Apple Silicon Mac, and only those: the AGX
|
|
1379
|
-
// driver does not publish the key ioreg reads, and pmset records no speed
|
|
1380
|
-
// limit on M-series. Asking macmon is the only thing left, and it is asked
|
|
1381
|
-
// ONLY here — an Intel Mac answers above and never spawns it. See
|
|
1382
|
-
// macmon.mjs for why a tool the user installed is the whole of the answer.
|
|
1383
|
-
const macmon = gpuC == null && !throttle
|
|
1384
|
-
? await (await import("./macmon.mjs")).readMacmonTemps()
|
|
1385
|
-
: {};
|
|
1386
|
-
|
|
1387
|
-
return darwinThermal({ gpuC, throttle, macmon });
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
if (platform === "win32") {
|
|
1391
|
-
// One child for both sources rather than two, because the cost here is the
|
|
1392
|
-
// PowerShell start and not the queries: the counter is tried first because
|
|
1393
|
-
// it needs no administrator, and MSAcpi only when the counter said nothing.
|
|
1394
|
-
// Both are wrapped in their own try — "no thermal zone on this machine" is
|
|
1395
|
-
// the ordinary answer and arrives as a throw from either.
|
|
1396
|
-
const out = await run("powershell.exe", [
|
|
1397
|
-
"-NoProfile", "-NonInteractive", "-Command", WIN_THERMAL_PS,
|
|
1398
|
-
], 6_000);
|
|
1399
|
-
const answer = out ? parseWinThermal(out.trim()) : [];
|
|
1400
|
-
if (answer.length) return { celsius: answer, throttle: null };
|
|
1401
|
-
|
|
1402
|
-
// Windows itself had nothing, which on a modern Intel laptop is every time:
|
|
1403
|
-
// the firmware declares no ACPI thermal zone and the sensors sit behind
|
|
1404
|
-
// Intel DTT, which an ordinary process may not read. If something on this
|
|
1405
|
-
// machine has already gone and got them — LibreHardwareMonitor, with its
|
|
1406
|
-
// web server on — they are a plain HTTP read away. Never installed, never
|
|
1407
|
-
// asked for. See hwmonitor.mjs.
|
|
1408
|
-
const { readHwMonitorTemps } = await import("./hwmonitor.mjs");
|
|
1409
|
-
const t = await readHwMonitorTemps();
|
|
1410
|
-
const rows = [];
|
|
1411
|
-
if (t.cpu != null) rows.push({ label: "CPU", celsius: t.cpu, warnAt: WARN_C, critAt: CRIT_C });
|
|
1412
|
-
if (t.gpu != null) rows.push({ label: "GPU", celsius: t.gpu, warnAt: WARN_C, critAt: CRIT_C });
|
|
1413
|
-
return rows.length ? { celsius: rows, throttle: null } : null;
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
return null;
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
/**
|
|
1420
|
-
* Fold one reading into the minute it belongs to, under a namespaced key.
|
|
1421
|
-
*
|
|
1422
|
-
* Keys are namespaced by section (`thermal:GPU`, `cpu:all`, `mem:swap`) rather
|
|
1423
|
-
* than kept in four rings, because they all share one clock: a bucket is a
|
|
1424
|
-
* minute of this machine, and every series that has something to say about that
|
|
1425
|
-
* minute says it in the same place. The sections sample at different rates —
|
|
1426
|
-
* CPU every three seconds, thermal every ten, memory every thirty — and folding
|
|
1427
|
-
* by maximum makes that difference invisible to the reader, which is what it
|
|
1428
|
-
* should be.
|
|
1429
|
-
*/
|
|
1430
|
-
function record(key, value, nowMs = Date.now()) {
|
|
1431
|
-
if (!Number.isFinite(value)) return;
|
|
1432
|
-
const minute = Math.floor(nowMs / BUCKET_MS);
|
|
1433
|
-
let last = history[history.length - 1];
|
|
1434
|
-
if (!last || last.m !== minute) {
|
|
1435
|
-
last = { m: minute, v: {} };
|
|
1436
|
-
history.push(last);
|
|
1437
|
-
while (history.length > HISTORY_MINUTES) history.shift();
|
|
1438
|
-
}
|
|
1439
|
-
const prev = last.v[key];
|
|
1440
|
-
last.v[key] = prev == null ? value : Math.max(prev, value);
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
|
-
/**
|
|
1444
|
-
* The thermal reading, whose series are not known until the machine answers.
|
|
1445
|
-
*
|
|
1446
|
-
* Keyed by the row's own label rather than by position, because the rows are
|
|
1447
|
-
* not the same on every platform and a machine can start reporting a sensor it
|
|
1448
|
-
* was not reporting before — a GPU driver loads, a laptop is docked. A series
|
|
1449
|
-
* that appears late simply has no points before it appeared, which is the truth
|
|
1450
|
-
* and draws correctly.
|
|
1451
|
-
*/
|
|
1452
|
-
function recordThermal(reading, nowMs = Date.now()) {
|
|
1453
|
-
if (!reading) return;
|
|
1454
|
-
for (const r of reading.celsius ?? []) record(`thermal:${r.label}`, r.celsius, nowMs);
|
|
1455
|
-
// Stored as the share TAKEN AWAY, the same way the panel draws it, so the
|
|
1456
|
-
// chart and the row cannot disagree about which direction is bad.
|
|
1457
|
-
if (reading.throttle) {
|
|
1458
|
-
record(`thermal:${THROTTLE_LABEL}`, Math.max(0, 100 - reading.throttle.speedLimit), nowMs);
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
/** The one thermal row that is not degrees. Named once so the recorder, the
|
|
1463
|
-
* route and the panel cannot drift apart on the spelling. */
|
|
1464
|
-
export const THROTTLE_LABEL = "Throttling";
|
|
1465
|
-
|
|
1466
|
-
/**
|
|
1467
|
-
* The scale a series is drawn against.
|
|
1468
|
-
*
|
|
1469
|
-
* Fixed at 100 wherever the PANEL draws the same number against a 0-100 track,
|
|
1470
|
-
* because two pictures of one reading that disagree about how alarming it is
|
|
1471
|
-
* would be worse than either alone. Load average is the exception and gets a
|
|
1472
|
-
* fitted top: it is genuinely unbounded — measured at 114 on a twelve-core
|
|
1473
|
-
* machine — and the section that shows it draws no track at all, so there is no
|
|
1474
|
-
* competing picture for a fitted scale to contradict. Rounded up to something a
|
|
1475
|
-
* person would choose, and floored at one and a half times the core count so a
|
|
1476
|
-
* quiet machine is not drawn as a dramatic climb.
|
|
1477
|
-
*/
|
|
1478
|
-
function loadTop(points, coreCount) {
|
|
1479
|
-
const peak = points.reduce((a, p) => Math.max(a, p.v), 0);
|
|
1480
|
-
const floor = Math.max(4, Math.ceil(coreCount * 1.5));
|
|
1481
|
-
const want = Math.max(floor, peak * 1.15);
|
|
1482
|
-
const step = want <= 20 ? 5 : want <= 100 ? 10 : 50;
|
|
1483
|
-
return Math.ceil(want / step) * step;
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
/**
|
|
1487
|
-
* What each section's chart is made of.
|
|
1488
|
-
*
|
|
1489
|
-
* One entry per series, each carrying its own unit, its own bands and its own
|
|
1490
|
-
* scale, because a percentage, a temperature and a queue depth share nothing —
|
|
1491
|
-
* drawing them against one axis would invite a reading of one shape against
|
|
1492
|
-
* another that means nothing.
|
|
1493
|
-
*/
|
|
1494
|
-
function seriesFor(group) {
|
|
1495
|
-
const at = key => history.filter(b => b.v[key] != null)
|
|
1496
|
-
// Timestamps rather than indices: a bucket only exists for a minute that was
|
|
1497
|
-
// sampled, so a gap — the machine asleep, the process paused — stays a gap
|
|
1498
|
-
// rather than becoming a straight line across it.
|
|
1499
|
-
.map(b => ({ t: b.m * BUCKET_MS, v: b.v[key] }));
|
|
1500
|
-
const coreCount = os.cpus().length;
|
|
1501
|
-
|
|
1502
|
-
if (group === "thermal") {
|
|
1503
|
-
const bands = new Map((thermal?.celsius ?? []).map(r => [r.label, r]));
|
|
1504
|
-
const labels = [];
|
|
1505
|
-
for (const b of history) {
|
|
1506
|
-
for (const k of Object.keys(b.v)) {
|
|
1507
|
-
if (!k.startsWith("thermal:")) continue;
|
|
1508
|
-
const label = k.slice("thermal:".length);
|
|
1509
|
-
if (!labels.includes(label)) labels.push(label);
|
|
1510
|
-
}
|
|
1511
|
-
}
|
|
1512
|
-
return labels.map(label => ({
|
|
1513
|
-
// The stable name of this reading, which the display label is not: `Swap`
|
|
1514
|
-
// is `Commit` on Windows, and anything joining on what the eye sees
|
|
1515
|
-
// breaks on the one platform nobody re-reads this on. It is the key the
|
|
1516
|
-
// ring is already recorded under, published rather than invented.
|
|
1517
|
-
key: `thermal:${label}`,
|
|
1518
|
-
label,
|
|
1519
|
-
unit: label === THROTTLE_LABEL ? "%" : "C",
|
|
1520
|
-
top: 100,
|
|
1521
|
-
// Throttling is the one reading here whose normal value is zero, so it
|
|
1522
|
-
// is the one that does not need a full-height box to be read. Said by the
|
|
1523
|
-
// series rather than inferred from "has no bands", which was the first
|
|
1524
|
-
// rule and was wrong: CPU has no bands DELIBERATELY and uses the whole
|
|
1525
|
-
// scale, so it was getting the short box for a reason that is not true
|
|
1526
|
-
// of it.
|
|
1527
|
-
restsAtZero: label === THROTTLE_LABEL,
|
|
1528
|
-
warnAt: label === THROTTLE_LABEL ? null : (bands.get(label)?.warnAt ?? WARN_C),
|
|
1529
|
-
critAt: label === THROTTLE_LABEL ? null : (bands.get(label)?.critAt ?? CRIT_C),
|
|
1530
|
-
points: at(`thermal:${label}`),
|
|
1531
|
-
}));
|
|
1532
|
-
}
|
|
1533
|
-
|
|
1534
|
-
if (group === "cores") {
|
|
1535
|
-
// Not one line per core: twelve lines in a 620px dialog is a picture nobody
|
|
1536
|
-
// can read. These two answer what the columns cannot answer over time —
|
|
1537
|
-
// "all cores" at 20 with "busiest" at 100 is ONE core pinned, which is a
|
|
1538
|
-
// different machine from twelve at 20.
|
|
1539
|
-
//
|
|
1540
|
-
// No bands, deliberately, and the reason is written at the top of
|
|
1541
|
-
// MachinePanel: a CPU at 90% is the machine doing the work you asked for. An
|
|
1542
|
-
// indicator that alarms during the normal case teaches you to stop reading
|
|
1543
|
-
// it.
|
|
1544
|
-
return [
|
|
1545
|
-
{ key: "cpu:all", label: "All cores", unit: "%", top: 100, warnAt: null, critAt: null, points: at("cpu:all"), restsAtZero: false },
|
|
1546
|
-
{ key: "cpu:busiest", label: "Busiest core", unit: "%", top: 100, warnAt: null, critAt: null, points: at("cpu:busiest"), restsAtZero: false },
|
|
1547
|
-
].filter(s => s.points.length);
|
|
1548
|
-
}
|
|
1549
|
-
|
|
1550
|
-
if (group === "memory") {
|
|
1551
|
-
const swapLabel = process.platform === "win32" ? "Commit" : "Swap";
|
|
1552
|
-
return [
|
|
1553
|
-
// One band, not two. A `critAt` of 100 draws a rule along the top of a
|
|
1554
|
-
// chart whose scale ends at 100 — it is the ceiling, drawn again in red,
|
|
1555
|
-
// and it says nothing the edge did not.
|
|
1556
|
-
{ key: "mem:physical", label: "Physical", unit: "%", top: 100, warnAt: 90, critAt: null, restsAtZero: false, points: at("mem:physical") },
|
|
1557
|
-
{ key: "mem:swap", label: swapLabel, unit: "%", top: 100, warnAt: 90, critAt: null, restsAtZero: false, points: at("mem:swap") },
|
|
1558
|
-
].filter(s => s.points.length);
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
if (group === "load") {
|
|
1562
|
-
// One series, not three. 1m, 5m and 15m are three views of one number —
|
|
1563
|
-
// the longer two are the short one smoothed — so charting the 1m over an
|
|
1564
|
-
// hour says everything the other two would, at the resolution they hide.
|
|
1565
|
-
const points = at("load:1m");
|
|
1566
|
-
if (!points.length) return [];
|
|
1567
|
-
return [{
|
|
1568
|
-
key: "load:1m",
|
|
1569
|
-
label: "Queued work",
|
|
1570
|
-
unit: "",
|
|
1571
|
-
top: loadTop(points, coreCount),
|
|
1572
|
-
// Where the queue exceeds the cores there are to run it, which is the one
|
|
1573
|
-
// number the section's own note already draws the line at.
|
|
1574
|
-
warnAt: coreCount,
|
|
1575
|
-
critAt: null,
|
|
1576
|
-
restsAtZero: false,
|
|
1577
|
-
points,
|
|
1578
|
-
}];
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
return [];
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
/**
|
|
1585
|
-
* Whether this machine has been held back AT ALL since the deck started, and
|
|
1586
|
-
* when it last was.
|
|
1587
|
-
*
|
|
1588
|
-
* The row reports the current sample, and on a desktop that current sample is
|
|
1589
|
-
* `0%` essentially always — measured here: ninety seconds of AES-NI on twelve
|
|
1590
|
-
* cores never moved `CPU_Speed_Limit` off 100. Which is the truth, and which
|
|
1591
|
-
* reads as "this readout does not work" the second time somebody looks at it.
|
|
1592
|
-
* It was reported that way twice.
|
|
1593
|
-
*
|
|
1594
|
-
* So the note under the row gets to say the other thing. Nothing new is
|
|
1595
|
-
* sampled for it: the minute buckets already hold the peak of every minute, and
|
|
1596
|
-
* this is a scan of what is already there. A machine that has never been
|
|
1597
|
-
* throttled says so; one that was at lunchtime says when.
|
|
1598
|
-
*/
|
|
1599
|
-
function heldBackSoFar() {
|
|
1600
|
-
const key = `thermal:${THROTTLE_LABEL}`;
|
|
1601
|
-
let peak = 0;
|
|
1602
|
-
let lastMs = 0;
|
|
1603
|
-
for (const b of history) {
|
|
1604
|
-
const v = b.v[key];
|
|
1605
|
-
if (v == null || v <= 0) continue;
|
|
1606
|
-
if (v > peak) peak = v;
|
|
1607
|
-
lastMs = b.m * BUCKET_MS;
|
|
1608
|
-
}
|
|
1609
|
-
return peak > 0 ? { peak, lastMs } : null;
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
/** What /api/system/history answers, for one section. */
|
|
1613
|
-
export function historySnapshot(group) {
|
|
1614
|
-
return { ok: true, sinceMs: historySince, stepMs: BUCKET_MS, series: seriesFor(group) };
|
|
1615
|
-
}
|
|
1616
|
-
|
|
1617
|
-
/**
|
|
1618
|
-
* One reading at a time, and a machine that cannot answer is asked three times
|
|
1619
|
-
* rather than for the life of the process.
|
|
1620
|
-
*
|
|
1621
|
-
* `deps.read` is a seam rather than a convenience: the rule this function
|
|
1622
|
-
* exists for only fires on a machine that answers with nothing, and the machine
|
|
1623
|
-
* this was written on answers with something, so there is no other way to run
|
|
1624
|
-
* the branch that matters.
|
|
1625
|
-
*/
|
|
1626
|
-
export async function sampleThermal(deps = {}) {
|
|
1627
|
-
if (thermalInFlight) return;
|
|
1628
|
-
// Giving up is only ever for a machine that has NEVER answered — the Windows
|
|
1629
|
-
// desktop with no MSAcpi class, which would otherwise pay a PowerShell child
|
|
1630
|
-
// every ten seconds for the life of the process. A machine that answered once
|
|
1631
|
-
// has a sensor, and it keeps being asked however long the silence runs.
|
|
1632
|
-
if (!thermalEverAnswered && thermalMisses >= THERMAL_GIVE_UP) return;
|
|
1633
|
-
const read = deps.read ?? readThermal;
|
|
1634
|
-
thermalInFlight = true;
|
|
1635
|
-
try {
|
|
1636
|
-
const next = await read();
|
|
1637
|
-
if (next) { thermal = next; thermalMisses = 0; thermalEverAnswered = true; recordThermal(next); }
|
|
1638
|
-
else if (++thermalMisses >= THERMAL_GIVE_UP) {
|
|
1639
|
-
// DROP THE LAST READING. It used to be kept, and that is a number from
|
|
1640
|
-
// four minutes ago printed as though it were now — the one thing this
|
|
1641
|
-
// whole section refuses. A GPU driver unloads, a laptop is docked, a
|
|
1642
|
-
// sensor goes away: the honest answer is that the section stops being
|
|
1643
|
-
// drawn, not that it freezes.
|
|
1644
|
-
thermal = null;
|
|
1645
|
-
// But keep ASKING on a machine that has answered before. The cost
|
|
1646
|
-
// argument for giving up was only ever about a machine that can never
|
|
1647
|
-
// answer — a Windows desktop with no MSAcpi class paying a PowerShell
|
|
1648
|
-
// child every ten seconds forever. One that answered has a sensor, and a
|
|
1649
|
-
// silence is a gap rather than an absence.
|
|
1650
|
-
if (!thermalEverAnswered && thermalTimer) {
|
|
1651
|
-
clearInterval(thermalTimer);
|
|
1652
|
-
thermalTimer = null;
|
|
1653
|
-
}
|
|
1654
|
-
// The one machine where "nothing" is worth doing something about: an
|
|
1655
|
-
// Apple Silicon Mac has sensors and no way to read them, and the tool
|
|
1656
|
-
// that can is a 746 KB signed binary this deck can fetch. Started HERE
|
|
1657
|
-
// rather than at boot on purpose — the boot was just taught not to wait
|
|
1658
|
-
// for an install (#742) and nothing waits for this one either. One
|
|
1659
|
-
// attempt per process, and only after the give-up, so a machine that
|
|
1660
|
-
// does have a sensor never downloads anything. See macmon.mjs.
|
|
1661
|
-
if (!thermalEverAnswered && process.platform === "darwin") fetchMacmon(deps);
|
|
1662
|
-
}
|
|
1663
|
-
} catch { thermalMisses++; }
|
|
1664
|
-
finally { thermalInFlight = false; }
|
|
1665
|
-
}
|
|
1666
|
-
|
|
1667
|
-
/**
|
|
1668
|
-
* Fetch macmon, then ask again — floating, on purpose.
|
|
1669
|
-
*
|
|
1670
|
-
* Not awaited by sampleThermal, which is itself not awaited by anything: this
|
|
1671
|
-
* is a download that may take a minute on a slow line, and the panel it serves
|
|
1672
|
-
* is optional. When it lands, the give-up above has already stopped the timer,
|
|
1673
|
-
* so the retry has to be made here rather than waited for.
|
|
1674
|
-
*/
|
|
1675
|
-
function fetchMacmon(deps = {}) {
|
|
1676
|
-
const boot = deps.bootstrap ?? (async () => (await import("./macmon.mjs")).bootstrapMacmon());
|
|
1677
|
-
Promise.resolve(boot()).then(r => {
|
|
1678
|
-
if (!r?.ok) return;
|
|
1679
|
-
// A sensor exists after all. Clear the give-up and let the timer run again,
|
|
1680
|
-
// which is what turns a downloaded binary into a section on screen without
|
|
1681
|
-
// the user restarting anything.
|
|
1682
|
-
thermalMisses = 0;
|
|
1683
|
-
if (!thermalTimer) {
|
|
1684
|
-
thermalTimer = setInterval(() => { sampleThermal(deps); }, THERMAL_INTERVAL_MS);
|
|
1685
|
-
// Unref'd like the one startSystemMetrics creates: a poll for an optional
|
|
1686
|
-
// panel must not be the reason a process refuses to exit.
|
|
1687
|
-
thermalTimer.unref?.();
|
|
1688
|
-
}
|
|
1689
|
-
sampleThermal(deps);
|
|
1690
|
-
}).catch(() => {});
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
async function sampleMemory() {
|
|
1694
|
-
if (memInFlight) return;
|
|
1695
|
-
memInFlight = true;
|
|
1696
|
-
try {
|
|
1697
|
-
const total = os.totalmem();
|
|
1698
|
-
const available = await readAvailable();
|
|
1699
|
-
// A poll that could not measure leaves the last reading standing and puts
|
|
1700
|
-
// nothing in the history (#789). Recording a guess here is worse than
|
|
1701
|
-
// recording nothing twice over: the meter would go red for 30 seconds, and
|
|
1702
|
-
// the bucket's Math.max would keep that peak on the chart for a day.
|
|
1703
|
-
// Swap below is a separate measurement and is still taken.
|
|
1704
|
-
if (available != null) {
|
|
1705
|
-
memory = {
|
|
1706
|
-
total,
|
|
1707
|
-
available,
|
|
1708
|
-
usedPct: Math.max(0, Math.min(100, Math.round(((total - available) / total) * 1000) / 10)),
|
|
1709
|
-
};
|
|
1710
|
-
record("mem:physical", memory.usedPct);
|
|
1711
|
-
}
|
|
1712
|
-
// Same 30s cadence as memory, and for the same reason: it moves in minutes
|
|
1713
|
-
// and costs a subprocess on two of the three platforms.
|
|
1714
|
-
swap = await readSwap();
|
|
1715
|
-
if (swap && swap.total > 0) record("mem:swap", Math.round((swap.used / swap.total) * 1000) / 10);
|
|
1716
|
-
} catch { /* keep the previous reading rather than blanking the meter */ }
|
|
1717
|
-
finally { memInFlight = false; }
|
|
1718
|
-
}
|
|
1719
|
-
|
|
1720
|
-
function sampleCpu() {
|
|
1721
|
-
const per = corePercents();
|
|
1722
|
-
const pct = cpuPercent();
|
|
1723
|
-
if (pct == null) return;
|
|
1724
|
-
cores = per;
|
|
1725
|
-
cpuHistory.push(pct);
|
|
1726
|
-
while (cpuHistory.length > HISTORY) cpuHistory.shift();
|
|
1727
|
-
record("cpu:all", pct);
|
|
1728
|
-
if (per?.length) record("cpu:busiest", Math.max(...per));
|
|
1729
|
-
// Free — os.loadavg() reads a kernel value, no syscall worth the name — so it
|
|
1730
|
-
// rides the CPU tick rather than earning a timer. Windows returns [0,0,0],
|
|
1731
|
-
// which is not a reading and is not recorded as one.
|
|
1732
|
-
const load = os.loadavg();
|
|
1733
|
-
if (process.platform !== "win32" && load.some(n => n > 0)) record("load:1m", Math.round(load[0] * 100) / 100);
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
/**
|
|
1737
|
-
* Begin sampling. Idempotent, and both timers are unref'd so this can never be
|
|
1738
|
-
* the reason the process stays alive.
|
|
1739
|
-
*/
|
|
1740
|
-
export function startSystemMetrics() {
|
|
1741
|
-
if (cpuTimer) return;
|
|
1742
|
-
prevTicks = readTicks(); // baseline, so the first tick has a delta
|
|
1743
|
-
prevCoreTicks = readCoreTicks();
|
|
1744
|
-
sampleMemory();
|
|
1745
|
-
historySince = Date.now();
|
|
1746
|
-
sampleThermal();
|
|
1747
|
-
cpuTimer = setInterval(sampleCpu, CPU_INTERVAL_MS);
|
|
1748
|
-
memTimer = setInterval(sampleMemory, MEM_INTERVAL_MS);
|
|
1749
|
-
thermalTimer = setInterval(sampleThermal, THERMAL_INTERVAL_MS);
|
|
1750
|
-
cpuTimer.unref?.();
|
|
1751
|
-
memTimer.unref?.();
|
|
1752
|
-
thermalTimer.unref?.();
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
/**
|
|
1756
|
-
* Stop the three timers and reset every reading this module holds.
|
|
1757
|
-
*
|
|
1758
|
-
* THE SUITE'S, AND SAID PLAINLY (#798). Production starts the loop once at boot
|
|
1759
|
-
* and never stops it — the process ending is what stops it — so an audit
|
|
1760
|
-
* grepping for callers finds none, and the honest answer is not to delete this
|
|
1761
|
-
* but to name what it is for. It is a RESET as much as a stop: `history`,
|
|
1762
|
-
* `thermal`, the CPU baselines and the miss counters all go back to their
|
|
1763
|
-
* initial values, which is exactly what a case needs between two runs of
|
|
1764
|
-
* `startSystemMetrics` in one process, and what nothing else in this module
|
|
1765
|
-
* offers. Deleting it would leave the suite leaking intervals into the values
|
|
1766
|
-
* the next case reads.
|
|
1767
|
-
*
|
|
1768
|
-
* That is also why it is safe as a test-only export where the four removed in
|
|
1769
|
-
* #798 were not: there is no shipped counterpart for it to drift away from. The
|
|
1770
|
-
* state it clears IS the state every other assertion here reads.
|
|
1771
|
-
*/
|
|
1772
|
-
export function stopSystemMetrics() {
|
|
1773
|
-
if (cpuTimer) clearInterval(cpuTimer);
|
|
1774
|
-
if (memTimer) clearInterval(memTimer);
|
|
1775
|
-
if (thermalTimer) clearInterval(thermalTimer);
|
|
1776
|
-
cpuTimer = memTimer = thermalTimer = null;
|
|
1777
|
-
thermal = null;
|
|
1778
|
-
thermalMisses = 0;
|
|
1779
|
-
thermalEverAnswered = false;
|
|
1780
|
-
history.length = 0;
|
|
1781
|
-
historySince = 0;
|
|
1782
|
-
prevTicks = null;
|
|
1783
|
-
prevCoreTicks = null;
|
|
1784
|
-
cpuHistory.length = 0;
|
|
1785
|
-
memory = null;
|
|
1786
|
-
cores = null;
|
|
1787
|
-
swap = null;
|
|
1788
|
-
prevProcCpu = null;
|
|
1789
|
-
prevProcAt = 0;
|
|
1790
|
-
// The last list goes with the baseline it was computed against. A sampler
|
|
1791
|
-
// that stopped and started again must not answer the first caller with a
|
|
1792
|
-
// reading from before the stop.
|
|
1793
|
-
procLast = null;
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
/**
|
|
1797
|
-
* What /api/system answers.
|
|
1798
|
-
*
|
|
1799
|
-
* `cpu` is null until two samples exist — the meter draws its track and no fill
|
|
1800
|
-
* rather than printing a zero it has not measured. `loadavg` is omitted on
|
|
1801
|
-
* Windows, where the API returns [0, 0, 0]: three zeros are not a reading, and
|
|
1802
|
-
* showing them as one would be the same lie in a different place.
|
|
1803
|
-
*/
|
|
1804
|
-
export function systemSnapshot() {
|
|
1805
|
-
const cpu = cpuHistory.length ? cpuHistory[cpuHistory.length - 1] : null;
|
|
1806
|
-
const load = os.loadavg();
|
|
1807
|
-
const hasLoad = process.platform !== "win32" && load.some(n => n > 0);
|
|
1808
|
-
return {
|
|
1809
|
-
ok: true,
|
|
1810
|
-
cpu,
|
|
1811
|
-
cpuHistory: [...cpuHistory],
|
|
1812
|
-
cores: os.cpus().length,
|
|
1813
|
-
memory,
|
|
1814
|
-
swap,
|
|
1815
|
-
perCore: cores,
|
|
1816
|
-
// Null on a machine that publishes nothing, and the panel draws no section
|
|
1817
|
-
// at all for it rather than an empty one.
|
|
1818
|
-
thermal: thermal ? { ...thermal, heldBack: heldBackSoFar() } : null,
|
|
1819
|
-
uptimeSec: Math.round(os.uptime()),
|
|
1820
|
-
platform: process.platform,
|
|
1821
|
-
loadavg: hasLoad ? load.map(n => Math.round(n * 100) / 100) : null,
|
|
1822
|
-
intervalMs: CPU_INTERVAL_MS,
|
|
1823
|
-
sampledAt: Date.now(),
|
|
1824
|
-
};
|
|
1825
|
-
}
|