@torrent-tv/proxy 2.98.15 → 2.98.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.98.15",
3
+ "version": "2.98.16",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -92,6 +92,34 @@ export async function readProcessCpuSeconds(pid) {
92
92
  }
93
93
  }
94
94
 
95
+ /**
96
+ * The CPU time every child of this process that has ended has used, from the
97
+ * `cutime` and `cstime` of `/proc/self/stat`: the kernel adds a child's time
98
+ * there when the child is waited for, which Node does as soon as it exits.
99
+ *
100
+ * What a process that started and ended between two readings of its own pid
101
+ * cost, which no reading of that pid can say: on Home Assistant a run of one
102
+ * piece started and ended between two readings 5 s apart, used 6.65 s of CPU,
103
+ * and both readings said `ffmpeg=0%` (r41, torrent-tv/meta#178).
104
+ *
105
+ * @returns {Promise<number | null>} Seconds of CPU, or null where there is no `/proc`.
106
+ */
107
+ export async function readEndedChildrenCpuSeconds() {
108
+ try {
109
+ const text = await readFile("/proc/self/stat", "utf8");
110
+ const afterComm = text.slice(text.lastIndexOf(")") + 2).trim().split(/\s+/);
111
+ // After the comm and state fields, cutime is index 13 and cstime index 14.
112
+ const cutime = Number(afterComm[13]);
113
+ const cstime = Number(afterComm[14]);
114
+ if (!Number.isFinite(cutime) || !Number.isFinite(cstime)) {
115
+ return null;
116
+ }
117
+ return (cutime + cstime) / CLOCK_TICKS_PER_SECOND;
118
+ } catch {
119
+ return null; // not Linux, or /proc is not mounted
120
+ }
121
+ }
122
+
95
123
  /**
96
124
  * Is this still the machine the benchmarks were taken on: its clock and its
97
125
  * temperature.
@@ -11,6 +11,7 @@ import os from "node:os";
11
11
  import { monitorEventLoopDelay } from "node:perf_hooks";
12
12
  import { logger } from "../../../utils/logger.js";
13
13
  import { availableShareFrom } from "../available-share.js";
14
+ import { encoderCpuBetween } from "./encoder-cpu.js";
14
15
  import { baseDrawFrom, costPerMegabyteFrom } from "../torrent-cost.js";
15
16
  import { medianOf, movedBeyondScatter, READINGS_KEPT, scatterOf } from "../learned-median.js";
16
17
  import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode-run-state.js";
@@ -532,17 +533,23 @@ export class HostLoad {
532
533
  * the only thing that would have taken the next reading.
533
534
  */
534
535
  async #learnIdleAvailability(watched, suspended) {
