ccdeck 3.37.1 → 3.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,8 +5,9 @@
5
5
  // decides how often the machine is asked and when one that has never answered
6
6
  // stops being asked (sampleThermal). This file is the asking: readThermal
7
7
  // answers once per call, per platform, and nothing here keeps state between two
8
- // calls. WARN_C and CRIT_C are exported because the history's thermal series
9
- // fall back to them.
8
+ // calls — the one reading that needs the call before it, Linux throttling, is
9
+ // handed that call's answer back by the sampler. WARN_C and CRIT_C are exported
10
+ // because the history's thermal series fall back to them.
10
11
  import { readdir, readFile } from "node:fs/promises";
11
12
  import { run } from "./metrics-run.mjs";
12
13
 
@@ -31,6 +32,17 @@ import { run } from "./metrics-run.mjs";
31
32
  // bands are the hardware's rather than ones invented here.
32
33
  // /sys/class/thermal/thermal_zone*/ is the coarser fallback.
33
34
  //
35
+ // Throttling comes from the kernel's own count of it, and for years
36
+ // it was not read at all: this branch answered `throttle: null`
37
+ // unconditionally, so a Linux machine never drew the row however
38
+ // hard it was being held back. Measured on an i5-12600H mini PC
39
+ // whose package sat at 90°C under a load of 41 — the panel showed
40
+ // the 90 and nothing else, while the kernel had logged 59 hours of
41
+ // package throttling in 17 days of uptime and was adding six seconds
42
+ // of it every ten. See readThrottleTime: Intel only, since the
43
+ // counters come from Intel's thermal-interrupt driver, and no row
44
+ // on a machine without them.
45
+ //
34
46
  // macOS No CPU degrees without root, verified: `powermetrics --samplers
35
47
  // smc` answers "powermetrics must be invoked as the superuser", and
36
48
  // `ioreg -c AppleSMC -r -d 1` publishes no temperature key at all to
@@ -260,6 +272,91 @@ export async function readThermalZones(root = "/sys/class/thermal", deps = {}) {
260
272
  return [known ?? found.reduce((a, b) => (b.celsius > a.celsius ? b : a))];
261
273
  }
262
274
 
275
+ /**
276
+ * How long each CPU package has spent thermally throttled since boot, in
277
+ * milliseconds, keyed by package — or null on a machine that does not count it.
278
+ *
279
+ * Intel's thermal-interrupt driver publishes, per CPU,
280
+ * `cpuN/thermal_throttle/package_throttle_total_time_ms`: the time the
281
+ * package's thermal monitor has held the clock down, summed over every event.
282
+ * Measured on a live machine, ten seconds apart:
283
+ *
284
+ * cpu0 package_throttle_total_time_ms 212885052 → 212890804
285
+ * cpu15 package_throttle_total_time_ms 212885101 → 212891179
286
+ *
287
+ * Every CPU of a package carries its own copy of the package's count, and the
288
+ * copies differ by the few events each one saw late — so the package's figure
289
+ * is the largest of them, which is also the one that survives a CPU going
290
+ * offline. The `core_*` files beside them are one core's own throttling and are
291
+ * not read: the CPU row is the package, and a throttle row about one core of it
292
+ * would be a different reading under the same label.
293
+ *
294
+ * Absent on AMD and on every virtual machine, and on a kernel older than 5.12,
295
+ * which counts events but not their time. A count of events is not a share of
296
+ * anything, so those machines draw no row rather than a guess.
297
+ */
298
+ export async function readThrottleTime(root = "/sys/devices/system/cpu", deps = {}) {
299
+ const dir = deps.readdir ?? readdir;
300
+ const file = deps.readFile ?? readFile;
301
+ const read = async path => { try { return String(await file(path, "utf8")).trim(); } catch { return null; } };
302
+ let cpus;
303
+ try { cpus = (await dir(root)).filter(n => /^cpu\d+$/.test(n)); } catch { return null; }
304
+ const held = {};
305
+ for (const cpu of cpus) {
306
+ const ms = Number(await read(`${root}/${cpu}/thermal_throttle/package_throttle_total_time_ms`) ?? NaN);
307
+ if (!Number.isFinite(ms) || ms < 0) continue;
308
+ const pkg = (await read(`${root}/${cpu}/topology/physical_package_id`)) ?? "0";
309
+ if (held[pkg] == null || ms > held[pkg]) held[pkg] = ms;
310
+ }
311
+ return Object.keys(held).length ? held : null;
312
+ }
313
+
314
+ /**
315
+ * The share of the time between two counts that the CPU spent throttled, as
316
+ * `{ timeHeld }`, or null when there are not two counts to compare.
317
+ *
318
+ * A SHARE OF TIME, NOT OF SPEED, and the shape says so rather than borrowing
319
+ * macOS's `speedLimit`. `pmset` reports how much of the clock the thermal
320
+ * manager allows; the kernel reports how long the clock was held down, and by
321
+ * how much it was held is not in the file. Writing 60% of the time as "held to
322
+ * 40% of full speed" would be a number this module made up.
323
+ *
324
+ * The worst package wins, for the reason the hottest sensor does: the question
325
+ * is whether this machine is being held back, and one package that is answers
326
+ * it. Any throttling at all is at least 1%, because a few milliseconds of it
327
+ * round to zero and the panel paints zero as "never held back".
328
+ *
329
+ * The kernel adds an event's time when the event ENDS, so one still running at
330
+ * a sample lands in the next. Events here run 5ms on average and 119ms at the
331
+ * longest, measured, so that is a sample's worth of lag at most — and the 100
332
+ * cap is for the one long event that lands whole in a single sample.
333
+ */
334
+ export function throttleFromTime(prev, next) {
335
+ if (!prev || !next) return null;
336
+ const elapsed = next.atMs - prev.atMs;
337
+ if (!(elapsed > 0)) return null;
338
+ let worst = null;
339
+ for (const [pkg, ms] of Object.entries(next.held)) {
340
+ const before = prev.held[pkg];
341
+ if (before == null) continue;
342
+ const share = (Math.max(0, ms - before) / elapsed) * 100;
343
+ if (worst == null || share > worst) worst = share;
344
+ }
345
+ if (worst == null) return null;
346
+ return { timeHeld: worst > 0 ? Math.min(100, Math.max(1, Math.round(worst))) : 0 };
347
+ }
348
+
349
+ /**
350
+ * The share of the CPU the thermal manager took away, whichever way this
351
+ * platform measures it: the speed `pmset` says is no longer allowed, or the
352
+ * time the kernel says the clock was held down. The one number the chart, the
353
+ * strip and the row's bar all draw — mirrored by `heldShare` in machine-live.ts.
354
+ */
355
+ export function heldShare(throttle) {
356
+ const held = "timeHeld" in throttle ? throttle.timeHeld : 100 - throttle.speedLimit;
357
+ return Math.max(0, Math.min(100, held));
358
+ }
359
+
263
360
  /**
264
361
  * GPU degrees out of `ioreg -r -k PerformanceStatistics`.
265
362
  *
@@ -458,13 +555,25 @@ export function darwinThermal({ gpuC = null, throttle = null, macmon = {} } = {}
458
555
  * collapsing them would let a throttle percentage be drawn under a °C heading
459
556
  * — the thing the label rule exists to prevent. `swapLabel` earned that rule
460
557
  * once already.
558
+ *
559
+ * `since` is the reading before this one. Only Linux uses it: its throttle is a
560
+ * difference between two counts, so the count rides along on the reading as
561
+ * `throttleCount` and the sampler hands it back on the next call. The first
562
+ * reading after a start therefore has no throttle yet, the same way `cpu` is
563
+ * null until two samples exist.
461
564
  */
