@torrent-tv/proxy 2.101.3 → 2.101.5

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.3",
3
+ "version": "2.101.5",
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
@@ -1159,7 +1159,9 @@ export async function startProxyServer({
1159
1159
  film: Number(run.progress?.processedSeconds) - Number(run.progress?.startPositionSeconds ?? 0)
1160
1160
  })))
1161
1161
  .filter((run) => Number.isInteger(run.pid)),
1162
- detailed: () => startupProfile.active()
1162
+ detailed: () => startupProfile.active(),
1163
+ // What the pause rule judges from (torrent-tv/meta#180, phase 4).
1164
+ onRunSeconds: (seconds) => outputParts.encodeOrchestrator?.noteRunSeconds(seconds)
1163
1165
  });
1164
1166
  // What the last life of this process left on the disk. The kernel kills this
1165
1167
  // one often enough for that to be an ordinary state rather than an odd one —
@@ -24,7 +24,8 @@
24
24
  */
25
25
 
26
26
  import { CoverageMap } from "./CoverageMap.js";
27
- import { firstUnmetWant, planEncoders } from "./EncodePlan.js";
27
+ import { deadlineReaderFor, firstUnmetWant, planEncoders } from "./EncodePlan.js";
28
+ import { PauseRule } from "./PauseRule.js";
28
29
  import { endOfRun } from "./EncodeRun.js";
29
30
  import { ENCODE_EXIT } from "./encode-exit.js";
30
31
  import { affordableRuns } from "./run-budget.js";
