@torrent-tv/proxy 2.101.0 → 2.101.2

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.101.0",
3
+ "version": "2.101.2",
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
@@ -1151,10 +1151,13 @@ export async function startProxyServer({
1151
1151
  });
1152
1152
  startProcessorReport({
1153
1153
  log: (line) => logger.info(line),
1154
- ffmpegPids: () => [...outputParts.outputs.values()]
1155
- .flatMap((output) => outputParts.encodeRuns.liveRunsOf(output))
1156
- .map((run) => run.process?.pid)
1157
- .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)),
1158
1161
  detailed: () => startupProfile.active()
1159
1162
  });
1160
1163
  // What the last life of this process left on the disk. The kernel kills this
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @file The one owner of what this machine's processor gives the encoders
3
+ * (torrent-tv/meta#180, phase 2: one owner, results unchanged).
4
+ *
5
+ * Before it, the same two readings reached the processor's decisions by six
6
+ * routes: the share of the machine other work leaves (`HostLoad`'s latest
7
+ * reading) was read separately by the admission, the quality offer, the cost
8
+ * model and the download share, and the contention penalties measured at
9
+ * startup were handed separately to the cost model, the runs and the plan. A
10
+ * reading that changes, or a new one (the processor's bound and measured free
11
+ * amount, `storage/machine-processor.js`), would have had to be threaded to
12
+ * each of them again. Now each asks this object, and the arithmetic stays in
13
+ * the two files that define it (`available-share.js`, `contention.js`).
14
+ *
15
+ * It lives in the encoding component because every question it answers is the
16
+ * encoders', and that component may import nothing outside itself; it is
17
+ * assembled beside the machine budget in `server/wire-outputs.js`. Its unit is
18
+ * today's: shares of the whole machine and seconds of the whole machine per
19
+ * second of film. Nothing here changes an answer.
20
+ */
21
+
22
+ import { correctForAvailability, leftForEncoders } from "./available-share.js";
23
+ import { contentionPenalty } from "./contention.js";
24
+
25
+ export class ProcessorBudget {
26
+ /** @type {() => ({ share: number, forEncoders?: number, known: boolean } | null | undefined)} */
27
+ #reading;
28
+
29
+ /** @type {import("./contention.js").ContentionPenalties | null} */
30
+ #penalties;
31
+
32
+ /**
33
+ * @param {object} params
34
+ * @param {() => ({ share: number, forEncoders?: number, known: boolean } | null | undefined)} params.reading -
35
+ * The latest share of the machine other work leaves, as `HostLoad` keeps it.
36
+ * @param {import("./contention.js").ContentionPenalties | null} [params.penalties] -
37
+ * How much slower one job runs beside others, measured at startup.
38
+ */
39
+ constructor({ reading, penalties = null }) {
40
+ if (typeof reading !== "function") {
41
+ throw new TypeError("ProcessorBudget requires reading");
42
+ }
43
+ this.#reading = reading;
44
+ this.#penalties = penalties instanceof Map ? penalties : null;
45
+ }
46
+
47
+ /**
48
+ * The latest share of the machine other work leaves, or null before the
49
+ * first reading.
50
+ *
51
+ * @returns {{ share: number, forEncoders?: number, known: boolean } | null}
52
+ */
53
+ availability() {
54
+ return this.#reading() ?? null;
55
+ }
56
+
57
+ /**
58
+ * The share of the machine an encoder gets now; 1 where the host does not
59
+ * report its load.
60
+ *
61
+ * @returns {number}
62
+ */
63
+ left() {
64
+ return leftForEncoders(this.availability());
65
+ }
66
+
67
+ /**
68
+ * A speed measured alone on a quiet machine, as it would run now.
69
+ *
70
+ * @param {number} aloneSpeed
71
+ * @returns {number}
72
+ */
73
+ speedOf(aloneSpeed) {
74
+ return correctForAvailability(aloneSpeed, this.availability());
75
+ }
76
+
77
+ /**
78
+ * The measured penalties, as the plan and the runs read them.
79
+ *
80
+ * @returns {import("./contention.js").ContentionPenalties | null}
81
+ */
82
+ get penalties() {
83
+ return this.#penalties;
84
+ }
85
+
86
+ /**
87
+ * Replace the penalties, as a new measurement of them does.
88
+ *
89
+ * @param {import("./contention.js").ContentionPenalties | null} penalties
90
+ */
91
+ set penalties(penalties) {
92
+ this.#penalties = penalties instanceof Map ? penalties : null;
93
+ }
94
+
95
+ /**
96
+ * How much slower one job runs with `others` beside it.
97
+ *
98
+ * @param {number} others
99
+ * @returns {{ penalty: number, measured: boolean, from: number }}
100
+ */
101
+ penaltyFor(others) {
102
+ return contentionPenalty(others, this.#penalties);
103
+ }
104
+ }
@@ -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
+ }
@@ -658,6 +658,7 @@ export class ViewerRequests {
658
658
  fileLength: stats?.fileLength,
659
659
  pieceLength: stats?.pieceLength,
660
660
  downloadRateReadings: this.#sourceRateReadings(measurement, output.file.sourceKey, stats, now),
661
+ downloadRateSummaries: this.#sourceRateSummaries(measurement, output.file.sourceKey),
661
662
  downloadForecast,
662
663
  });
