@torrent-tv/proxy 2.100.15 → 2.101.1

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/bin/cli.js CHANGED
@@ -90,6 +90,7 @@ program
90
90
  .option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
91
91
  .option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
92
92
  .option("--pin-preset <name>", "Encode every software picture at this libx264 preset only, for runs that must be compared at one quality (off by default)")
93
+ .option("--input-stretch-bytes <bytes>", "Size every original-input stretch to this many bytes instead of copy rate times open, for runs that compare input sizes; memory, downloaded bytes and the plan still bound it (off by default)")
93
94
  .option("--heap-snapshots", "Write a heap snapshot of a thread whose memory reaches a new high-water; each one stops that thread while it is written (off by default)")
94
95
  .option("--profile-start <seconds>", "Profile the CPU of the main thread and the torrent thread for this many seconds from the first source opened, written beside the state (off by default)")
95
96
  .option(
@@ -399,6 +400,7 @@ try {
399
400
  profileStartSeconds: Number(options.profileStart) || 0,
400
401
  heapSnapshots: options.heapSnapshots === true,
401
402
  pinnedPreset: typeof options.pinPreset === "string" && options.pinPreset.trim() ? options.pinPreset.trim() : null,
403
+ inputStretchBytes: Number(options.inputStretchBytes) > 0 ? Math.floor(Number(options.inputStretchBytes)) : null,
402
404
  // Late-bound the same way `webRtcManager` is below: the torrent pool is
403
405
  // built inside `startProxyServer`, before `dataChannelHandler` — the
404
406
  // thing that actually owns a channel to push down — exists.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.100.15",
3
+ "version": "2.101.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -134,6 +134,9 @@ export async function startProxyServer({
134
134
  // The one software preset every picture is encoded at, for runs compared at
135
135
  // one quality; null leaves the choice to the machine.
136
136
  pinnedPreset = null,
137
+ // The size every original-input stretch is given in place of copy rate
138
+ // times open, for runs that compare input sizes; null keeps the rule.
139
+ inputStretchBytes = null,
137
140
  // What viewers' connections carry beyond the film, per byte of film; the
138
141
  // transport counts it and is built after this server, so it is late-bound.
139
142
  serviceShare = () => null,
@@ -854,6 +857,7 @@ export async function startProxyServer({
854
857
  let sourcePreparation;
855
858
  const outputParts = wireOutputs({
856
859
  pinnedPreset,
860
+ inputStretchBytes,
857
861
  indexMemory,
858
862
  serviceShare,
859
863
  readMetadataActivity: () => containerOrchestrator.activity(),
@@ -1147,10 +1151,13 @@ export async function startProxyServer({
1147
1151
  });
1148
1152
  startProcessorReport({
1149
1153
  log: (line) => logger.info(line),
1150
- ffmpegPids: () => [...outputParts.outputs.values()]
1151
- .flatMap((output) => outputParts.encodeRuns.liveRunsOf(output))
1152
- .map((run) => run.process?.pid)
1153
- .filter((pid) => Number.isInteger(pid)),
1154
+ ffmpegRuns: () => [...outputParts.outputs.values()]
1155
+ .flatMap((output) => outputParts.encodeRuns.liveRunsOf(output).map((run) => ({
1156
+ pid: run.process?.pid,
1157
+ label: `${output.outputKey} #${run.from}..#${run.to}`,
1158
+ film: Number(run.progress?.processedSeconds) - Number(run.progress?.startPositionSeconds ?? 0)
1159
+ })))
1160
+ .filter((run) => Number.isInteger(run.pid)),
1154
1161
  detailed: () => startupProfile.active()
1155
1162
  });
1156
1163
  // What the last life of this process left on the disk. The kernel kills this
@@ -62,10 +62,20 @@ export class EncodeInputs {
62
62
  * @type {(output: object, from: number, to: number, input: object) => void}
63
63
  */
64
64
  #stretchCopied;
65
+ /**
66
+ * A stretch size the operator named for comparison runs
67
+ * (`--input-stretch-bytes`), in place of copy rate times open; null keeps
68
+ * the measured rule. Only the target changes: memory, downloaded bytes and
69
+ * the plan's bound still end a stretch (torrent-tv/meta#182).
70
+ *
71
+ * @type {number | null}
72
+ */
73
+ #fixedStretchBytes;
65
74
 
66
75
  constructor({ resolve, readRanges, heldRanges = null, reviseBudget, changed, failed, capacity = () => null, urgent = () => true,
67
- rankAt = () => null, openOf = () => null, log = () => {}, now = Date.now, stretchCopied = () => {} }) {
76
+ rankAt = () => null, openOf = () => null, log = () => {}, now = Date.now, stretchCopied = () => {}, stretchBytes = null }) {
68
77
  this.#stretchCopied = stretchCopied;
78
+ this.#fixedStretchBytes = Number.isSafeInteger(stretchBytes) && stretchBytes > 0 ? stretchBytes : null;
69
79
  this.#openOf = openOf;
70
80
  this.#resolve = resolve;
71
81
  this.#read = readRanges;
@@ -344,6 +354,10 @@ export class EncodeInputs {
344
354
  #sizingFor(output) {
345
355
  const rate = middleOf(this.#copyRates);
346
356
  const open = this.#openOf(output);
357
+ if (this.#fixedStretchBytes !== null) {
358
+ return { bytes: this.#fixedStretchBytes, openMeasured: true, openSeconds: open?.seconds ?? null,
359
+ copyBytesPerMs: rate, fixed: true };
360
+ }
347
361
  return rate === null || !open ? null : { bytes: Math.floor(rate * open.seconds * 1000),
348
362
  openMeasured: open.measured, openSeconds: open.seconds, copyBytesPerMs: rate };
349
363
  }
@@ -607,7 +621,7 @@ export class EncodeInputs {
607
621
  if (result.kind === "result") {
608
622
  this.#log(`encode input selection of ${addressOf(output)}: ${JSON.stringify({
609
623
  from, requestedTo: request.to, serves, floor, targetEnd, chosenEnd: end,
610
- targetBytes: target, minimumBytes: minimum, inputBytes: result.bytes,
624
+ targetBytes: target, targetFixed: sizing?.fixed === true, minimumBytes: minimum, inputBytes: result.bytes,
611
625
  copyBytesPerMs: sizing?.copyBytesPerMs ?? null, openSeconds: sizing?.openSeconds ?? null,
612
626
  openMeasured: sizing?.openMeasured ?? null, reservations, probes
613
627
  })}`);
@@ -16,6 +16,7 @@ import { baseDrawFrom, costPerMegabyteFrom } from "../torrent-cost.js";
16
16
  import { medianOf, movedBeyondScatter, READINGS_KEPT, scatterOf } from "../learned-median.js";
17
17
  import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode-run-state.js";
18
18
  import { SourceFiles } from "../../media/SourceFile.js";
19
+ import { costKindForSession } from "./EncodeCost.js";
19
20
 
20
21
  /**
21
22
  * What the torrent thread says of itself over one interval: how late its event
@@ -537,7 +538,8 @@ export class HostLoad {
537
538
  this.#host.readSystemCpu?.() ?? null,
538
539
  this.#host.readEndedChildrenCpuSeconds?.() ?? null
539
540
  ]);
540
- const sample = { takenAt: Date.now(), system, endedChildren, byPid: new Map(), proxyCpuSeconds: this.#host.readProxyCpuSeconds() };
541
+ const sample = { takenAt: Date.now(), system, endedChildren, byPid: new Map(), proxyCpuSeconds: this.#host.readProxyCpuSeconds(),
542
+ processor: this.#host.sampleProcessor?.() ?? null };
541
543
  const previous = this.#idleAvailabilitySample;
542
544
  this.#idleAvailabilitySample = sample;
543
545
  if (previous === null) {
@@ -556,7 +558,7 @@ export class HostLoad {
556
558
  // stands is seen beside what it is free for (torrent-tv/meta#178); a proxy
557
559
  // nobody watches stays quiet.
558
560
  if (share !== null && watched) {
559
- await this.#sayMachine(share, proxyShare, 0, suspended);
561
+ await this.#sayMachine(share, proxyShare, 0, suspended, false, this.#processorLine(previous, sample, share, proxyShare));
560
562
  }
561
563
  }
562
564
 
@@ -566,7 +568,7 @@ export class HostLoad {
566
568
  * and the processor share divide; `free` is what nothing used over the
567
569
  * interval. Unknown stays `n/a`, never zero.
568
570
  */
569
- async #sayMachine(share, proxyShare, running, suspended, incomplete = false) {
571
+ async #sayMachine(share, proxyShare, running, suspended, incomplete = false, processor = "") {
570
572
  const machine = await this.#host.readMachineState();
571
573
  // HOW LATE THIS THREAD'S LOOP RAN over the interval: every answer to FFmpeg
572
574
  // and every hand-over of a copy waits for it (torrent-tv/meta#178). Begun at
@@ -591,10 +593,74 @@ export class HostLoad {
591
593
  // What the offer will multiply a prediction by, in the same line as the
592
594
  // readings it comes from.
593
595
  ` available=${asPercent(this.hostAvailability.share)} for-encoders=${asPercent(this.hostAvailability.forEncoders)}` +
594
- ` free=${asPercent(free)}` + late
596
+ ` free=${asPercent(free)}` + processor + late
595
597
  );
596
598
  }
597
599
 
600
+ /**
601
+ * The processor's upper bound and its measured free amount over the same
602
+ * interval, for the machine's line (torrent-tv/meta#180, phase 1). Read
603
+ * beside today's figures and used by no decision; empty where the host does
604
+ * not take these readings.
605
+ *
606
+ * @param {{ processor?: object | null }} previous
607
+ * @param {{ processor?: object | null }} sample
608
+ * @param {{ processShare: number | null, systemShare: number | null }} share
609
+ * @param {number | null} proxyShare
610
+ * @returns {string}
611
+ */
612
+ #processorLine(previous, sample, share, proxyShare) {
613
+ if (typeof this.#host.processorBetween !== "function" || typeof this.#host.describeProcessor !== "function") {
614
+ return "";
615
+ }
616
+ const ourShare = Number.isFinite(proxyShare) && Number.isFinite(share.processShare) ? proxyShare + share.processShare : null;
617
+ return this.#host.describeProcessor(this.#host.processorBetween(previous.processor, sample.processor, {
618
+ systemShare: share.systemShare,
619
+ ourShare,
620
+ hostCores: Math.max(1, os.cpus().length)
621
+ }));
622
+ }
623
+
624
+ /**
625
+ * What each run's FFmpeg spent per second of film over the interval between
626
+ * two readings, with the runs beside it (torrent-tv/meta#180, phase 1).
627
+ *
628
+ * In CPU seconds per second of film (`k`), which is not the unit of
629
+ * `EncodeCost` (seconds of the whole machine per second of film, from
630
+ * speed): a run that uses all it is given has `cost = k / cores`. A `k` read
631
+ * beside other runs already carries their contention, so it is said with
632
+ * them; a run is alone only when no other run was alive at either reading.
633
+ * Only runs read at both ends that made film in between are said. For the
634
+ * log only.
635
+ *
636
+ * @param {{ takenAt: number, byPid: Map<number, number>, runs?: Map<number, { film: number | null, label: string, kind: string }> }} previous
637
+ * @param {{ takenAt: number, byPid: Map<number, number>, runs?: Map<number, { film: number | null, label: string, kind: string }> }} sample
638
+ */
639
+ #sayRunCosts(previous, sample) {
640
+ if (!previous.runs || !sample.runs) return;
641
+ const elapsedSec = (sample.takenAt - previous.takenAt) / 1000;
642
+ if (!(elapsedSec > 0)) return;
643
+ const alive = new Map([...previous.runs, ...sample.runs]);
644
+ const said = [];
645
+ for (const [pid, run] of sample.runs) {
646
+ const before = previous.runs.get(pid);
647
+ const cpuNow = sample.byPid.get(pid);
648
+ const cpuBefore = previous.byPid.get(pid);
649
+ if (!before || !Number.isFinite(cpuNow) || !Number.isFinite(cpuBefore) || cpuNow < cpuBefore) continue;
650
+ if (!Number.isFinite(run.film) || !Number.isFinite(before.film)) continue;
651
+ const film = run.film - before.film;
652
+ if (!(film > 0)) continue;
653
+ const cpu = cpuNow - cpuBefore;
654
+ const beside = [...alive].filter(([other]) => other !== pid).map(([, other]) => other.kind);
655
+ said.push(`${run.label} ${run.kind} k=${(cpu / film).toFixed(3)} CPU-s per film-s ` +
656
+ `(${cpu.toFixed(2)} CPU-s, ${film.toFixed(2)} film-s, speed ${(film / elapsedSec).toFixed(2)}x, ` +
657
+ `${(cpu / elapsedSec).toFixed(2)} cores), beside ${beside.length}` + (beside.length > 0 ? ` [${beside.join(", ")}]` : ""));
658
+ }
659
+ if (said.length > 0) {
660
+ logger.info(`processor: run cost over ${elapsedSec.toFixed(1)}s: ${said.join("; ")}`);
661
+ }
662
+ }
663
+
598
664
  /**
599
665
  * @param {{ elapsedSec: number, processShare: number | null, systemShare: number | null, rejected?: string[] } | null} share
600
666
  * @param {{ proxyCpuSeconds: number | null }} previous
@@ -658,9 +724,17 @@ export class HostLoad {
658
724
  // that ends meanwhile drops its process, and read after the await it threw
659
725
  // `Cannot read properties of null (reading 'pid')`, seven times in one
660
726
  // field session, each losing that reading (r64, torrent-tv/meta#178).
661
- const runs = encoding.flatMap((session) => this.#host.liveRunsOf(session))
727
+ // With the film each has made and what it is, for its cost over the
728
+ // interval (torrent-tv/meta#180).
729
+ const runs = encoding.flatMap((session) => this.#host.liveRunsOf(session)
662
730
  .filter((run) => Number.isInteger(run.process?.pid))
663
- .map((run) => ({ pid: run.process.pid, startedAt: run.startedAt }));
731
+ .map((run) => ({
732
+ pid: run.process.pid,
733
+ startedAt: run.startedAt,
734
+ film: filmMadeBy(run),
735
+ label: `${session.outputKey ?? session.id} #${run.from}..#${run.to}`,
736
+ kind: costKindForSession(session)
737
+ })));
664
738
  const [system, endedChildren, ...cpuReadings] = await Promise.all([
665
739
  this.#host.readSystemCpu(),
666
740
  this.#host.readEndedChildrenCpuSeconds?.() ?? null,
@@ -670,12 +744,15 @@ export class HostLoad {
670
744
  const byPid = new Map();
671
745
  /** @type {Map<number, number>} */
672
746
  const startedAt = new Map();
747
+ /** @type {Map<number, { film: number | null, label: string, kind: string }>} */
748
+ const runsByPid = new Map();
673
749
  runs.forEach((run, index) => {
674
750
  const seconds = cpuReadings[index];
675
751
  if (seconds !== null) {
676
752
  byPid.set(run.pid, seconds);
677
753
  startedAt.set(run.pid, run.startedAt);
678
754
  }
755
+ runsByPid.set(run.pid, { film: run.film, label: run.label, kind: run.kind });
679
756
  });
680
757
  const sample = {
681
758
  takenAt: Date.now(),
@@ -687,7 +764,9 @@ export class HostLoad {
687
764
  // piece store and the delivery. None of it is in the encode budget, and
688
765
  // on the addon host it is most of what the machine does while encoders
689
766
  // are suspended.
690
- proxyCpuSeconds: this.#host.readProxyCpuSeconds()
767
+ proxyCpuSeconds: this.#host.readProxyCpuSeconds(),
768
+ runs: runsByPid,
769
+ processor: this.#host.sampleProcessor?.() ?? null
691
770
  };
692
771
  const previous = this.#hostLoadSample;
693
772
  this.#hostLoadSample = sample;
@@ -699,6 +778,7 @@ export class HostLoad {
699
778
  // `/proc` there are no readings at all, and the share is null rather than a
700
779
  // confident zero.
701
780
  const encoders = encoderCpuBetween(previous, sample);
781
+ this.#sayRunCosts(previous, sample);
702
782
  const share = this.#host.shareOfMachine(
703
783
  { takenAt: previous.takenAt, processCpuSeconds: encoders === null ? null : 0, system: previous.system },
704
784
  { takenAt: sample.takenAt, processCpuSeconds: encoders?.seconds ?? null, system: sample.system }
@@ -718,7 +798,7 @@ export class HostLoad {
718
798
  if (encoders && !encoders.complete) {
719
799
  // A lower bound of our own load is not a reading of what other work left
720
800
  // us: the last availability stands, and the line says so.
721
- await this.#sayMachine(share, null, running, suspended, true);
801
+ await this.#sayMachine(share, null, running, suspended, true, this.#processorLine(previous, sample, share, null));
722
802
  return false;
723
803
  }
724
804
  // Kept for the quality offer, which predicts from a benchmark taken on a
@@ -726,7 +806,7 @@ export class HostLoad {
726
806
  // machine a new encoder could actually have. Only what nobody has been
727
807
  // charged for is subtracted — see `encode/available-share.js`.
728
808
  const proxyShare = this.#setAvailability(share, previous, sample);
729
- await this.#sayMachine(share, proxyShare, running, suspended);
809
+ await this.#sayMachine(share, proxyShare, running, suspended, false, this.#processorLine(previous, sample, share, proxyShare));
730
810
  return false;
731
811
  }
732
812
 
@@ -756,3 +836,15 @@ export class HostLoad {
756
836
  : 0;
757
837
  }
758
838
  }
839
+
840
+ /**
841
+ * The film a run has made since it started, from its progress, or null before
842
+ * it has said any.
843
+ *
844
+ * @param {{ progress?: { processedSeconds?: number, startPositionSeconds?: number } }} run
845
+ * @returns {number | null}
846
+ */
847
+ function filmMadeBy(run) {
848
+ const made = Number(run.progress?.processedSeconds) - Number(run.progress?.startPositionSeconds ?? 0);
849
+ return Number.isFinite(made) && made >= 0 ? made : null;
850
+ }
@@ -101,6 +101,7 @@ import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObserva
101
101
  import { configurationKeyOf } from "../encode/fingerprint.js";
102
102
  import { qualityStateOf } from "../encode/quality/OutputQualityState.js";
103
103
  import { downloadShare } from "../storage/processor-share.js";
104
+ import { describeProcessor, processorBetween, sampleProcessor } from "../storage/machine-processor.js";
104
105
  import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
105
106
  import { secondsUntilClosed } from "../encode/piece-close-time.js";
106
107
 
@@ -195,6 +196,9 @@ export function wireOutputs({
195
196
  // A software preset every picture is encoded at, for runs compared at one
196
197
  // quality (`--pin-preset`); null leaves the choice to the machine.
197
198
  pinnedPreset = null,
199
+ // A size every original-input stretch is given in place of copy rate
200
+ // times open (`--input-stretch-bytes`); null keeps the rule.
201
+ inputStretchBytes = null,
198
202
  // What every encoder this host may use was qualified and measured to do at
199
203
  // startup, by kind (`encode/calibration.js`). Takes the place of the one
200
204
  // array above, which a test may still hand in on its own.
@@ -300,6 +304,11 @@ export function wireOutputs({
300
304
  readProxyCpuSeconds,
301
305
  readSystemCpu,
302
306
  shareOfMachine,
307
+ // The processor's upper bound and measured free amount, said beside the
308
+ // machine's line and used by no decision yet (torrent-tv/meta#180).
309
+ sampleProcessor: () => sampleProcessor(),
310
+ processorBetween,
311
+ describeProcessor,
303
312
  liveRunsOf: (...args) => parts.encodeRuns.liveRunsOf(...args),
304
313
  runStateOf: (...args) => parts.encodeRuns.runStateOf(...args),
305
314
  get getSourceStats() { return parts.getSourceStats; },
@@ -1097,6 +1106,7 @@ export function wireOutputs({
1097
1106
  readRanges: readEncodeRanges,
1098
1107
  heldRanges: readEncodeHeldRanges,
1099
1108
  stretchCopied,
1109
+ stretchBytes: inputStretchBytes,
1100
1110
  reviseBudget: () => parts.machineBudget.revise("memory", "admitted encode inputs"),
1101
1111
  capacity: () => parts.machineBudget.capacityOf("memory"),
1102
1112
  // How long this output's input takes to open, from the one owner of start
@@ -1120,6 +1130,9 @@ export function wireOutputs({
1120
1130
  parts.serving.invalidateWaits(output);
1121
1131
  }
1122
1132
  }) : null);
1133
+ if (parts.encodeInputs && inputStretchBytes) {
1134
+ logger.info(`encode input: every original-input stretch sized to ${inputStretchBytes} bytes (--input-stretch-bytes)`);
1135
+ }
1123
1136
  // One owner of the disk, and the list of what takes it lives with the owner.
1124
1137
  parts.machineBudget = wireMachineBudget({
1125
1138
  segmentStore: parts.segmentStore,
@@ -163,7 +163,7 @@ function hostAvailable(readFile) {
163
163
  function containerRoom(readFile, hostTotal) {
164
164
  let membership;
165
165
  try {
166
- membership = ownCgroup(readFile("/proc/self/cgroup"));
166
+ membership = cgroupOf(readFile("/proc/self/cgroup"), "memory");
167
167
  } catch {
168
168
  return null; // silent-ok: not Linux, or /proc is not readable.
169
169
  }
@@ -187,18 +187,19 @@ function containerRoom(readFile, hostTotal) {
187
187
  }
188
188
 
189
189
  /**
190
- * Which cgroup this process is in, for the memory controller.
190
+ * Which cgroup this process is in, for one controller (`memory`, `cpu`).
191
191
  *
192
- * A host in hybrid mode lists both versions; the memory controller is then on
193
- * version 1, named in the line that lists it.
192
+ * A host in hybrid mode lists both versions; a controller mounted on version 1
193
+ * is named in the line that lists it, and only then is version 2 the answer.
194
194
  *
195
195
  * @param {string} text - `/proc/self/cgroup`.
196
+ * @param {string} controller
196
197
  * @returns {{ version: 1 | 2, path: string } | null}
197
198
  */
198
- function ownCgroup(text) {
199
+ export function cgroupOf(text, controller) {
199
200
  for (const line of text.split("\n")) {
200
201
  const match = /^\d+:([^:]*):(.*)$/.exec(line);
201
- if (match && match[1].split(",").includes("memory")) {
202
+ if (match && match[1].split(",").includes(controller)) {
202
203
  return { version: 1, path: match[2] };
203
204
  }
204
205
  }
@@ -219,7 +220,7 @@ function ownCgroup(text) {
219
220
  * @param {string} path
220
221
  * @returns {string[]}
221
222
  */
222
- function levelsOf(mount, path) {
223
+ export function levelsOf(mount, path) {
223
224
  const parts = path.split("/").filter(Boolean);
224
225
  if (parts.includes("..")) {
225
226
  return [];
@@ -0,0 +1,395 @@
1
+ /**
2
+ * @file How much processor this process could ever use, and how much of it was
3
+ * free over an interval (torrent-tv/meta#180, phase 1: readings only).
4
+ *
5
+ * Two quantities, never put in each other's place:
6
+ *
7
+ * 1. **The upper bound**, in cores: what the operating system lets this process
8
+ * run on — the CPUs it may be scheduled on (`os.availableParallelism()`,
9
+ * which follows the affinity mask on Linux) and the CPU quota of every cgroup
10
+ * on its path (Docker's `--cpus`, systemd's `CPUQuota=`), the smallest of
11
+ * them. A quota may be fractional: `cpu.max` of `150000 100000` is one and a
12
+ * half cores. The bound says what can never be exceeded; it says nothing of
13
+ * what is free.
14
+ * 2. **The measured free amount**, in cores: the idle time of the machine's
15
+ * CPUs over the interval — from `/proc/stat` where the caller has it, from
16
+ * `os.cpus()` times on every other platform — and, under a bound smaller
17
+ * than the machine, no more than the bound less what this proxy and its
18
+ * encoders used. Inside a container both readings describe the HOST's CPUs,
19
+ * which is what the other containers left. Where neither reading exists, or
20
+ * our own use is unknown under such a bound, it is unknown, and said so.
21
+ *
22
+ * A cgroup's own `cpu.stat` is NOT a reading of other work: its usage is what
23
+ * this cgroup and its descendants used, and its throttling says that the quota
24
+ * held our work back. Both are reported as that, and nothing else.
25
+ *
26
+ * Memory had the same defect this file answers for the processor — the host's
27
+ * figure where a container's limit decided — and was fixed in #153/#155
28
+ * (`machine-memory.js`); the cgroup is found the same way.
29
+ */
30
+
31
+ import { readFileSync } from "node:fs";
32
+ import os from "node:os";
33
+
34
+ import { cgroupOf, levelsOf } from "./machine-memory.js";
35
+
36
+ /**
37
+ * Where each cgroup version keeps its CPU controller, and under which names.
38
+ *
39
+ * Version 1 keeps the usage in a controller of its own, `cpuacct`, mounted
40
+ * either beside `cpu` (`cpu,cpuacct`, with `cpu` and `cpuacct` as links to
41
+ * it) or on its own; `/sys/fs/cgroup/cpuacct` names it in both cases. Its
42
+ * counters are in nanoseconds; version 2's in microseconds.
43
+ */
44
+ const HIERARCHIES = {
45
+ 1: { mount: "/sys/fs/cgroup/cpu", source: "cgroup v1 cfs", usageController: "cpuacct", usageMount: "/sys/fs/cgroup/cpuacct",
46
+ usage: "cpuacct.usage", usageUnit: 1e-9, throttledName: "throttled_time", throttledUnit: 1e-9 },
47
+ 2: { mount: "/sys/fs/cgroup", source: "cgroup v2 cpu.max", usageController: "cpu", usageMount: "/sys/fs/cgroup",
48
+ usage: "cpu.stat", usageUnit: 1e-6, throttledName: "throttled_usec", throttledUnit: 1e-6 }
49
+ };
50
+
51
+ /**
52
+ * @typedef {object} ProcessorBound
53
+ * @property {number} cores - What this process can never exceed, in cores.
54
+ * @property {number} parallelism - The CPUs it may be scheduled on.
55
+ * @property {number | null} quotaCores - The smallest cgroup quota on its path, or null for none.
56
+ * @property {string} source - Which of the two decided `cores`.
57
+ */
58
+
59
+ /**
60
+ * @typedef {object} CgroupCpu
61
+ * @property {number | null} usageSeconds - CPU seconds this cgroup and its descendants used.
62
+ * @property {number | null} throttledPeriods - Periods the binding quota held the cgroup back.
63
+ * @property {number | null} throttledSeconds - Time it was held back.
64
+ */
65
+
66
+ /**
67
+ * @typedef {object} ProcessorSample
68
+ * @property {number} takenAt - Wall clock, ms.
69
+ * @property {ProcessorBound} bound
70
+ * @property {CgroupCpu | null} cgroup
71
+ * @property {number | null} idleSeconds - Idle time of every CPU since boot, from `os.cpus()`.
72
+ */
73
+
74
+ /**
75
+ * What this process can never exceed.
76
+ *
77
+ * @param {object} [options]
78
+ * @param {(path: string) => string} [options.readFile] - Throws where a file is absent; injected by the checks.
79
+ * @param {number} [options.parallelism]
80
+ * @returns {ProcessorBound}
81
+ */
82
+ export function processorBound({ readFile = readText, parallelism = os.availableParallelism() } = {}) {
83
+ const quota = quotaOnPath(readFile);
84
+ const cores = Math.max(1, parallelism);
85
+ if (quota !== null && quota.cores < cores) {
86
+ return { cores: quota.cores, parallelism: cores, quotaCores: quota.cores, source: HIERARCHIES[quota.version].source };
87
+ }
88
+ return { cores, parallelism: cores, quotaCores: quota?.cores ?? null, source: "cores" };
89
+ }
90
+
91
+ /**
92
+ * This cgroup's own CPU counters: its usage, and how often its binding quota
93
+ * held it back. Null where there is no cgroup to read (not Linux, no CPU
94
+ * controller); each counter null where its file is not there.
95
+ *
96
+ * @param {object} [options]
97
+ * @param {(path: string) => string} [options.readFile]
98
+ * @returns {CgroupCpu | null}
99
+ */
100
+ export function cgroupCpu({ readFile = readText } = {}) {
101
+ const levels = cpuLevels(readFile);
102
+ if (levels === null) {
103
+ return null;
104
+ }
105
+ const hierarchy = HIERARCHIES[levels.version];
106
+ let usageSeconds = null;
107
+ for (const directory of usageLevels(readFile, levels.version)) {
108
+ const text = tryRead(readFile, `${directory}/${hierarchy.usage}`);
109
+ if (text === null) continue;
110
+ const value = levels.version === 2 ? statField(text, "usage_usec") : wholeNumber(text);
111
+ if (value !== null) {
112
+ usageSeconds = value * hierarchy.usageUnit;
113
+ break;
114
+ }
115
+ }
116
+ let throttledPeriods = null;
117
+ let throttledSeconds = null;
118
+ const binding = bindingLevel(readFile, levels);
119
+ const stat = binding === null ? null : tryRead(readFile, `${binding}/cpu.stat`);
120
+ if (stat !== null) {
121
+ throttledPeriods = statField(stat, "nr_throttled");
122
+ const time = statField(stat, hierarchy.throttledName);
123
+ throttledSeconds = time === null ? null : time * hierarchy.throttledUnit;
124
+ }
125
+ return { usageSeconds, throttledPeriods, throttledSeconds };
126
+ }
127
+
128
+ /**
129
+ * The idle time of every CPU since boot, from `os.cpus()`, which every
130
+ * platform answers. Null where it states no times.
131
+ *
132
+ * @param {Array<{ times?: { idle?: number } }>} [cpus]
133
+ * @returns {number | null} Seconds.
134
+ */
135
+ export function cpusIdleSeconds(cpus = os.cpus()) {
136
+ if (!Array.isArray(cpus) || cpus.length === 0) {
137
+ return null;
138
+ }
139
+ let idleMs = 0;
140
+ for (const cpu of cpus) {
141
+ const idle = Number(cpu?.times?.idle);
142
+ if (!Number.isFinite(idle)) return null;
143
+ idleMs += idle;
144
+ }
145
+ return idleMs / 1000;
146
+ }
147
+
148
+ /**
149
+ * One reading of everything above, to be compared against the next.
150
+ *
151
+ * @param {object} [options]
152
+ * @param {(path: string) => string} [options.readFile]
153
+ * @param {() => Array<{ times?: { idle?: number } }>} [options.cpus]
154
+ * @param {number} [options.parallelism]
155
+ * @param {() => number} [options.now]
156
+ * @returns {ProcessorSample}
157
+ */
158
+ export function sampleProcessor({ readFile = readText, cpus = () => os.cpus(), parallelism, now = Date.now } = {}) {
159
+ return {
160
+ takenAt: now(),
161
+ bound: processorBound({ readFile, ...(parallelism === undefined ? {} : { parallelism }) }),
162
+ cgroup: cgroupCpu({ readFile }),
163
+ idleSeconds: cpusIdleSeconds(cpus())
164
+ };
165
+ }
166
+
167
+ /**
168
+ * @typedef {object} ProcessorReading
169
+ * @property {ProcessorBound} bound
170
+ * @property {number | null} freeCores - Measured free, in cores; null when unknown.
171
+ * @property {string} freeSource - Where it came from, or why it is unknown.
172
+ * @property {number | null} cgroupUsedCores - This cgroup's own use over the interval.
173
+ * @property {number | null} throttledPeriods
174
+ * @property {number | null} throttledSeconds
175
+ */
176
+
177
+ /**
178
+ * What two samples say about the interval between them.
179
+ *
180
+ * @param {ProcessorSample | null | undefined} before
181
+ * @param {ProcessorSample | null | undefined} after
182
+ * @param {object} measured - Shares of the WHOLE machine over the same interval.
183
+ * @param {number | null} measured.systemShare - Everything the machine did, from `/proc/stat`; null where unread.
184
+ * @param {number | null} measured.ourShare - The proxy and its encoders; null where either is unread.
185
+ * @param {number} measured.hostCores - The CPUs those shares are fractions of.
186
+ * @returns {ProcessorReading | null} Null without two samples.
187
+ */
188
+ export function processorBetween(before, after, { systemShare, ourShare, hostCores }) {
189
+ if (!before || !after) {
190
+ return null;
191
+ }
192
+ const elapsedSec = (after.takenAt - before.takenAt) / 1000;
193
+ if (!(elapsedSec > 0)) {
194
+ return null;
195
+ }
196
+ const cores = Math.max(1, hostCores);
197
+ let idleCores = null;
198
+ let freeSource = "unknown: no idle reading";
199
+ if (Number.isFinite(systemShare)) {
200
+ idleCores = clamp((1 - systemShare) * cores, 0, cores);
201
+ freeSource = "/proc/stat";
202
+ } else if (Number.isFinite(before.idleSeconds) && Number.isFinite(after.idleSeconds) && after.idleSeconds >= before.idleSeconds) {
203
+ idleCores = clamp((after.idleSeconds - before.idleSeconds) / elapsedSec, 0, cores);
204
+ freeSource = "os.cpus";
205
+ }
206
+ let freeCores = idleCores;
207
+ const bound = after.bound;
208
+ if (idleCores !== null && bound.cores < cores) {
209
+ // Under a bound smaller than the machine, an idle CPU is ours only as far
210
+ // as the bound leaves room beside what we already use.
211
+ if (Number.isFinite(ourShare)) {
212
+ freeCores = Math.min(idleCores, Math.max(0, bound.cores - ourShare * cores));
213
+ } else {
214
+ freeCores = null;
215
+ freeSource = "unknown: our own use is unread under a limit";
216
+ }
217
+ }
218
+ const delta = (key) => {
219
+ const from = before.cgroup?.[key];
220
+ const to = after.cgroup?.[key];
221
+ return Number.isFinite(from) && Number.isFinite(to) && to >= from ? to - from : null;
222
+ };
223
+ const used = delta("usageSeconds");
224
+ return {
225
+ bound,
226
+ freeCores,
227
+ freeSource,
228
+ cgroupUsedCores: used === null ? null : used / elapsedSec,
229
+ throttledPeriods: delta("throttledPeriods"),
230
+ throttledSeconds: delta("throttledSeconds")
231
+ };
232
+ }
233
+
234
+ /**
235
+ * The reading as it is written into the machine's line of the log.
236
+ *
237
+ * @param {ProcessorReading | null} reading
238
+ * @returns {string}
239
+ */
240
+ export function describeProcessor(reading) {
241
+ if (reading === null) {
242
+ return " bound=n/a free-cores=n/a";
243
+ }
244
+ const { bound } = reading;
245
+ const limit = bound.quotaCores === null ? "no quota" : `quota ${bound.quotaCores.toFixed(2)}`;
246
+ const free = reading.freeCores === null ? `unknown (${reading.freeSource.replace(/^unknown: /, "")})`
247
+ : `${reading.freeCores.toFixed(2)} (${reading.freeSource})`;
248
+ const used = reading.cgroupUsedCores === null ? "n/a" : `${reading.cgroupUsedCores.toFixed(2)} cores`;
249
+ const throttled = reading.throttledPeriods === null && reading.throttledSeconds === null ? "n/a"
250
+ : `${reading.throttledPeriods ?? "n/a"} periods ${reading.throttledSeconds === null ? "n/a" : `${reading.throttledSeconds.toFixed(2)}s`}`;
251
+ return ` bound=${bound.cores.toFixed(2)} cores (${bound.source}; ${bound.parallelism} cpus, ${limit})` +
252
+ ` free-cores=${free} cgroup-used=${used} throttled=${throttled}`;
253
+ }
254
+
255
+ /**
256
+ * This process's cgroup for the CPU controller and the directories to read,
257
+ * its own first, or null where there is none.
258
+ *
259
+ * @param {(path: string) => string} readFile
260
+ * @returns {{ version: 1 | 2, directories: string[] } | null}
261
+ */
262
+ function cpuLevels(readFile) {
263
+ const text = tryRead(readFile, "/proc/self/cgroup");
264
+ const membership = text === null ? null : cgroupOf(text, "cpu");
265
+ if (membership === null) {
266
+ return null;
267
+ }
268
+ const directories = levelsOf(HIERARCHIES[membership.version].mount, membership.path);
269
+ return directories.length === 0 ? null : { version: membership.version, directories };
270
+ }
271
+
272
+ /**
273
+ * The directories that hold this process's CPU usage, its own first.
274
+ *
275
+ * @param {(path: string) => string} readFile
276
+ * @param {1 | 2} version
277
+ * @returns {string[]}
278
+ */
279
+ function usageLevels(readFile, version) {
280
+ const hierarchy = HIERARCHIES[version];
281
+ const text = tryRead(readFile, "/proc/self/cgroup");
282
+ const membership = text === null ? null : cgroupOf(text, hierarchy.usageController);
283
+ return membership === null ? [] : levelsOf(hierarchy.usageMount, membership.path);
284
+ }
285
+
286
+ /**
287
+ * The quota of one cgroup directory, in cores, or null where it states none.
288
+ *
289
+ * @param {(path: string) => string} readFile
290
+ * @param {1 | 2} version
291
+ * @param {string} directory
292
+ * @returns {number | null}
293
+ */
294
+ function quotaAt(readFile, version, directory) {
295
+ if (version === 2) {
296
+ const text = tryRead(readFile, `${directory}/cpu.max`);
297
+ const [quota, period] = (text ?? "").trim().split(/\s+/);
298
+ const cores = Number(quota) / Number(period);
299
+ return /^\d+$/.test(quota ?? "") && Number.isFinite(cores) && cores > 0 ? cores : null;
300
+ }
301
+ const quota = wholeNumber(tryRead(readFile, `${directory}/cpu.cfs_quota_us`) ?? "");
302
+ const period = wholeNumber(tryRead(readFile, `${directory}/cpu.cfs_period_us`) ?? "");
303
+ // `-1` is version 1's "no quota", which `wholeNumber` does not accept.
304
+ return quota !== null && period !== null && quota > 0 && period > 0 ? quota / period : null;
305
+ }
306
+
307
+ /**
308
+ * The smallest quota on this process's cgroup path, and its version.
309
+ *
310
+ * @param {(path: string) => string} readFile
311
+ * @returns {{ version: 1 | 2, cores: number } | null}
312
+ */
313
+ function quotaOnPath(readFile) {
314
+ const levels = cpuLevels(readFile);
315
+ if (levels === null) {
316
+ return null;
317
+ }
318
+ const binding = bindingLevel(readFile, levels);
319
+ return binding === null ? null : { version: levels.version, cores: quotaAt(readFile, levels.version, binding) };
320
+ }
321
+
322
+ /**
323
+ * The directory whose quota is the smallest on the path, or null for none.
324
+ *
325
+ * @param {(path: string) => string} readFile
326
+ * @param {{ version: 1 | 2, directories: string[] }} levels
327
+ * @returns {string | null}
328
+ */
329
+ function bindingLevel(readFile, levels) {
330
+ let binding = null;
331
+ let smallest = Infinity;
332
+ for (const directory of levels.directories) {
333
+ const cores = quotaAt(readFile, levels.version, directory);
334
+ if (cores !== null && cores < smallest) {
335
+ smallest = cores;
336
+ binding = directory;
337
+ }
338
+ }
339
+ return binding;
340
+ }
341
+
342
+ /**
343
+ * @param {string} path
344
+ * @returns {string}
345
+ */
346
+ function readText(path) {
347
+ return readFileSync(path, "utf8");
348
+ }
349
+
350
+ /**
351
+ * @param {(path: string) => string} readFile
352
+ * @param {string} path
353
+ * @returns {string | null}
354
+ */
355
+ function tryRead(readFile, path) {
356
+ try {
357
+ return readFile(path);
358
+ } catch {
359
+ return null; // silent-ok: not Linux, no such level, or no CPU controller on it.
360
+ }
361
+ }
362
+
363
+ /**
364
+ * @param {string} text
365
+ * @returns {number | null}
366
+ */
367
+ function wholeNumber(text) {
368
+ const trimmed = text.trim();
369
+ return /^\d+$/.test(trimmed) ? Number(trimmed) : null;
370
+ }
371
+
372
+ /**
373
+ * One field of a `cpu.stat`.
374
+ *
375
+ * @param {string} text
376
+ * @param {string} name
377
+ * @returns {number | null}
378
+ */
379
+ function statField(text, name) {
380
+ for (const line of text.split("\n")) {
381
+ const [key, value = ""] = line.trim().split(/\s+/);
382
+ if (key === name) return wholeNumber(value);
383
+ }
384
+ return null;
385
+ }
386
+
387
+ /**
388
+ * @param {number} value
389
+ * @param {number} low
390
+ * @param {number} high
391
+ * @returns {number}
392
+ */
393
+ function clamp(value, low, high) {
394
+ return Math.min(high, Math.max(low, value));
395
+ }
@@ -14,6 +14,12 @@
14
14
  * threads only says where that time went. Each ffmpeg is its own process and
15
15
  * is added beside it. Linux only for the split and for ffmpeg; elsewhere the
16
16
  * process figure alone is said.
17
+ *
18
+ * Each run is also said on its own once a minute: the middle of its seconds'
19
+ * processor and speed, and how far those seconds sit from it
20
+ * (torrent-tv/meta#180, phase 1). That scatter is what a slowdown of the most
21
+ * urgent run would have to exceed before it could be put down to another run
22
+ * beside it; nothing decides on it yet.
17
23
  */
18
24
 
19
25
  import { readFileSync, readdirSync } from "node:fs";
@@ -212,35 +218,108 @@ export function describeShares(seconds) {
212
218
  return `${said.join(", ")} of a core over ${seconds.length}s`;
213
219
  }
214
220
 
221
+ /**
222
+ * One second of each run read at both ends of it: the cores its ffmpeg used
223
+ * and the film it made per second of wall clock. A run whose film is not
224
+ * known at either end says its processor alone.
225
+ *
226
+ * @param {{ at: number, ffmpeg: Map<number, number>, runs: Map<number, { label: string, film: number | null }> }} before
227
+ * @param {{ at: number, ffmpeg: Map<number, number>, runs: Map<number, { label: string, film: number | null }> }} after
228
+ * @returns {Map<number, { label: string, cores: number, speed: number | null }>}
229
+ */
230
+ export function runSecondsBetween(before, after) {
231
+ /** @type {Map<number, { label: string, cores: number, speed: number | null }>} */
232
+ const seconds = new Map();
233
+ const elapsed = (after.at - before.at) / 1000;
234
+ if (!(elapsed > 0)) return seconds;
235
+ for (const [pid, run] of after.runs) {
236
+ const from = before.ffmpeg.get(pid);
237
+ const to = after.ffmpeg.get(pid);
238
+ const earlier = before.runs.get(pid);
239
+ if (!earlier || !Number.isFinite(from) || !Number.isFinite(to) || to < from) continue;
240
+ const made = Number.isFinite(run.film) && Number.isFinite(earlier.film) && run.film >= earlier.film ? run.film - earlier.film : null;
241
+ seconds.set(pid, { label: run.label, cores: (to - from) / elapsed, speed: made === null ? null : made / elapsed });
242
+ }
243
+ return seconds;
244
+ }
245
+
246
+ /**
247
+ * Per run, over the interval: the median of its seconds and the median of
248
+ * their distances to it, for its processor and its speed.
249
+ *
250
+ * @param {Map<number, { label: string, cores: number[], speed: number[] }>} runs
251
+ * @returns {string} Empty when no run was read for two seconds.
252
+ */
253
+ export function describeRuns(runs) {
254
+ const said = [];
255
+ for (const run of runs.values()) {
256
+ if (run.cores.length < 2) continue;
257
+ const cores = middleAndScatter(run.cores);
258
+ const speed = middleAndScatter(run.speed);
259
+ said.push(`${run.label} cores ${cores.middle.toFixed(2)} (scatter ${cores.scatter.toFixed(2)})` +
260
+ (speed === null ? ", speed n/a" : `, speed ${speed.middle.toFixed(2)}x (scatter ${speed.scatter.toFixed(2)})`) +
261
+ ` over ${run.cores.length}s`);
262
+ }
263
+ return said.join("; ");
264
+ }
265
+
266
+ /**
267
+ * @param {number[]} values
268
+ * @returns {{ middle: number, scatter: number } | null}
269
+ */
270
+ function middleAndScatter(values) {
271
+ if (values.length === 0) return null;
272
+ const median = (list) => {
273
+ const sorted = [...list].sort((a, b) => a - b);
274
+ const half = Math.floor(sorted.length / 2);
275
+ return sorted.length % 2 === 0 ? (sorted[half - 1] + sorted[half]) / 2 : sorted[half];
276
+ };
277
+ const middle = median(values);
278
+ return { middle, scatter: median(values.map((value) => Math.abs(value - middle))) };
279
+ }
280
+
215
281
  /**
216
282
  * Read every second, say once a minute, and every second while `detailed()`
217
283
  * is true.
218
284
  *
219
285
  * @param {object} params
220
286
  * @param {(line: string) => void} params.log
221
- * @param {() => number[]} params.ffmpegPids - The encoders running now.
287
+ * @param {() => Array<{ pid: number, label: string, film: number | null }>} params.ffmpegRuns - The encoders running now, with the film each has made.
222
288
  * @param {() => boolean} [params.detailed]
223
289
  * @param {number} [params.everyMs]
224
290
  * @param {number} [params.sayEveryMs]
225
291
  * @returns {{ stop: () => void }}
226
292
  */
227
- export function startProcessorReport({ log, ffmpegPids, detailed = () => false, everyMs = 1000, sayEveryMs = 60_000 }) {
293
+ export function startProcessorReport({ log, ffmpegRuns, detailed = () => false, everyMs = 1000, sayEveryMs = 60_000 }) {
228
294
  let previous = null;
229
295
  let series = [];
296
+ /** @type {Map<number, { label: string, cores: number[], speed: number[] }>} */
297
+ let runSeries = new Map();
230
298
  let since = Date.now();
231
299
  const threads = threadReader();
232
300
  const tick = () => {
233
301
  const at = Date.now();
234
302
  const usage = process.cpuUsage();
235
- const now = { at, process: (usage.user + usage.system) / 1e6, threads: threads.read(), ffmpeg: readProcessesSeconds(ffmpegPids()) };
303
+ const runs = new Map(ffmpegRuns().map((run) => [run.pid, { label: run.label, film: run.film }]));
304
+ const now = { at, process: (usage.user + usage.system) / 1e6, threads: threads.read(), ffmpeg: readProcessesSeconds([...runs.keys()]), runs };
236
305
  const shares = previous ? sharesBetween(previous, now) : null;
306
+ const perRun = previous ? runSecondsBetween(previous, now) : new Map();
237
307
  previous = now;
238
308
  if (!shares) return;
239
309
  series.push(shares);
310
+ for (const [pid, second] of perRun) {
311
+ const kept = runSeries.get(pid) ?? { label: second.label, cores: [], speed: [] };
312
+ kept.cores.push(second.cores);
313
+ if (second.speed !== null) kept.speed.push(second.speed);
314
+ runSeries.set(pid, kept);
315
+ }
240
316
  if (detailed()) log(`processor: ${describeShares([shares])}`);
241
317
  if (now.at - since >= sayEveryMs) {
242
318
  log(`processor: ${describeShares(series)}`);
319
+ const perRunLine = describeRuns(runSeries);
320
+ if (perRunLine) log(`processor: per run, by second: ${perRunLine}`);
243
321
  series = [];
322
+ runSeries = new Map();
244
323
  since = now.at;
245
324
  // A torrent thread made again since is counted as the rest until then.
246
325
  threads.rediscover();