@@ -138,13 +139,19 @@ export class EncodeOrchestrator {
138
139
  * viewer was stopped for waited 59 s behind a picture that had long left
139
140
  * the urgent band (Home Assistant 2026-10-12, torrent-tv/meta#178).
140
141
  *
141
- * @type {Map<string, { urgent: boolean, priority: number }>}
142
+ * @type {Map<string, { urgent: boolean, priority: number, from: number }>}
142
143
  */
143
144
  #heldBack = new Map();
144
145
 
145
146
  /** What a stop and a start have cost on this host. */
146
147
  #costs = new RunCosts();
147
148
 
149
+ /**
150
+ * Whether a less urgent encoder runs beside the leading one, from what
151
+ * running it was measured to do (torrent-tv/meta#180, phase 4).
152
+ */
153
+ #pauseRule = new PauseRule();
154
+
148
155
  /**
149
156
  * What starting and stopping an encoder was measured to cost on this host,
150
157
  * before any viewer existed. Told once; real runs replace it as they end.
@@ -228,6 +235,9 @@ export class EncodeOrchestrator {
228
235
  * this one may be asked about encoders with no ledger, no clock and no
229
236
  * viewer anywhere. Absent, the line simply does not carry it.
230
237
  * @param {SegmentCoverage | null} [params.segmentCoverage]
238
+ * @param {{ freeCores: () => number | null, otherCores: () => number | null } | null} [params.processor] -
239
+ * The processor left free and the processor other work takes, in cores
240
+ * (`ProcessorBudget`); absent, both are unknown.
231
241
  */
232
242
  constructor({
233
243
  maxRunsFor,
@@ -242,6 +252,7 @@ export class EncodeOrchestrator {
242
252
  inputsUnplaced = null,
243
253
  describeWaits = null,
244
254
  admission = null,
255
+ processor = null,
245
256
  logger,
246
257
  now
247
258
  }) {
@@ -250,6 +261,7 @@ export class EncodeOrchestrator {
250
261
  // encoder that would slow everybody below realtime. Absent in a check that
251
262
  // is about one output, and then only the per-output limit binds.
252
263
  this.admission = admission && typeof admission.placesFor === "function" ? admission : null;
264
+ this.processor = processor && typeof processor.freeCores === "function" ? processor : null;
253
265
  this.#describeWaits = typeof describeWaits === "function" ? describeWaits : () => "";
254
266
  // Only the segment facts and operations this planner needs cross the
255
267
  // storage boundary; the concrete storage remains in the server assembly.
@@ -665,7 +677,7 @@ export class EncodeOrchestrator {
665
677
  /**
666
678
  * The most urgent band a live encoder stands in, over every output.
667
679
  *
668
- * @returns {{ urgent: boolean, priority: number, address: string, index: number } | null}
680
+ * @returns {{ urgent: boolean, priority: number, address: string, index: number, run: object } | null}
669
681
  */
670
682
  #leadingBand() {
671
683
  let leading = null;
@@ -673,7 +685,7 @@ export class EncodeOrchestrator {
673
685
  for (const run of runs) {
674
686
  if (!run.isAlive || run.isStopping) continue;
675
687
  const band = this.#bandAt(address, run.head);
676
- if (!leading || compareUrgency(band, leading) < 0) leading = { ...band, address, index: run.head };
688
+ if (!leading || compareUrgency(band, leading) < 0) leading = { ...band, address, index: run.head, run };
677
689
  }
678
690
  }
679
691
  return leading;
@@ -696,6 +708,13 @@ export class EncodeOrchestrator {
696
708
  * Paused rather than lowered in priority: a lowered priority cannot be raised
697
709
  * again without `CAP_SYS_NICE`, which the add-on's container does not have
698
710
  * (Home Assistant 2026-10-10: `renice 0` after `renice 19` left 19).
711
+ *
712
+ * AND PAUSED ONLY WHERE RUNNING IT WAS MEASURED TO COST THE LEADER
713
+ * (torrent-tv/meta#180, phase 4). Behind the leader is the condition, not
714
+ * the verdict: `PauseRule` lets a less urgent encoder run while the leader
715
+ * still makes what its deadlines need, and pauses it once the leader's own
716
+ * readings say it does not. Without readings to judge from, behind is paused
717
+ * as before.
699
718
  */
700
719
  shareTheMachine() {
701
720
  const leading = this.#leadingBand();
@@ -704,21 +723,125 @@ export class EncodeOrchestrator {
704
723
  this.#placeHeldBack(null);
705
724
  return;
706
725
  }
726
+ const now = this.now();
727
+ const running = this.#runningNow();
728
+ const need = this.#needOf(leading);
729
+ const free = this.#processorReading("freeCores");
730
+ const others = this.#processorReading("otherCores");
707
731
  for (const [address, runs] of this.#runs) {
708
732
  for (const run of runs) {
709
733
  if (!run.isAlive || run.isStopping || typeof run.pause !== "function") continue;
710
734
  const behind = compareUrgency(this.#bandAt(address, run.head), leading) > 0;
711
- if (behind && run.isSuspended !== true) {
712
- run.pause(`#${run.head} of ${address} is wanted less urgently than #${leading.index} of ${leading.address}, ` +
713
- `which is being made`);
714
- } else if (!behind && run.isSuspended === true) {
715
- run.resume(`nothing more urgent than #${run.head} of ${address} is being made`);
735
+ if (!behind) {
736
+ if (run.isSuspended === true) run.resume(`nothing more urgent than #${run.head} of ${address} is being made`);
737
+ continue;
738
+ }
739
+ const urgency = `#${run.head} of ${address} is wanted less urgently than #${leading.index} of ${leading.address}`;
740
+ const verdict = this.#pauseRule.judge({
741
+ leader: leading.run, band: leading, run, address, need, remainingFilm: this.#remainingFilm(run), free, others, running, now
742
+ });
743
+ if (verdict === null) {
744
+ if (run.isSuspended !== true) run.pause(`${urgency}, which is being made`);
745
+ } else if (verdict.runs && run.isSuspended === true) {
746
+ run.resume(`${urgency}, and runs beside it: ${verdict.because}`);
747
+ } else if (!verdict.runs && run.isSuspended !== true) {
748
+ run.pause(`${urgency}, which is being made: ${verdict.because}`);
716
749
  }
717
750
  }
718
751
  }
719
752
  this.#placeHeldBack(leading);
720
753
  }
721
754
 
755
+ /**
756
+ * One second of each encoder's processor and speed, by process
757
+ * (`storage/processor-report.js`): what `PauseRule` judges from. Who has the
758
+ * processor is decided again after it.
759
+ *
760
+ * @param {Map<number, { cores: number, speed: number | null }>} seconds
761
+ * @param {number} [at]
762
+ */
763
+ noteRunSeconds(seconds, at = this.now()) {
764
+ if (!(seconds instanceof Map) || seconds.size === 0) return;
765
+ const running = this.#runningNow();
766
+ let noted = false;
767
+ for (const runs of this.#runs.values()) {
768
+ for (const run of runs) {
769
+ const reading = seconds.get(run.process?.pid);
770
+ // A paused process draws nothing and makes nothing: its second says
771
+ // nothing about what it costs when it runs.
772
+ if (!reading || !run.isAlive || run.isSuspended === true) continue;
773
+ this.#pauseRule.noteSecond(run, { at, cores: reading.cores, speed: reading.speed }, running, run.isWaitingForInput === true);
774
+ noted = true;
775
+ }
776
+ }
777
+ if (noted) this.shareTheMachine();
778
+ }
779
+
780
+ /** @returns {Set<object>} Every encoder running now, not paused. */
781
+ #runningNow() {
782
+ const running = new Set();
783
+ for (const runs of this.#runs.values()) {
784
+ for (const run of runs) if (run.isAlive && !run.isStopping && run.isSuspended !== true) running.add(run);
785
+ }
786
+ return running;
787
+ }
788
+
789
+ /**
790
+ * The speed the leading run's deadlines ask for: over every number it has
791
+ * left, the film up to it over the time until it is needed. Infinite where a
792
+ * viewer already waits for one of them; 0 where none is due.
793
+ *
794
+ * @param {{ address: string, run: object }} leading
795
+ * @returns {number}
796
+ */
797
+ #needOf({ address, run }) {
798
+ const seconds = this.segmentSeconds;
799
+ if (!(seconds > 0)) return Number.POSITIVE_INFINITY;
800
+ const dueIn = deadlineReaderFor(this.#demand.mapOn(address), seconds);
801
+ let need = 0;
802
+ for (let index = run.head; index <= run.to; index += 1) {
803
+ const due = dueIn(index);
804
+ if (!Number.isFinite(due)) break;
805
+ if (due <= 0) return Number.POSITIVE_INFINITY;
806
+ need = Math.max(need, ((index - run.head + 1) * seconds) / due);
807
+ }
808
+ return need;
809
+ }
810
+
811
+ /**
812
+ * @param {object} run
813
+ * @returns {number} Film seconds it has left to make; infinite with no end.
814
+ */
815
+ #remainingFilm(run) {
816
+ return Number.isFinite(run.to) ? Math.max(0, run.to - run.head + 1) * this.segmentSeconds : Number.POSITIVE_INFINITY;
817
+ }
818
+
819
+ /**
820
+ * @param {"freeCores" | "otherCores"} name
821
+ * @returns {number | null}
822
+ */
823
+ #processorReading(name) {
824
+ const value = this.processor?.[name]?.();
825
+ return Number.isFinite(value) ? value : null;
826
+ }
827
+
828
+ /**
829
+ * Why a start at `from` on `address` would be paused the moment it exists,
830
+ * or null: behind the leader, and not let go by `PauseRule`.
831
+ *
832
+ * @param {string} address
833
+ * @param {number} from
834
+ * @returns {string | null}
835
+ */
836
+ #holdReason(address, from) {
837
+ const leading = this.#leading;
838
+ if (!leading?.run || compareUrgency(this.#bandAt(address, from), leading) <= 0) return null;
839
+ const verdict = this.#pauseRule.wouldStart({
840
+ leader: leading.run, address, need: this.#needOf(leading), free: this.#processorReading("freeCores")
841
+ });
842
+ return verdict.runs ? null : verdict.because;
843
+ }
844
+
722
845
  /**
723
846
  * Plan again every output whose start was held back behind what is no
724
847
  * longer more urgent than it: the start is placed now, by the same plan,
@@ -728,7 +851,7 @@ export class EncodeOrchestrator {
728
851
  */
729
852
  #placeHeldBack(leading) {
730
853
  for (const [address, band] of [...this.#heldBack]) {
731
- if (leading && compareUrgency(band, leading) > 0) continue;
854
+ if (leading && compareUrgency(band, leading) > 0 && this.#holdReason(address, band.from) !== null) continue;
732
855
  this.#heldBack.delete(address);
733
856
  this.#reconcileOne(address);
734
857
  }
@@ -824,7 +947,11 @@ export class EncodeOrchestrator {
824
947
  // was measured it holds the largest reading rather than continuing a curve
825
948
  // nothing observed.
826
949
  contentionPenaltyFor: (others) => contentionPenalty(others, this.contentionPenalties).penalty,
827
- speedX
950
+ speedX,
951
+ // A START THE PAUSE RULE WOULD PAUSE AT ONCE IS NOT MADE: the encoder
952
+ // would hold a process and its input for nothing. The plan returns it as
953
+ // `hold`, and it is placed when the rule lets it go (`#placeHeldBack`).
954
+ holdStart: (from) => this.#holdReason(address, from)
828
955
  });
829
956
 
830
957
  // A move is the plan taking a running encoder away from where it already
@@ -869,13 +996,10 @@ export class EncodeOrchestrator {
869
996
  `live=${JSON.stringify(live.map((run) => ({ from: run.from, to: run.to, head: run.head, speedX: run.speedX })))}`
870
997
  );
871
998
  }
872
- // A START BEHIND WHAT IS BEING MADE ELSEWHERE IS NOT MADE: the encoder would
873
- // be paused the moment it exists (`shareTheMachine`), holding a process and
874
- // its input for nothing. It is placed when nothing more urgent is in hand.
999
+ // A start held by the plan is placed when the pause rule lets it go.
875
1000
  let heldBack = null;
876
1001
  for (const action of actions) {
877
- if (action.type === "start" && this.#leading &&
878
- compareUrgency(this.#bandAt(address, action.from), this.#leading) > 0) {
1002
+ if (action.type === "hold") {
879
1003
  heldBack = action;
880
1004
  continue;
881
1005
  }
@@ -946,7 +1070,7 @@ export class EncodeOrchestrator {
946
1070
  // a 159 090-line file, 31 % of the log, all of one wedge.
947
1071
  const wanting = firstUnmetWant(coverage, windows);
948
1072
  const stillRunning = this.runsOn(address).filter((run) => run.isAlive);
949
- if (heldBack) this.#heldBack.set(address, this.#bandAt(address, heldBack.from));
1073
+ if (heldBack) this.#heldBack.set(address, { ...this.#bandAt(address, heldBack.from), from: heldBack.from });
950
1074
  else this.#heldBack.delete(address);
951
1075
  if (wanting === null || stillRunning.length > 0) {
952
1076
  this.#lastUnmet.delete(address);
@@ -956,7 +1080,7 @@ export class EncodeOrchestrator {
956
1080
  this.#lastUnmet.set(address, said);
957
1081
  this.logger.info(
958
1082
  `encode: #${heldBack.from} of ${address} waits — #${this.#leading?.index} of ` +
959
- `${this.#leading?.address} is wanted more urgently and is being made`
1083
+ `${this.#leading?.address} is wanted more urgently and is being made (${heldBack.because})`
960
1084
  );
961
1085
  }
962
1086
  } else if (this.#lastUnmet.get(address) !== wanting) {
@@ -1184,6 +1308,7 @@ export class EncodeOrchestrator {
1184
1308
  this.#lastProgress.set(ended.address, progress);
1185
1309
  }
1186
1310
  this.#costs.note(ended);
1311
+ if (ended.run) this.#pauseRule.forget(ended.run, ended.address);
1187
1312
  // Exactly one ending is normal — the run reached the end of the stretch it
1188
1313
  // was given and closed its last file. Every other leaves a piece open, and
1189
1314
  // that piece is under this run's OWN working name, so clearing up after it
@@ -84,7 +84,8 @@
84
84
  * @typedef {{ type: "start", from: number, to: number, because: string }
85
85
  * | { type: "move", run: object, from: number, to: number, because: string }
86
86
  * | { type: "stop", run: object, because: string }
87
- * | { type: "keep", run: object, from: number, to: number }} PlanAction
87
+ * | { type: "keep", run: object, from: number, to: number }
88
+ * | { type: "hold", from: number, to: number, because: string }} PlanAction
88
89
  */
89
90
 
90
91
  /**
@@ -116,6 +117,11 @@
116
117
  * encoder per piece: at exactly realtime each next piece is marginally late
117
118
  * however many are running, so another one always seemed to help a little.
118
119
  * Unmeasured is 1, and then the budget is the only thing bounding the count.
120
+ * @param {(from: number) => string | null} [params.holdStart] - Why a start at
121
+ * `from` would be paused the moment it exists (`PauseRule`), or null. Such a
122
+ * start comes back as `hold` with that reason: the encoder would hold a
123
+ * process and its input for nothing, and one place decides a start
124
+ * (torrent-tv/meta#180).
119
125
  * @returns {PlanAction[]} Stops first, then moves, then starts, so that a plan
120
126
  * carried out in order never holds two encoders where it means to hold one.
121
127
  */
@@ -130,7 +136,8 @@ export function planEncoders({
130
136
  now = Date.now(),
131
137
  refetchSecPerFilmSecond = 0,
132
138
  contentionPenaltyFor = () => 1,
133
- speedX = 0
139
+ speedX = 0,
140
+ holdStart = () => null
134
141
  }) {
135
142
  /** @type {PlanAction[]} */
136
143
  const stops = [];
@@ -686,7 +693,11 @@ export function planEncoders({
686
693
  });
687
694
  }
688
695
 
689
- return [...stops, ...moves, ...starts, ...keeps];
696
+ const startsOrHeld = starts.map((start) => {
697
+ const because = holdStart(start.from);
698
+ return because ? { type: "hold", from: start.from, to: start.to, because } : start;
699
+ });
700
+ return [...stops, ...moves, ...startsOrHeld, ...keeps];
690
701
  }
691
702
 
692
703
 
@@ -1010,7 +1021,7 @@ function lastOf(windows) {
1010
1021
  return Math.max(...windows.map((span) => span.to));
1011
1022
  }
1012
1023
 
1013
- function deadlineReaderFor(windows, segmentSeconds) {
1024
+ export function deadlineReaderFor(windows, segmentSeconds) {
1014
1025
  const perSegment = segmentSeconds > 0 ? segmentSeconds : 0;
1015
1026
  // A TABLE BY SEGMENT, filled once per plan. Read for every segment of every
1016
1027
  // arrangement the plan weighs, a search of every window for each index was
@@ -454,6 +454,11 @@ export class EncodeRun {
454
454
  this.clock.inputWaitEnds();
455
455
  }
456
456
 
457
+ /** @returns {boolean} Whether an input read of this run is waiting for bytes now. */
458
+ get isWaitingForInput() {
459
+ return this.clock.waitingForInput;
460
+ }
461
+
457
462
  /** @returns {boolean} Whether it is stopped where it stands, producing nothing. */
458
463
  get isSuspended() {
459
464
  return this.#state === ENCODE_RUN_STATE.SUSPENDED;
@@ -0,0 +1,334 @@
1
+ /**
2
+ * @file Whether an encoder making something less urgent runs beside the most
3
+ * urgent one, from what running it was measured to do (torrent-tv/meta#180,
4
+ * phase 4).
5
+ *
6
+ * Until this, everything less urgent than the leading band was stopped,
7
+ * whatever the leader did with the processor and whatever it needed by its
8
+ * deadline (`EncodeOrchestrator.shareTheMachine`). On a machine where the
9
+ * leader does not take all the processor — libx264 thread scaling, a hardware
10
+ * encoder whose work is on the device, a leader ahead of its deadline — the
11
+ * paused work waited for nothing; and a one-piece soundtrack copy at 150x was
12
+ * paused behind a picture it could not have slowed (Home Assistant r73).
13
+ *
14
+ * THE RULE. `L` is the leading run, `r` a less urgent one.
15
+ *
16
+ * 1. `L`'s need `s*` is the speed its deadlines ask for (film seconds per
17
+ * second); infinite where a viewer already waits for what `L` is making.
18
+ * 2. `r` keeps running when, over the seconds it ran beside `L`, `L` still made
19
+ * at least `s*` (finite need), or neither `L`'s speed nor its cores fell
20
+ * below what they were without `r` by more than their own scatter (infinite
21
+ * need). Otherwise `r` is paused. Both are read: memory-bandwidth contention
22
+ * lowers the speed and not the cores, and on Home Assistant (r78, r79) a
23
+ * short run's speed scattered by as much as its median from second to second
24
+ * while its cores scattered by 0.02-0.2, so a slowdown is often visible in
25
+ * the cores first. No number of cores enters: a fraction of a core can be
26
+ * enough, and a free core does not cap what a new ffmpeg takes.
27
+ * 3. A paused `r` is tried when the processor left free (`F`, cores) is
28
+ * unknown or exceeds what `r` drew when it last ran, or when `L` makes more
29
+ * than `s*` by more than its scatter. With an infinite need it is tried only
30
+ * once `L` has seconds without it to be compared against.
31
+ * 4. A run whose whole remaining stretch costs less processor than `L`'s
32
+ * scatter over one second cannot be measured against `L` and is never
33
+ * paused.
34
+ *
35
+ * NO ALTERNATION UNDER UNCHANGED LOAD. A pause for a measured slowdown is
36
+ * remembered for the pair with what it was measured under: `L`'s output and
37
+ * band, the other runs running, and the processor other work took. `r` is not
38
+ * tried beside `L` again until one of these changes — the others' draw by more
39
+ * than `r`'s own — so under constant load there is at most one try and one
40
+ * pause.
41
+ *
42
+ * WITHOUT READINGS NOTHING CHANGES. Where `L` has no per-second readings (no
43
+ * `/proc` to read an ffmpeg's processor from), or `r` has run beside `L` since
44
+ * before anything could be compared, the answer is null and the caller keeps
45
+ * the urgency rule it had.
46
+ *
47
+ * Readings are the per-second ones of `storage/processor-report.js`: each
48
+ * run's cores and the film it made per second of wall clock. Seconds in which
49
+ * `L` waited for its input say nothing about `r` and are left out.
50
+ */
51
+
52
+ /**
53
+ * How many seconds of readings a run keeps. Enough for a baseline before a try
54
+ * and the seconds beside after it; `L`'s cost changes from piece to piece, so
55
+ * older seconds describe other pieces.
56
+ */
57
+ const KEPT_SECONDS = 30;
58
+
59
+ /**
60
+ * The fewest seconds a median and its scatter are taken from: with fewer,
61
+ * the scatter of one or two values says nothing.
62
+ */
63
+ const LEAST_SECONDS = 3;
64
+
65
+ /**
66
+ * @typedef {object} RunSecond
67
+ * @property {number} at - When the second ended, in milliseconds.
68
+ * @property {number} cores - What the run's ffmpeg used over it.
69
+ * @property {number | null} speed - Film made per second of wall clock.
70
+ * @property {Set<object>} running - The runs running (not paused) beside it.
71
+ * @property {boolean} waiting - Whether its input waited at the end of it.
72
+ */
73
+
74
+ /**
75
+ * @typedef {object} PairConditions
76
+ * @property {string} address - `L`'s output.
77
+ * @property {boolean} urgent - `L`'s band.
78
+ * @property {number} priority
79
+ * @property {Set<object>} running - The other runs running, `L` and `r` aside.
80
+ * @property {number | null} others - Cores other work took.
81
+ */
82
+
83
+ export class PauseRule {
84
+ /** @type {Map<object, RunSecond[]>} */
85
+ #seconds = new Map();
86
+
87
+ /** When the rule last let each run go. @type {Map<object, number>} */
88
+ #triedAt = new Map();
89
+
90
+ /** What the last run on each output drew, for the next run there. @type {Map<string, number>} */
91
+ #lastDrawOn = new Map();
92
+
93
+ /** Pauses for a measured slowdown, by leader, then by run. @type {Map<object, Map<object, PairConditions>>} */
94
+ #pairs = new Map();
95
+
96
+ /**
97
+ * One second of one run.
98
+ *
99
+ * @param {object} run
100
+ * @param {{ at: number, cores: number, speed: number | null }} reading
101
+ * @param {Iterable<object>} running - Every run running at the time.
102
+ * @param {boolean} [waiting]
103
+ */
104
+ noteSecond(run, { at, cores, speed }, running, waiting = false) {
105
+ if (!Number.isFinite(cores)) return;
106
+ const kept = this.#seconds.get(run) ?? [];
107
+ kept.push({ at, cores, speed: Number.isFinite(speed) ? speed : null, running: new Set(running), waiting: waiting === true });
108
+ while (kept.length > 0 && kept[0].at <= at - KEPT_SECONDS * 1000) kept.shift();
109
+ this.#seconds.set(run, kept);
110
+ }
111
+
112
+ /**
113
+ * A run that has ended: what it drew is kept for the next run on its output.
114
+ *
115
+ * @param {object} run
116
+ * @param {string} address
117
+ */
118
+ forget(run, address) {
119
+ const draw = this.#drawOf(run);
120
+ if (draw !== null && typeof address === "string") this.#lastDrawOn.set(address, draw);
121
+ this.#seconds.delete(run);
122
+ this.#triedAt.delete(run);
123
+ this.#pairs.delete(run);
124
+ for (const pairs of this.#pairs.values()) pairs.delete(run);
125
+ }
126
+
127
+ /**
128
+ * Whether `run` runs beside `leader` now.
129
+ *
130
+ * @param {object} params
131
+ * @param {object} params.leader - The leading run.
132
+ * @param {{ address: string, urgent: boolean, priority: number }} params.band - Where it leads.
133
+ * @param {object} params.run - A run less urgent than it.
134
+ * @param {string} params.address - `run`'s output.
135
+ * @param {number} params.need - `L`'s `s*`: film seconds per second, `Infinity`
136
+ * where a viewer waits for what it is making, 0 where nothing is due.
137
+ * @param {number} params.remainingFilm - Film seconds `run` has left to make.
138
+ * @param {number | null} params.free - `F`: cores left free, or null.
139
+ * @param {number | null} params.others - Cores other work took, or null.
140
+ * @param {Iterable<object>} params.running - Every run running now.
141
+ * @param {number} params.now
142
+ * @returns {{ runs: boolean, because: string } | null} Null where nothing
143
+ * was measured to decide from.
144
+ */
145
+ judge({ leader, band, run, address, need, remainingFilm, free, others, running, now }) {
146
+ const lead = (this.#seconds.get(leader) ?? []).filter((second) => !second.waiting && second.speed !== null);
147
+ if (lead.length === 0) return null;
148
+ const leadCores = middleAndScatter(lead.map((second) => second.cores));
149
+ const draw = this.#drawOf(run) ?? this.#lastDrawOn.get(address) ?? null;
150
+ const perFilm = this.#costPerFilm(run);
151
+ if (perFilm !== null && Number.isFinite(remainingFilm) && remainingFilm * perFilm < leadCores.scatter) {
152
+ return { runs: true, because: `its remaining ${remainingFilm.toFixed(1)}s of film cost ${(remainingFilm * perFilm).toFixed(3)} ` +
153
+ `CPU-s, less than the leader's scatter of ${leadCores.scatter.toFixed(3)} cores over one second` };
154
+ }
155
+ const without = lead.filter((second) => !second.running.has(run));
156
+ const otherRunning = new Set([...running].filter((other) => other !== leader && other !== run));
157
+ if (run.isSuspended === true) {
158
+ return this.#whetherToTry({ leader, band, run, need, lead, without, draw, free, others, otherRunning, now });
159
+ }
160
+ const since = this.#triedAt.get(run) ?? Number.NEGATIVE_INFINITY;
161
+ const beside = lead.filter((second) => second.running.has(run) && second.at > since);
162
+ if (beside.length < LEAST_SECONDS) {
163
+ // Let go by this rule and not measured yet: it runs until it is.
164
+ // Running since before anything could be compared: the urgency rule.
165
+ return this.#triedAt.has(run) ? { runs: true, because: `being measured beside the leader (${beside.length}s so far)` } : null;
166
+ }
167
+ const besideSpeed = middleAndScatter(beside.map((second) => second.speed));
168
+ if (Number.isFinite(need)) {
169
+ if (besideSpeed.middle >= need) {
170
+ return { runs: true, because: `the leader made ${besideSpeed.middle.toFixed(2)}x beside it over ${beside.length}s, ` +
171
+ `needing ${need.toFixed(2)}x` };
172
+ }
173
+ this.#remember(leader, run, { band, otherRunning, others });
174
+ return { runs: false, because: `the leader made ${besideSpeed.middle.toFixed(2)}x beside it over ${beside.length}s, ` +
175
+ `below the ${need.toFixed(2)}x its deadlines need` };
176
+ }
177
+ if (without.length < LEAST_SECONDS) {
178
+ return this.#triedAt.has(run) ? { runs: true, because: "nothing to compare the leader's speed with" } : null;
179
+ }
180
+ const alone = middleAndScatter(without.map((second) => second.speed));
181
+ const besideCores = middleAndScatter(beside.map((second) => second.cores));
182
+ const aloneCores = middleAndScatter(without.map((second) => second.cores));
183
+ const measured = `the leader made ${besideSpeed.middle.toFixed(2)}x on ${besideCores.middle.toFixed(2)} cores beside it ` +
184
+ `over ${beside.length}s, ${alone.middle.toFixed(2)}x (scatter ${alone.scatter.toFixed(2)}) on ` +
185
+ `${aloneCores.middle.toFixed(2)} cores (scatter ${aloneCores.scatter.toFixed(2)}) without it over ${without.length}s, ` +
186
+ "and a viewer waits for it";
187
+ if (alone.middle - besideSpeed.middle > alone.scatter || aloneCores.middle - besideCores.middle > aloneCores.scatter) {
188
+ this.#remember(leader, run, { band, otherRunning, others });
189
+ return { runs: false, because: measured };
190
+ }
191
+ return { runs: true, because: measured };
192
+ }
193
+
194
+ /**
195
+ * Whether a run not started yet, on `address` and less urgent than
196
+ * `leader`, would be let go at once: a start the rule would pause is not
197
+ * made (`EncodePlan` holds it). Without readings of the leader, it would.
198
+ *
199
+ * @param {object} params
200
+ * @param {object} params.leader
201
+ * @param {string} params.address
202
+ * @param {number} params.need
203
+ * @param {number | null} params.free
204
+ * @returns {{ runs: boolean, because: string }}
205
+ */
206
+ wouldStart({ leader, address, need, free }) {
207
+ const lead = (this.#seconds.get(leader) ?? []).filter((second) => !second.waiting && second.speed !== null);
208
+ if (lead.length === 0) {
209
+ return { runs: false, because: "nothing has measured what the leader does" };
210
+ }
211
+ const draw = this.#lastDrawOn.get(address) ?? null;
212
+ return this.#tryCondition({ need, lead, without: lead, draw, free });
213
+ }
214
+
215
+ /**
216
+ * Whether a paused run is let go to be measured beside the leader.
217
+ *
218
+ * @param {object} params
219
+ * @returns {{ runs: boolean, because: string }}
220
+ */
221
+ #whetherToTry({ leader, band, run, need, lead, without, draw, free, others, otherRunning, now }) {
222
+ const remembered = this.#pairs.get(leader)?.get(run);
223
+ const changed = remembered ? changeOf(remembered, { band, otherRunning, others, draw }) : null;
224
+ if (remembered) {
225
+ if (changed === null) {
226
+ return { runs: false, because: "it slowed the leader when last tried, and nothing it was tried under has changed" };
227
+ }
228
+ this.#pairs.get(leader)?.delete(run);
229
+ }
230
+ const verdict = this.#tryCondition({ need, lead, without, draw, free });
231
+ if (!verdict.runs) return verdict;
232
+ this.#triedAt.set(run, now);
233
+ return { runs: true, because: `tried beside the leader: ${verdict.because}` +
234
+ (remembered ? ` (${changed} changed since it was paused)` : "") };
235
+ }
236
+
237
+ /**
238
+ * Rule 3: the conditions under which a run not running is let go.
239
+ *
240
+ * @param {{ need: number, lead: RunSecond[], without: RunSecond[], draw: number | null, free: number | null }} params
241
+ * @returns {{ runs: boolean, because: string }}
242
+ */
243
+ #tryCondition({ need, lead, without, draw, free }) {
244
+ if (!Number.isFinite(need) && without.length < LEAST_SECONDS) {
245
+ return { runs: false, because: `a viewer waits for the leader, and it has run ${without.length}s without this one ` +
246
+ "to be compared with" };
247
+ }
248
+ const leadSpeed = middleAndScatter(lead.map((second) => second.speed));
249
+ if (free === null || !Number.isFinite(free)) {
250
+ return { runs: true, because: "the processor left free is unknown" };
251
+ }
252
+ if (free > (draw ?? 0)) {
253
+ return { runs: true, because: `${free.toFixed(2)} cores are free, more than the ` +
254
+ `${draw === null ? "unmeasured draw" : `${draw.toFixed(2)} cores`} it draws` };
255
+ }
256
+ if (Number.isFinite(need) && leadSpeed.middle - need > leadSpeed.scatter) {
257
+ return { runs: true, because: `the leader makes ${leadSpeed.middle.toFixed(2)}x, needing ${need.toFixed(2)}x` };
258
+ }
259
+ return { runs: false, because: `${free.toFixed(2)} cores are free, not more than the ` +
260
+ `${draw === null ? "?" : draw.toFixed(2)} cores it draws, and the leader makes ${leadSpeed.middle.toFixed(2)}x` +
261
+ (Number.isFinite(need) ? `, needing ${need.toFixed(2)}x` : " with a viewer waiting") };
262
+ }
263
+
264
+ /**
265
+ * @param {object} leader
266
+ * @param {object} run
267
+ * @param {{ band: { address: string, urgent: boolean, priority: number }, otherRunning: Set<object>, others: number | null }} under
268
+ */
269
+ #remember(leader, run, { band, otherRunning, others }) {
270
+ const pairs = this.#pairs.get(leader) ?? new Map();
271
+ pairs.set(run, { address: band.address, urgent: band.urgent === true, priority: band.priority, running: otherRunning,
272
+ others: Number.isFinite(others) ? others : null });
273
+ this.#pairs.set(leader, pairs);
274
+ this.#triedAt.delete(run);
275
+ }
276
+
277
+ /**
278
+ * The cores a run drew while it ran: the median of its seconds.
279
+ *
280
+ * @param {object} run
281
+ * @returns {number | null}
282
+ */
283
+ #drawOf(run) {
284
+ const seconds = this.#seconds.get(run) ?? [];
285
+ return seconds.length === 0 ? null : middleAndScatter(seconds.map((second) => second.cores)).middle;
286
+ }
287
+
288
+ /**
289
+ * CPU seconds per second of film, over the seconds in which it made film.
290
+ *
291
+ * @param {object} run
292
+ * @returns {number | null}
293
+ */
294
+ #costPerFilm(run) {
295
+ const costs = (this.#seconds.get(run) ?? []).filter((second) => second.speed > 0).map((second) => second.cores / second.speed);
296
+ return costs.length === 0 ? null : middleAndScatter(costs).middle;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * What has changed since a pause for a slowdown, or null.
302
+ *
303
+ * @param {PairConditions} remembered
304
+ * @param {{ band: { address: string, urgent: boolean, priority: number }, otherRunning: Set<object>, others: number | null, draw: number | null }} now
305
+ * @returns {string | null}
306
+ */
307
+ function changeOf(remembered, { band, otherRunning, others, draw }) {
308
+ if (remembered.address !== band.address || remembered.urgent !== (band.urgent === true) || remembered.priority !== band.priority) {
309
+ return "the leader's band";
310
+ }
311
+ if (remembered.running.size !== otherRunning.size || [...otherRunning].some((other) => !remembered.running.has(other))) {
312
+ return "the runs running";
313
+ }
314
+ if (remembered.others !== null && Number.isFinite(others) && draw !== null && remembered.others - others > draw) {
315
+ return "the processor other work takes";
316
+ }
317
+ return null;
318
+ }
319
+
320
+ /**
321
+ * The median and the median distance to it.
322
+ *
323
+ * @param {number[]} values
324
+ * @returns {{ middle: number, scatter: number }}
325
+ */
326
+ function middleAndScatter(values) {
327
+ const median = (list) => {
328
+ const sorted = [...list].sort((left, right) => left - right);
329
+ const half = Math.floor(sorted.length / 2);
330
+ return sorted.length % 2 === 0 ? (sorted[half - 1] + sorted[half]) / 2 : sorted[half];
331
+ };
332
+ const middle = median(values);
333
+ return { middle, scatter: median(values.map((value) => Math.abs(value - middle))) };
334
+ }
@@ -23,7 +23,7 @@ import { correctForAvailability, leftForEncoders } from "./available-share.js";
23
23
  import { contentionPenalty } from "./contention.js";
24
24
 
25
25
  export class ProcessorBudget {
26
- /** @type {() => ({ share: number, forEncoders?: number, known: boolean } | null | undefined)} */
26
+ /** @type {() => ({ share: number, forEncoders?: number, known: boolean, freeCores?: number | null, otherCores?: number | null } | null | undefined)} */
27
27
  #reading;
28
28
 
29
29
  /** @type {import("./contention.js").ContentionPenalties | null} */
@@ -74,6 +74,27 @@ export class ProcessorBudget {
74
74
  return correctForAvailability(aloneSpeed, this.availability());
75
75
  }
76
76
 
77
+ /**
78
+ * The processor left free over the latest reading, in cores, or null where
79
+ * it was not read (torrent-tv/meta#180, phase 4).
80
+ *
81
+ * @returns {number | null}
82
+ */
83
+ freeCores() {
84
+ const free = this.availability()?.freeCores;
85
+ return Number.isFinite(free) ? free : null;
86
+ }
87
+
88
+ /**
89
+ * The processor other work took over the latest reading, in cores, or null.
90
+ *
91
+ * @returns {number | null}
92
+ */
93
+ otherCores() {
94
+ const others = this.availability()?.otherCores;
95
+ return Number.isFinite(others) ? others : null;
96
+ }
97
+
77
98
  /**
78
99
  * The measured penalties, as the plan and the runs read them.
79
100
  *
@@ -91,6 +91,16 @@ export class RunClock {
91
91
  }
92
92
  }
93
93
 
94
+ /**
95
+ * Whether an input read of this run is waiting for bytes now, whether or
96
+ * not the process is also stopped.
97
+ *
98
+ * @returns {boolean}
99
+ */
100
+ get waitingForInput() {
101
+ return this.#idleCauses - (this.#isStopped ? 1 : 0) > 0;
102
+ }
103
+
94
104
  /**
95
105
  * Milliseconds the process has been stopped so far.
96
106
  *
@@ -685,13 +685,25 @@ export class HostLoad {
685
685
  : null;
686
686
  // Of the cores this process may use where that is fewer than the host's
687
687
  // (torrent-tv/meta#180, phase 3).
688
- this.hostAvailability = availableShareFrom({
688
+ const availability = availableShareFrom({
689
689
  systemBusy: share.systemShare,
690
690
  encoderShare: share.processShare,
691
691
  proxyShare,
692
692
  hostCores: cores,
693
693
  boundCores: sample.processor?.bound?.cores ?? null
694
694
  });
695
+ // The processor left free and what other work takes, in cores, for the
696
+ // pause rule (torrent-tv/meta#180, phase 4). Unknown stays null.
697
+ const ourShare = Number.isFinite(proxyShare) && Number.isFinite(share.processShare) ? proxyShare + share.processShare : null;
698
+ const reading = typeof this.#host.processorBetween === "function"
699
+ ? this.#host.processorBetween(previous.processor ?? null, sample.processor ?? null,
700
+ { systemShare: share.systemShare, ourShare, hostCores: cores })
701
+ : null;
702
+ this.hostAvailability = {
703
+ ...availability,
704
+ freeCores: Number.isFinite(reading?.freeCores) ? reading.freeCores : null,
705
+ otherCores: availability.known ? availability.unattributed * cores : null
706
+ };
695
707
  return proxyShare;
696
708
  }
697
709
 
@@ -828,7 +828,9 @@ export class ViewerRequests {
828
828
  logger.info(`playback held ${session.id} consumer=${consumerId}: delay ${seconds(delay)}s by ` +
829
829
  (holding.kind === "position-not-held" ? "the position, which nothing buffered covers" :
830
830
  `the ${holding.kind} of ${holding.track} #${holding.segment} in ${seconds(holding.atSeconds)}s against ` +
831
- `${seconds(holding.aheadSeconds)}s of media before it${holding.requiresSource ? ", its input still downloading" : ""}`) +
831
+ `${seconds(holding.aheadSeconds)}s of media before it${holding.requiresSource ? ", its input still downloading" : ""}` +
832
+ (Number.isFinite(holding.sourceArrivalSeconds) ? ` until ${seconds(holding.sourceArrivalSeconds)}s by ` +
833
+ (holding.sourcePlanned ? "the torrent's queues" : "the measured download rate") : "")) +
832
834
  `; buffered ${seconds(forecast.bufferedSeconds)}s; rates ${rates.join(", ") || "none"}; download ${downloads.join(", ") || "none"}`);
833
835
  }
834
836
 
@@ -1076,6 +1076,9 @@ export function wireOutputs({
1076
1076
  makeRun: ({ address, from, to, because }) => parts.encodeRuns.makeRunAt(address, from, to, because),
1077
1077
  segmentSeconds: parts.segmentDurationSec,
1078
1078
  contentionPenalties: parts.processor.penalties,
1079
+ // The processor left free and what other work takes, for the pause rule
1080
+ // (torrent-tv/meta#180, phase 4).
1081
+ processor: parts.processor,
1079
1082
  startingSpeedFor: (address) => parts.encodeCost.speedForOutput(address),
1080
1083
  segmentCoverage,
1081
1084
  // HOW IT ASKS TO DECIDE AGAIN. A plan that refuses to place anything
@@ -286,11 +286,14 @@ function middleAndScatter(values) {
286
286
  * @param {(line: string) => void} params.log
287
287
  * @param {() => Array<{ pid: number, label: string, film: number | null }>} params.ffmpegRuns - The encoders running now, with the film each has made.
288
288
  * @param {() => boolean} [params.detailed]
289
+ * @param {(seconds: Map<number, { label: string, cores: number, speed: number | null }>) => void} [params.onRunSeconds] -
290
+ * Each second's readings of every run, by process, for whoever decides
291
+ * from them (`EncodeOrchestrator.noteRunSeconds`, torrent-tv/meta#180).
289
292
  * @param {number} [params.everyMs]
290
293
  * @param {number} [params.sayEveryMs]
291
294
  * @returns {{ stop: () => void }}
292
295
  */
293
- export function startProcessorReport({ log, ffmpegRuns, detailed = () => false, everyMs = 1000, sayEveryMs = 60_000 }) {
296
+ export function startProcessorReport({ log, ffmpegRuns, detailed = () => false, onRunSeconds = () => {}, everyMs = 1000, sayEveryMs = 60_000 }) {
294
297
  let previous = null;
295
298
  let series = [];
296
299
  /** @type {Map<number, { label: string, cores: number[], speed: number[] }>} */
@@ -305,6 +308,15 @@ export function startProcessorReport({ log, ffmpegRuns, detailed = () => false,
305
308
  const shares = previous ? sharesBetween(previous, now) : null;
306
309
  const perRun = previous ? runSecondsBetween(previous, now) : new Map();
307
310
  previous = now;
311
+ if (perRun.size > 0) {
312
+ // A fault in whoever decides from the readings must not end the
313
+ // readings, nor reach the timer as an uncaught exception.
314
+ try {
315
+ onRunSeconds(perRun);
316
+ } catch (error) {
317
+ log(`processor: run seconds not handled: ${error instanceof Error ? error.message : String(error)}`);
318
+ }
319
+ }
308
320
  if (!shares) return;
309
321
  series.push(shares);
310
322
  for (const [pid, second] of perRun) {
@@ -1,27 +1,25 @@
1
1
  import { setImmediate as nextTurn } from "node:timers/promises";
2
2
  import { piecesOf } from "../demand/pieces.js";
3
3
  import { pieceStoreOf } from "../piece-store-of.js";
4
- import { compareBands } from "./bands.js";
5
4
 
6
5
  const BLOCK_BYTES = 16 * 1024;
7
6
 
8
- /** Estimate completion from observed queues and the public selection bands.
9
- * The native picker takes a band's pieces from its first up (the `sequential`
10
- * strategy), so an unqueued piece arrives after the band's pieces before it.
11
- * A piece whose every missing block is asked for arrives when its queues say,
12
- * and is not the band's to share out. A supplier is a peer that can be asked
13
- * for the piece now. Pieces without a measured supplier remain unknown. No
14
- * requests are changed.
7
+ /** When the pieces whose every missing block is asked for arrive, from the
8
+ * queues that hold them. A block is reserved to the wire it was asked of, so a
9
+ * faster peer elsewhere does not bring it sooner (WebTorrent 2.8.5 `_request`,
10
+ * torrent.js:1758). Every other missing piece is left unknown: the start
11
+ * forecast brings those at the source's measured service, in its own media
12
+ * order (`viewer/playback-readiness.js`), one model of supply rather than a
13
+ * second one here (torrent-tv/meta#185). No requests are changed.
15
14
  * Times are conditional on the measured rate and successful verification.
16
15
  */
17
16
  export async function futureDownload(entries, { now = Date.now(), findStore = pieceStoreOf, isCurrent = () => true } = {}) {
18
17
  if (!isCurrent()) return new Map();
19
18
  const forecasts = new Map();
20
- const pending = [];
21
19
  let steps = 0;
22
- for (const { torrent, register, limit = null } of entries) {
20
+ for (const { torrent, register } of entries) {
23
21
  if (torrent.destroyed || torrent.ready === false) continue;
24
- const wanted = new Map();
22
+ const wanted = new Set();
25
23
  for (const window of register.windows()) {
26
24
  const file = torrent.files?.[window.fileIndex];
27
25
  if (!file) continue;
@@ -29,10 +27,7 @@ export async function futureDownload(entries, { now = Date.now(), findStore = pi
29
27
  byteEnd: Math.min(window.byteEnd, file.length - 1), pieceLength: torrent.pieceLength });
30
28
  if (!range) continue;
31
29
  for (let piece = range.from; piece <= range.to; piece++) {
32
- const previous = wanted.get(piece);
33
- if (!previous || compareBands(window, previous) < 0) {
34
- wanted.set(piece, { urgency: window.urgency, priority: window.priority, deadlineAt: window.deadlineAt });
35
- }
30
+ wanted.add(piece);
36
31
  if (++steps % 256 === 0) {
37
32
  await nextTurn();
38
33
  if (!isCurrent()) return new Map();
@@ -63,7 +58,7 @@ export async function futureDownload(entries, { now = Date.now(), findStore = pi
63
58
  }
64
59
  const store = findStore(torrent);
65
60
  const arrivals = new Map();
66
- for (const [index, band] of wanted) {
61
+ for (const index of wanted) {
67
62
  const present = store?.locationOf ? store.locationOf(index) !== "missing" : torrent.bitfield?.get(index);
68
63
  if (present) {
69
64
  arrivals.set(index, now);
@@ -81,33 +76,6 @@ export async function futureDownload(entries, { now = Date.now(), findStore = pi
81
76
  at = Math.max(at, arrival);
82
77
  }
83
78
  arrivals.set(index, at);
84
- // EVERY MISSING BLOCK ASKED FOR: the queues holding them say when it
85
- // arrives. Each block is reserved to the wire it was asked of, so a faster
86
- // peer elsewhere does not bring it sooner, and its bytes are not the
87
- // band's to share out (torrent-tv/meta#178 review, WebTorrent 2.8.5
88
- // `_request`, torrent.js:1758).
89
- if (at !== null) {
90
- if (++steps % 256 === 0) {
91
- await nextTurn();
92
- if (!isCurrent()) return new Map();
93
- }
94
- continue;
95
- }
96
- const remaining = piece?.missing;
97
- const holders = [];
98
- for (const wire of torrent.wires ?? []) {
99
- // Only a peer that can be asked for it now: one choking us is asked for
100
- // nothing outside the pieces it allows, whatever it carried before.
101
- if (wire.destroyed || !wire.peerPieces?.get(index) || !mayAsk(wire, index)) continue;
102
- // What it carried with its queue kept full, where it has been seen so:
103
- // its speed while our selection gave it little measures our asking
104
- // (`SelectionLimit`, torrent-tv/meta#178).
105
- const rate = Number(limit?.capacityOf?.(wire) ?? wire.downloadSpeed?.());
106
- if (Number.isFinite(rate) && rate > 0) holders.push({ wire, rate });
107
- }
108
- pending.push({ arrivals, index, ...band, holders, remaining:
109
- Number.isSafeInteger(remaining) && remaining > 0 && remaining <= piece.length && holders.length > 0
110
- ? remaining : null });
111
79
  if (++steps % 256 === 0) {
112
80
  await nextTurn();
113
81
  if (!isCurrent()) return new Map();
@@ -115,77 +83,5 @@ export async function futureDownload(entries, { now = Date.now(), findStore = pi
115
83
  }
116
84
  forecasts.set(torrent, { measuredAt: now, arrivals });
117
85
  }
118
- // WITHIN A BAND, IN THE ORDER THE PIECES COME. The picker walks each
119
- // selection from its first piece up (WebTorrent 2.8.5's default `sequential`
120
- // strategy, which this proxy keeps), so a piece of a band arrives once those
121
- // before it have. Charged the whole band's end instead, a band of many
122
- // segments — the map states one per stretch of a level since 2.97.27 — would
123
- // put its nearest piece at its farthest piece's time (torrent-tv/meta#178).
124
- //
125
- // AND IN PARALLEL WHERE THE PEERS DIFFER. A piece is brought by the peers
126
- // holding it, each from when it has brought what was asked of it before, and
127
- // no sooner than the measured supply of every peer that holds a piece still
128
- // to come carries all the bytes ahead of it. Summed one piece after another
129
- // at its own holders' speed, a two-hour film whose far pieces each had a few
130
- // unchoked holders was forecast to arrive in 127 838 s at 2.33 MB/s, and a
131
- // seek was held for 39 s on it (r49, torrent-tv/meta#178). One holder for
132
- // every piece gives the sum it was; a piece nobody can be asked for still
133
- // leaves every piece after it unknown.
134
- pending.sort((left, right) => compareBands(left, right) || left.index - right.index);
135
- const supply = new Map();
136
- for (const { holders } of pending) for (const { wire, rate } of holders) supply.set(wire, rate);
137
- const total = [...supply.values()].reduce((sum, rate) => sum + rate, 0);
138
- const freeAt = new Map();
139
- let ahead = 0;
140
- let known = true;
141
- for (const { arrivals, index, holders, remaining } of pending) {
142
- if (!known || remaining === null || !(total > 0)) {
143
- known = false;
144
- continue;
145
- }
146
- ahead += remaining;
147
- const finish = Math.max(sharedFinish(holders, freeAt, remaining, now), now + ahead / total * 1000);
148
- for (const { wire } of holders) freeAt.set(wire, Math.max(freeAt.get(wire) ?? now, finish));
149
- arrivals.set(index, finish);
150
- }
151
86
  return isCurrent() ? forecasts : new Map();
152
87
  }
153
-
154
- /**
155
- * When peers bringing `bytes` together finish: each starts when it is free
156
- * and brings at its own rate, so the finish is where their combined bytes
157
- * reach `bytes` (the earliest of them working alone at first, more joining as
158
- * they come free).
159
- *
160
- * @param {Array<{ wire: object, rate: number }>} holders - Bytes per second.
161
- * @param {Map<object, number>} freeAt - Milliseconds.
162
- * @param {number} bytes
163
- * @param {number} now - Milliseconds.
164
- * @returns {number} Milliseconds.
165
- */
166
- function sharedFinish(holders, freeAt, bytes, now) {
167
- const starts = holders.map(({ wire, rate }) => ({ at: Math.max(now, freeAt.get(wire) ?? now), rate }))
168
- .sort((left, right) => left.at - right.at);
169
- let rate = 0;
170
- let weighted = 0;
171
- for (let index = 0; index < starts.length; index++) {
172
- rate += starts[index].rate;
173
- weighted += starts[index].rate * starts[index].at;
174
- const finish = (bytes * 1000 + weighted) / rate;
175
- if (index === starts.length - 1 || finish <= starts[index + 1].at) return finish;
176
- }
177
- return now;
178
- }
179
-
180
- /**
181
- * Whether this peer can be asked for this piece now: it is not choking us, or
182
- * it allows this piece while it does (BEP 6, WebTorrent 2.8.5 `_updateWire`).
183
- *
184
- * @param {{ peerChoking?: boolean, hasFast?: boolean, peerAllowedFastSet?: number[] }} wire
185
- * @param {number} index
186
- * @returns {boolean}
187
- */
188
- function mayAsk(wire, index) {
189
- if (wire.peerChoking !== true) return true;
190
- return wire.hasFast === true && Array.isArray(wire.peerAllowedFastSet) && wire.peerAllowedFastSet.includes(index);
191
- }
@@ -380,7 +380,7 @@ export function predictPlaybackReadiness(input = {}) {
380
380
  const arrival = (range.availableAt - now) / 1000;
381
381
  // The entire protocol piece becomes readable after verification;
382
382
  // bytes inside it never arrive proportionally to their file address.
383
- sourceByteService.get(id).push({ start: range.start, end: range.end, begin: arrival, finish: arrival });
383
+ sourceByteService.get(id).push({ start: range.start, end: range.end, begin: arrival, finish: arrival, planned: true });
384
384
  }
385
385
  }
386
386
  }
@@ -409,6 +409,7 @@ export function predictPlaybackReadiness(input = {}) {
409
409
  for (const job of jobs) {
410
410
  const { track, trackIndex, segment, sizeBits, encodeWork, sourceByteIntervals } = job;
411
411
  let sourceArrivalAt = 0;
412
+ const plan = { finish: 0 };
412
413
  for (const [sourceId, ranges] of sourceByteIntervals) {
413
414
  const source = sourceState.get(sourceId);
414
415
  if (!source) {
@@ -417,7 +418,7 @@ export function predictPlaybackReadiness(input = {}) {
417
418
  for (const interval of ranges) {
418
419
  const missing = uncoveredIntervals(interval, sourceByteCoverage.get(sourceId) ?? []);
419
420
  if (missing.length === 0) {
420
- sourceArrivalAt = Math.max(sourceArrivalAt, latestArrival(interval, sourceArrivals.get(sourceId)));
421
+ sourceArrivalAt = Math.max(sourceArrivalAt, latestArrival(interval, sourceArrivals.get(sourceId), plan));
421
422
  continue;
422
423
  }
423
424
 
@@ -468,10 +469,13 @@ export function predictPlaybackReadiness(input = {}) {
468
469
  }
469
470
  const producedAt = job.produced || encodeWork === 0 ? 0 : processing.finish(encodeWork, admittedAt);
470
471
  trackFinish[trackIndex] = Math.max(trackFinish[trackIndex], producedAt);
471
- productions.push({ at: producedAt, trackIndex, segment, requiresSource: sourceArrivalAt > 0 });
472
+ const sourcePlanned = sourceArrivalAt > 0 && plan.finish >= sourceArrivalAt;
473
+ productions.push({ at: producedAt, trackIndex, segment, requiresSource: sourceArrivalAt > 0, sourceArrivalAt,
474
+ sourcePlanned });
472
475
  const deliveryAt = link.finish(sizeBits, Math.max(transferFinish, producedAt));
473
476
  transferFinish = deliveryAt;
474
- completions.push({ at: deliveryAt + finiteNonNegative(track.appendSeconds), trackIndex, segment });
477
+ completions.push({ at: deliveryAt + finiteNonNegative(track.appendSeconds), trackIndex, segment, sourceArrivalAt,
478
+ sourcePlanned });
475
479
  }
476
480
 
477
481
  const constraints = [];
@@ -479,10 +483,12 @@ export function predictPlaybackReadiness(input = {}) {
479
483
  // largest of thousands, and the log could only say that some term was
480
484
  // positive while the picture stood for 17 s (torrent-tv/meta#178).
481
485
  let holding = null;
482
- const constrain = (value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource) => {
486
+ const constrain = (value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource,
487
+ sourceArrivalAt = 0, sourcePlanned = false) => {
483
488
  constraints.push(value);
484
489
  if (value > 0 && (holding === null || value > holding.value)) {
485
- holding = { value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource };
490
+ holding = { value, kind, trackIndex, segment, atSeconds, aheadSeconds, requiresSource, sourceArrivalAt,
491
+ sourcePlanned };
486
492
  }
487
493
  };
488
494
  const end = rescale(fromSeconds(duration), unit);
@@ -501,7 +507,8 @@ export function predictPlaybackReadiness(input = {}) {
501
507
  for (const range of completion.segment.ranges) addMediaRange(delivered, range);
502
508
  after = contiguousEnd(delivered, at);
503
509
  const ahead = secondsAhead(earliest(before, trackEnd), at);
504
- constrain(completion.at - ahead, "delivery", trackIndex, completion.segment.index, completion.at, ahead, false);
510
+ constrain(completion.at - ahead, "delivery", trackIndex, completion.segment.index, completion.at, ahead, false,
511
+ completion.sourceArrivalAt, completion.sourcePlanned);
505
512
  if (compare(after, before) <= 0 && completion.segment.ranges.some(({ start }) => compare(start, before) > 0)) {
506
513
  // A timestamp hole cannot be repaired by waiting longer. Keep it
507
514
  // distinct from an unavailable rate or a slow but finite service.
@@ -524,7 +531,7 @@ export function predictPlaybackReadiness(input = {}) {
524
531
  : Math.max(0, state.tailEndSeconds - toSeconds(before));
525
532
  const ahead = Math.max(0, secondsAhead(before, at) - Math.min(reserve, left) * Number(production.requiresSource));
526
533
  constrain(production.at - ahead, "production", trackIndex, production.segment.index, production.at, ahead,
527
- production.requiresSource);
534
+ production.requiresSource, production.sourceArrivalAt, production.sourcePlanned);
528
535
  }
529
536
  }
530
537
  if (trackState.some(({ tail }) => tail.length > 0)) {
@@ -548,7 +555,11 @@ export function predictPlaybackReadiness(input = {}) {
548
555
  holdingBy: ready ? null : holding === null ? (originReady ? null : { kind: "position-not-held" })
549
556
  : { kind: holding.kind, track: tracks[holding.trackIndex]?.id ?? holding.trackIndex,
550
557
  segment: holding.segment, atSeconds: holding.atSeconds, aheadSeconds: holding.aheadSeconds,
551
- requiresSource: holding.requiresSource },
558
+ requiresSource: holding.requiresSource,
559
+ // When its input's last bytes come, and whether the torrent thread's
560
+ // queues say so rather than the source's measured service.
561
+ ...(holding.sourceArrivalAt > 0 ? { sourceArrivalSeconds: holding.sourceArrivalAt,
562
+ sourcePlanned: holding.sourcePlanned } : {}) },
552
563
  preparedUntilSeconds: Math.min(duration, ...trackState.map((state, index) => {
553
564
  const ranges = state.clientRanges.map((range) => ({ ...range }));
554
565
  for (const item of productions.filter((item) => item.trackIndex === index && item.at <= delay)) {
@@ -600,6 +611,7 @@ function scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArriv
600
611
  return state;
601
612
  };
602
613
  const next = trackState.map(() => 0);
614
+ const plan = { finish: 0 };
603
615
  let delivered = transferFinish;
604
616
  for (;;) {
605
617
  // The next piece in media order across the tracks, as the near window orders them.
@@ -635,6 +647,7 @@ function scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArriv
635
647
  work = Math.max(0, work);
636
648
  }
637
649
  let sourceArrivalAt = 0;
650
+ plan.finish = 0;
638
651
  if (work > 0) {
639
652
  if (!state.curve) return { reason: "encode-rate-unavailable" };
640
653
  const trackSources = Array.isArray(track.sourceIds) ? track.sourceIds : [];
@@ -653,7 +666,7 @@ function scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArriv
653
666
  let order = 0;
654
667
  for (const range of ranges) {
655
668
  // Bytes the torrent thread planned, or the near window brought, come when they were to come.
656
- sourceArrivalAt = Math.max(sourceArrivalAt, latestArrival(range, sourceArrivals.get(sourceId)));
669
+ sourceArrivalAt = Math.max(sourceArrivalAt, latestArrival(range, sourceArrivals.get(sourceId), plan));
657
670
  const after = missingBefore(bytes, range.end);
658
671
  if (after > missingBefore(bytes, range.start)) order = Math.max(order, after);
659
672
  }
@@ -703,10 +716,13 @@ function scheduleTail({ trackState, sourceState, sourceByteCoverage, sourceArriv
703
716
  // hole stops the media is decided in the near window, in exact time.
704
717
  const ahead = Math.max(0, startsAt - position);
705
718
  const requiresSource = sourceArrivalAt > 0;
706
- constrain(completedAt - ahead, "delivery", trackIndex, segment.index, completedAt, ahead, false);
719
+ const sourcePlanned = requiresSource && plan.finish >= sourceArrivalAt;
720
+ constrain(completedAt - ahead, "delivery", trackIndex, segment.index, completedAt, ahead, false, sourceArrivalAt,
721
+ sourcePlanned);
707
722
  const covered = Math.max(0, ahead - Math.min(reserve, Math.max(0, state.tailEndSeconds - startsAt)) *
708
723
  Number(requiresSource));
709
- constrain(producedAt - covered, "production", trackIndex, segment.index, producedAt, covered, requiresSource);
724
+ constrain(producedAt - covered, "production", trackIndex, segment.index, producedAt, covered, requiresSource,
725
+ sourceArrivalAt, sourcePlanned);
710
726
  }
711
727
  }
712
728
 
@@ -1023,7 +1039,16 @@ function indexedArrivals(ranges) {
1023
1039
  .map(range => ({ ...range, maximumEnd: maximumEnd = Math.max(maximumEnd, range.end) }));
1024
1040
  }
1025
1041
 
1026
- function latestArrival(interval, ranges = []) {
1042
+ /**
1043
+ * When the last of an interval's bytes arrives, by the arrivals indexed; with
1044
+ * `plan`, the latest of those the torrent thread's queues gave is kept in it.
1045
+ *
1046
+ * @param {{ start: number, end: number }} interval
1047
+ * @param {Array<{ start: number, end: number, finish: number, maximumEnd: number, planned?: boolean }>} [ranges]
1048
+ * @param {{ finish: number } | null} [plan]
1049
+ * @returns {number}
1050
+ */
1051
+ function latestArrival(interval, ranges = [], plan = null) {
1027
1052
  let low = 0, high = ranges.length;
1028
1053
  while (low < high) {
1029
1054
  const middle = (low + high) >>> 1;
@@ -1032,7 +1057,10 @@ function latestArrival(interval, ranges = []) {
1032
1057
  }
1033
1058
  let finish = 0;
1034
1059
  for (let index = low; index < ranges.length && ranges[index].start < interval.end; index++) {
1035
- if (ranges[index].end > interval.start) finish = Math.max(finish, ranges[index].finish);
1060
+ if (ranges[index].end > interval.start) {
1061
+ finish = Math.max(finish, ranges[index].finish);
1062
+ if (plan && ranges[index].planned) plan.finish = Math.max(plan.finish, ranges[index].finish);
1063
+ }
1036
1064
  }
1037
1065
  return finish;
1038
1066
  }