663
664
  }
@@ -774,11 +775,14 @@ export class ViewerRequests {
774
775
  bufferLimitSeconds: input.bufferLimitSeconds,
775
776
  lookaheadSeconds: input.lookaheadSeconds,
776
777
  link: input.linkReadings,
777
- sources: input.sources.map(({ id, complete, residence, downloadForecast, downloadRateReadings }) => ({
778
+ sources: input.sources.map(({ id, complete, residence, downloadForecast, downloadRateReadings,
779
+ downloadRateSummaries }) => ({
778
780
  id, complete, residenceRanges: residence?.length ?? 0,
779
781
  forecastAt: downloadForecast?.measuredAt ?? null, plannedPieces: downloadForecast?.ranges.length ?? 0,
780
782
  unknownPieces: downloadForecast?.ranges.filter(range => range.availableAt === null).length ?? 0,
781
- downloadRateBytesPerSecond: downloadRateReadings?.lastValue ?? null
783
+ // The rate the forecast reads, and both summaries it was chosen from.
784
+ downloadRateBytesPerSecond: this.#host.playbackReadiness.forecastRate(downloadRateReadings, now, 0, "time"),
785
+ downloadRateSummaries
782
786
  })),
783
787
  tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices }) => ({
784
788
  id, processedSeconds, readings, segments: segments.length, prepared: readySegmentIndices.length
@@ -818,7 +822,7 @@ export class ViewerRequests {
818
822
  const rates = (forecast.operations ?? []).map(operation =>
819
823
  `${operation.track} ${operation.operation} ${Number.isFinite(operation.speed) ? operation.speed.toFixed(2) : "?"}x`);
820
824
  const downloads = (input.sources ?? []).map(source => {
821
- const rate = source.downloadRateReadings?.lastValue;
825
+ const rate = this.#host.playbackReadiness.forecastRate(source.downloadRateReadings, now, 0, "time");
822
826
  return `${Number.isFinite(rate) ? (rate / 1_048_576).toFixed(2) : "?"} MB/s`;
823
827
  });
824
828
  logger.info(`playback held ${session.id} consumer=${consumerId}: delay ${seconds(delay)}s by ` +
@@ -949,7 +953,14 @@ export class ViewerRequests {
949
953
  }
950
954
  // Totals that went back belong to a torrent added again: read on from them.
951
955
  if (limit) source.limit = limit;
952
- return sourceService(source.free.snapshot(), source.delivered.snapshot());
956
+ return sourceService(source.free.snapshot(), source.delivered.snapshot(),
957
+ (summary) => this.#host.playbackReadiness.forecastRate(summary, now, 0, "time"));
958
+ }
959
+
960
+ /** Both summaries a source's service was chosen from, for the forecast's log line. */
961
+ #sourceRateSummaries(state, id) {
962
+ const source = state.sources.get(id);
963
+ return source ? { free: source.free.snapshot(), delivered: source.delivered.snapshot() } : null;
953
964
  }
954
965
  }
955
966
 
@@ -962,12 +973,19 @@ export class ViewerRequests {
962
973
  * downloaded 5-18 MB/s, answered "service not advancing", and held the viewer
963
974
  * (r54, torrent-tv/meta#178). What the swarm did carry, it can carry.
964
975
  *
976
+ * The two are compared by the rate the forecast reads from them, bytes over
977
+ * the span measured. Compared by their last readings, a summary whose last
978
+ * reading was 10.7 MB/s and whose span read 0 B/s won over 3.4-16.7 MB/s
979
+ * carried, and the forecast held the viewer with no delay to state (r74,
980
+ * torrent-tv/meta#185).
981
+ *
965
982
  * @param {object | null} free - A rate summary (`RateTrend.snapshot`).
966
983
  * @param {object | null} delivered
984
+ * @param {(summary: object) => number | null} rateOf - The rate the forecast reads from a summary.
967
985
  * @returns {object | null}
968
986
  */
969
- export function sourceService(free, delivered) {
987
+ export function sourceService(free, delivered, rateOf) {
970
988
  if (!free) return delivered;
971
989
  if (!delivered) return free;
972
- return delivered.lastValue > free.lastValue ? delivered : free;
990
+ return (rateOf(delivered) ?? 0) > (rateOf(free) ?? 0) ? delivered : free;
973
991
  }
@@ -101,6 +101,8 @@ 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";
105
+ import { ProcessorBudget } from "../encode/ProcessorBudget.js";
104
106
  import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
105
107
  import { secondsUntilClosed } from "../encode/piece-close-time.js";
106
108
 
@@ -303,6 +305,11 @@ export function wireOutputs({
303
305
  readProxyCpuSeconds,
304
306
  readSystemCpu,
305
307
  shareOfMachine,
308
+ // The processor's upper bound and measured free amount, said beside the
309
+ // machine's line and used by no decision yet (torrent-tv/meta#180).
310
+ sampleProcessor: () => sampleProcessor(),
311
+ processorBetween,
312
+ describeProcessor,
306
313
  liveRunsOf: (...args) => parts.encodeRuns.liveRunsOf(...args),
307
314
  runStateOf: (...args) => parts.encodeRuns.runStateOf(...args),
308
315
  get getSourceStats() { return parts.getSourceStats; },
@@ -344,7 +351,7 @@ export function wireOutputs({
344
351
  producedNumbers: (...args) => parts.serving.producedNumbers(...args),
345
352
  servesAudioSeparately: (...args) => parts.renditions.servesAudioSeparately(...args),
346
353
  disposeSession: (...args) => parts.lifecycle.disposeSession(...args),
347
- get contentionPenalties() { return parts.contentionPenalties; },
354
+ get contentionPenalties() { return parts.processor?.penalties ?? null; },
348
355
  get encodeCost() { return parts.encodeCost; },
349
356
  get encodeOrchestrator() { return parts.encodeOrchestrator; },
350
357
  get ffmpegBin() { return parts.ffmpegBin; },
@@ -730,7 +737,7 @@ export function wireOutputs({
730
737
  files.set(key, file);
731
738
  }
732
739
  const watched = [...files.values()].filter((file) => Number.isFinite(file.bytesPerFilmSecond) && file.bytesPerFilmSecond > 0);
733
- const share = downloadShare({ availableShare: parts.hostLoad.hostAvailability?.share ?? 1,
740
+ const share = downloadShare({ availableShare: parts.processor?.availability()?.share ?? 1,
734
741
  baseDraw: parts.hostLoad.baseDraw, costPerMegabyte: parts.hostLoad.observedTorrentCostPerMegabyte, files: watched });
735
742
  // Kept beside the readings it is judged against: what the torrents moved
736
743
  // over each interval is said next to the limit in force (meta#178).
@@ -738,7 +745,17 @@ export function wireOutputs({
738
745
  void Promise.resolve(parts.setDownloadRate(share.bytesPerSecond, `film speed ${share.filmSpeed.toFixed(2)}x ` +
739
746
  `over ${watched.length} file(s), ${Math.round(share.capacity * 100)}% of the machine for the proxy`)).catch(() => {});
740
747
  };
741
- parts.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
748
+ // The one owner of what the processor gives the encoders: the share of the
749
+ // machine other work leaves and the measured contention penalties, read by
750
+ // every place that decides on the processor (torrent-tv/meta#180).
751
+ parts.processor = new ProcessorBudget({ reading: () => parts.hostLoad.hostAvailability, penalties: contentionPenalties });
752
+ // Kept as the name the penalties were always set and read by, now the budget's.
753
+ Object.defineProperty(parts, "contentionPenalties", {
754
+ get: () => parts.processor.penalties,
755
+ set: (penalties) => { parts.processor.penalties = penalties; },
756
+ enumerable: true,
757
+ configurable: true
758
+ });
742
759
  // Seconds of film per second when the picture is COPIED, measured at
743
760
  // startup. Nothing else prices that branch: the other benchmarks measure
744
761
  // encoding and decoding, and a copy does neither.
@@ -907,11 +924,11 @@ export function wireOutputs({
907
924
  host: () => ({
908
925
  benchmark: parts.softwarePresetBenchmark,
909
926
  decodeModel: parts.decodeCostModel,
910
- contentionPenalties: parts.contentionPenalties,
927
+ contentionPenalties: parts.processor.penalties,
911
928
  copySpeedX: parts.copySpeedX,
912
929
  audioCalibration,
913
930
  audioDescriptionOf,
914
- availability: parts.hostLoad.hostAvailability,
931
+ availability: parts.processor.availability() ?? undefined,
915
932
  // WHICH encoder this host settled on. Only the software ladder is
916
933
  // benchmarked, so a reading taken off a hardware encoder cannot be
917
934
  // split into its decode and encode halves and is not filed as one.
@@ -951,7 +968,7 @@ export function wireOutputs({
951
968
  encodeCost: parts.encodeCost,
952
969
  occupiedCostSec: (fileKey) => parts.admission?.occupiedCost({ exceptFileKey: fileKey }).costSec ?? 0,
953
970
  occupancyKnownFor: (fileKey) => (parts.admission?.occupiedCost({ exceptFileKey: fileKey }).unpriced ?? 0) === 0,
954
- availability: () => parts.hostLoad.hostAvailability ?? null,
971
+ availability: () => parts.processor.availability(),
955
972
  outputs: parts.outputs,
956
973
  stateFor: (session) => parts.encodeRuns.runStateOf(session),
957
974
  // WHICH HEIGHTS A LIVE VIEWER HAS ON SCREEN, as numbers. Who is watching
@@ -1045,7 +1062,7 @@ export function wireOutputs({
1045
1062
  finished: (address) => parts.outputs.outputsOn(address).some((session) => finishedOutput(session)),
1046
1063
  loadOf: (address) => parts.encodeCost.loadOfOutput(address),
1047
1064
  loadForCandidate: (spec, file) => parts.encodeCost.loadForCandidate(spec, file),
1048
- availability: () => parts.hostLoad.hostAvailability ?? null
1065
+ availability: () => parts.processor.availability()
1049
1066
  });
1050
1067
  parts.encodeOrchestrator = new EncodeOrchestrator({
1051
1068
  inputDemandChanged: (address, windows) => {
@@ -1061,7 +1078,7 @@ export function wireOutputs({
1061
1078
  maxRunsFor: (address) => parts.encodeRuns.maxRunsForOutput(address),
1062
1079
  makeRun: ({ address, from, to, because }) => parts.encodeRuns.makeRunAt(address, from, to, because),
1063
1080
  segmentSeconds: parts.segmentDurationSec,
1064
- contentionPenalties: parts.contentionPenalties,
1081
+ contentionPenalties: parts.processor.penalties,
1065
1082
  startingSpeedFor: (address) => parts.encodeCost.speedForOutput(address),
1066
1083
  segmentCoverage,
1067
1084
  // HOW IT ASKS TO DECIDE AGAIN. A plan that refuses to place anything
@@ -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 [];