462
- export async function readThermal(platform = process.platform) {
565
+ export async function readThermal(platform = process.platform, since = null) {
463
566
  if (platform === "linux") {
464
567
  const sensors = await readHwmon();
465
568
  const celsius = pickThermalRows(sensors);
466
569
  const rows = celsius.length ? celsius : await readThermalZones();
467
- return rows.length ? { celsius: rows, throttle: null } : null;
570
+ const held = await readThrottleTime();
571
+ // performance.now rather than Date.now: a clock that NTP steps backwards
572
+ // between two samples would otherwise divide by a negative ten seconds.
573
+ const throttleCount = held ? { atMs: performance.now(), held } : null;
574
+ const throttle = throttleFromTime(since?.throttleCount, throttleCount);
575
+ if (!rows.length && !throttle) return null;
576
+ return { celsius: rows, throttle, ...(throttleCount ? { throttleCount } : {}) };
468
577
  }
469
578
 
470
579
  if (platform === "darwin") {
@@ -10,7 +10,7 @@
10
10
  // thermalSnapshot on /api/system, and re-exports sampleThermal and
11
11
  // THROTTLE_LABEL, which is where the suite reaches them.
12
12
  import { pointsOf, record } from "./metrics-history.mjs";
13
- import { readThermal } from "./thermal-metrics.mjs";
13
+ import { heldShare, readThermal } from "./thermal-metrics.mjs";
14
14
 
15
15
  // ---------------------------------------------------------------------------
16
16
  // Thermal: how often this machine is asked whether it is getting hot, and when
@@ -60,7 +60,7 @@ function recordThermal(reading, nowMs = Date.now()) {
60
60
  // Stored as the share TAKEN AWAY, the same way the panel draws it, so the
61
61
  // chart and the row cannot disagree about which direction is bad.
62
62
  if (reading.throttle) {
63
- record(`thermal:${THROTTLE_LABEL}`, Math.max(0, 100 - reading.throttle.speedLimit), nowMs);
63
+ record(`thermal:${THROTTLE_LABEL}`, heldShare(reading.throttle), nowMs);
64
64
  }
65
65
  }
66
66
 
@@ -114,7 +114,9 @@ export async function sampleThermal(deps = {}) {
114
114
  const read = deps.read ?? readThermal;
115
115
  thermalInFlight = true;
116
116
  try {
117
- const next = await read();
117
+ // The last reading goes back in because Linux throttling is the difference
118
+ // between its count and this one's. See readThermal.
119
+ const next = await read(process.platform, thermal);
118
120
  if (next) { thermal = next; thermalMisses = 0; thermalEverAnswered = true; recordThermal(next); }
119
121
  else if (++thermalMisses >= THERMAL_GIVE_UP) {
120
122
  // DROP THE LAST READING. It used to be kept, and that is a number from
@@ -181,7 +183,11 @@ export function lastThermal() {
181
183
  /** The thermal row /api/system answers: the last reading, with whether the
182
184
  * machine has been held back since the deck started, or null without one. */
183
185
  export function thermalSnapshot() {
184
- return thermal ? { ...thermal, heldBack: heldBackSoFar() } : null;
186
+ if (!thermal) return null;
187
+ // The raw kernel count is the sampler's, for the next difference, and not a
188
+ // reading: the route answers what the panel draws.
189
+ const { throttleCount, ...shown } = thermal;
190
+ return { ...shown, heldBack: heldBackSoFar() };
185
191
  }
186
192
 
187
193
  /** The ten-second timer, made where startSystemMetrics in system-metrics.mjs