535
- const system = (await this.#host.readSystemCpu?.()) ?? null;
536
- const sample = { takenAt: Date.now(), system, proxyCpuSeconds: this.#host.readProxyCpuSeconds() };
536
+ const [system, endedChildren] = await Promise.all([
537
+ this.#host.readSystemCpu?.() ?? null,
538
+ this.#host.readEndedChildrenCpuSeconds?.() ?? null
539
+ ]);
540
+ const sample = { takenAt: Date.now(), system, endedChildren, byPid: new Map(), proxyCpuSeconds: this.#host.readProxyCpuSeconds() };
537
541
  const previous = this.#idleAvailabilitySample;
538
542
  this.#idleAvailabilitySample = sample;
539
543
  if (previous === null) {
540
544
  this.hostAvailability = availableShareFrom({});
541
545
  return;
542
546
  }
547
+ // Nothing runs at either reading, and a run may still have run between
548
+ // them: what it used is in the ended-children time (`encoder-cpu.js`).
549
+ const encoders = encoderCpuBetween(previous, sample);
543
550
  const share = this.#host.shareOfMachine(
544
551
  { takenAt: previous.takenAt, processCpuSeconds: 0, system: previous.system },
545
- { takenAt: sample.takenAt, processCpuSeconds: 0, system: sample.system }
552
+ { takenAt: sample.takenAt, processCpuSeconds: encoders?.seconds ?? 0, system: sample.system }
546
553
  );
547
554
  const proxyShare = this.#setAvailability(share, previous, sample);
548
555
  // Said while something is watched, so what is free while every encoder
@@ -559,7 +566,7 @@ export class HostLoad {
559
566
  * and the processor share divide; `free` is what nothing used over the
560
567
  * interval. Unknown stays `n/a`, never zero.
561
568
  */
562
- async #sayMachine(share, proxyShare, running, suspended) {
569
+ async #sayMachine(share, proxyShare, running, suspended, incomplete = false) {
563
570
  const machine = await this.#host.readMachineState();
564
571
  // HOW LATE THIS THREAD'S LOOP RAN over the interval: every answer to FFmpeg
565
572
  // and every hand-over of a copy waits for it (torrent-tv/meta#178). Begun at
@@ -574,7 +581,8 @@ export class HostLoad {
574
581
  const asPercent = (value) => (value === null || value === undefined ? "n/a" : `${Math.round(value * 100)}%`);
575
582
  const free = Number.isFinite(share.systemShare) ? Math.max(0, 1 - share.systemShare) : null;
576
583
  logger.info(
577
- `host-load: ffmpeg=${asPercent(share.processShare)} proxy=${asPercent(proxyShare)} ` +
584
+ `host-load: ffmpeg=${incomplete ? `at least ${asPercent(share.processShare)} (a run was not read; availability kept)` : asPercent(share.processShare)} ` +
585
+ `proxy=${asPercent(proxyShare)} ` +
578
586
  `system=${asPercent(share.systemShare)} ` +
579
587
  `iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
580
588
  `temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
@@ -646,22 +654,28 @@ export class HostLoad {
646
654
  // alone would print something like `ffmpeg=-598%`. Only pids present in
647
655
  // BOTH readings are counted, so a process that came or went contributes
648
656
  // nothing rather than a lie.
649
- const pids = encoding.flatMap((session) => this.#host.liveRunsOf(session).map((run) => run.process?.pid ?? null)).filter((pid) => pid !== null);
650
- const [system, ...cpuReadings] = await Promise.all([
657
+ const runs = encoding.flatMap((session) => this.#host.liveRunsOf(session)).filter((run) => Number.isInteger(run.process?.pid));
658
+ const [system, endedChildren, ...cpuReadings] = await Promise.all([
651
659
  this.#host.readSystemCpu(),
652
- ...pids.map((pid) => this.#host.readProcessCpuSeconds(pid))
660
+ this.#host.readEndedChildrenCpuSeconds?.() ?? null,
661
+ ...runs.map((run) => this.#host.readProcessCpuSeconds(run.process.pid))
653
662
  ]);
654
663
  /** @type {Map<number, number>} */
655
664
  const byPid = new Map();
656
- pids.forEach((pid, index) => {
665
+ /** @type {Map<number, number>} */
666
+ const startedAt = new Map();
667
+ runs.forEach((run, index) => {
657
668
  const seconds = cpuReadings[index];
658
669
  if (seconds !== null) {
659
- byPid.set(pid, seconds);
670
+ byPid.set(run.process.pid, seconds);
671
+ startedAt.set(run.process.pid, run.startedAt);
660
672
  }
661
673
  });
662
674
  const sample = {
663
675
  takenAt: Date.now(),
664
676
  byPid,
677
+ startedAt,
678
+ endedChildren,
665
679
  system,
666
680
  // The proxy's own CPU, across every thread: the torrent, the hashing, the
667
681
  // piece store and the delivery. None of it is in the encode budget, and
@@ -674,20 +688,14 @@ export class HostLoad {
674
688
  if (previous === null) {
675
689
  return false; // the first reading is only something to compare against
676
690
  }
677
- // Summed over the pids both readings hold, so nothing is measured against a
678
- // process that was not there before. Unknown stays unknown: on a host with
679
- // no `/proc` there are no readings at all, and the share is null rather
680
- // than a confident zero.
681
- let encoderDelta = null;
682
- for (const [pid, seconds] of sample.byPid) {
683
- const before = previous.byPid?.get(pid);
684
- if (before !== undefined && seconds >= before) {
685
- encoderDelta = (encoderDelta ?? 0) + (seconds - before);
686
- }
687
- }
691
+ // Every process counted once, including runs that started or ended between
692
+ // the readings (`encoder-cpu.js`). Unknown stays unknown: on a host with no
693
+ // `/proc` there are no readings at all, and the share is null rather than a
694
+ // confident zero.
695
+ const encoders = encoderCpuBetween(previous, sample);
688
696
  const share = this.#host.shareOfMachine(
689
- { takenAt: previous.takenAt, processCpuSeconds: encoderDelta === null ? null : 0, system: previous.system },
690
- { takenAt: sample.takenAt, processCpuSeconds: encoderDelta, system: sample.system }
697
+ { takenAt: previous.takenAt, processCpuSeconds: encoders === null ? null : 0, system: previous.system },
698
+ { takenAt: sample.takenAt, processCpuSeconds: encoders?.seconds ?? null, system: sample.system }
691
699
  );
692
700
  if (share === null) {
693
701
  this.hostAvailability = availableShareFrom({});
@@ -701,6 +709,12 @@ export class HostLoad {
701
709
  // they stopped).
702
710
  const suspended = encoding.filter((session) => this.#host.runStateOf(session) === ENCODE_RUN_STATE.SUSPENDED).length;
703
711
  const running = encoding.length - suspended;
712
+ if (encoders && !encoders.complete) {
713
+ // A lower bound of our own load is not a reading of what other work left
714
+ // us: the last availability stands, and the line says so.
715
+ await this.#sayMachine(share, null, running, suspended, true);
716
+ return false;
717
+ }
704
718
  // Kept for the quality offer, which predicts from a benchmark taken on a
705
719
  // QUIET host: the same reading that is printed here says how much of the
706
720
  // machine a new encoder could actually have. Only what nobody has been
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @file The CPU this proxy's FFmpeg processes used between two readings of the
3
+ * machine (torrent-tv/meta#178).
4
+ *
5
+ * A process's own counter says what it used since it started, and is gone once
6
+ * it has ended. Counting only processes present in both readings left out every
7
+ * run that started or ended between them: on Home Assistant a run of one piece
8
+ * started and ended between two readings 5 s apart, used 6.65 s of CPU, and
9
+ * both readings said `ffmpeg=0%` (r41). The availability left to encoders is
10
+ * the machine's load less theirs, so an encoder left out made the machine look
11
+ * busier with other work than it was.
12
+ *
13
+ * So three parts, each counted once:
14
+ * 1. a process in both readings: what its counter moved;
15
+ * 2. a process started after the first reading: its whole counter;
16
+ * 3. a process that ended between them: what the kernel added to this
17
+ * process's ended-children time (`cutime + cstime`), less what the first
18
+ * reading already counted of it.
19
+ * A part that cannot be told — a process that was there but unread, an
20
+ * ended-children time that went back, no such time at all while a process
21
+ * ended — makes the answer incomplete: a lower bound, never a measured zero.
22
+ */
23
+
24
+ /**
25
+ * @typedef {object} EncoderReading
26
+ * @property {number} takenAt - Wall clock, ms.
27
+ * @property {Map<number, number>} byPid - CPU seconds of each live FFmpeg process read.
28
+ * @property {Map<number, number>} [startedAt] - When each of them was spawned, wall clock ms.
29
+ * @property {number | null} endedChildren - This process's ended-children CPU seconds.
30
+ */
31
+
32
+ /**
33
+ * @param {EncoderReading} previous
34
+ * @param {EncoderReading} sample
35
+ * @returns {{ seconds: number, complete: boolean } | null} Null when nothing at all was read.
36
+ */
37
+ export function encoderCpuBetween(previous, sample) {
38
+ const endedKnown = Number.isFinite(previous.endedChildren) && Number.isFinite(sample.endedChildren);
39
+ if (sample.byPid.size === 0 && previous.byPid.size === 0 && !endedKnown) return null;
40
+ let seconds = 0, complete = true;
41
+ for (const [pid, now] of sample.byPid) {
42
+ const before = previous.byPid.get(pid);
43
+ if (before !== undefined) {
44
+ if (now >= before) seconds += now - before;
45
+ else complete = false;
46
+ } else if (sample.startedAt?.get(pid) > previous.takenAt) {
47
+ seconds += now;
48
+ } else {
49
+ complete = false;
50
+ }
51
+ }
52
+ let countedOfEnded = 0, ended = 0;
53
+ for (const [pid, before] of previous.byPid) {
54
+ if (sample.byPid.has(pid)) continue;
55
+ countedOfEnded += before;
56
+ ended += 1;
57
+ }
58
+ if (endedKnown) {
59
+ const rest = sample.endedChildren - previous.endedChildren - countedOfEnded;
60
+ if (rest >= 0) seconds += rest;
61
+ else complete = false;
62
+ } else if (ended > 0) {
63
+ complete = false;
64
+ }
65
+ return { seconds, complete };
66
+ }
@@ -11,7 +11,7 @@
11
11
  import { logger } from "../../utils/logger.js";
12
12
  import { KeyframeTables } from "../media/KeyframeTables.js";
13
13
  import { waits } from "./WaitLedger.js";
14
- import { readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "../encode/host-load.js";
14
+ import { readEndedChildrenCpuSeconds, readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "../encode/host-load.js";
15
15
  import { minimumBufferFrom } from "../torrent/supply-margin.js";
16
16
  import { PriorityOrchestrator } from "../viewer/PriorityOrchestrator.js";
17
17
  import { urgentOutputsReady } from "../viewer/urgent-output-ready.js";
@@ -290,6 +290,7 @@ export function wireOutputs({
290
290
  // What the torrent and the proxy itself spend on this host, and how fast each watched torrent moves.
291
291
  parts.hostLoad = new HostLoad({
292
292
  readMetadataActivity,
293
+ readEndedChildrenCpuSeconds,
293
294
  readMachineState,
294
295
  readProcessCpuSeconds,
295
296
  readProxyCpuSeconds,