@torrent-tv/proxy 2.80.12 → 2.80.14

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.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @file How well the priority map is being served, measured where somebody waits.
3
+ *
4
+ * The map says what matters most. Nothing said whether what mattered most was
5
+ * actually delivered first — so "is the prioritisation any good" had no answer
6
+ * of any kind, for either of the two things that read the map.
7
+ *
8
+ * The measure is the same for both, and it is a wait recorded against the rank
9
+ * the map gave the thing waited for AT THE MOMENT it was asked for. Read that
10
+ * way it says which part is wrong rather than whether the whole scheme is:
11
+ *
12
+ * - long waits at the TOP rank mean the urgent zone is not being served first,
13
+ * which is a fault in whoever acts on the map;
14
+ * - long waits further down with none at the top mean the zones are the wrong
15
+ * width — the urgent one too narrow, so the viewer reaches material that was
16
+ * only ever ranked "soon";
17
+ * - a rank with no waits at all is not a good sign or a bad one, it is silence,
18
+ * and it is reported as such rather than as a zero.
19
+ *
20
+ * There is nothing in here about encoders, torrents, sessions or viewers: it is
21
+ * given a rank and a number of milliseconds.
22
+ */
23
+
24
+ /** How many waits to keep per rank. A median wants a sample, not a history. */
25
+ const HISTORY = 200;
26
+
27
+ /**
28
+ * Ranks are collapsed into bands before they are counted.
29
+ *
30
+ * The map's ranks are as many as the film needs — 100 down to 1 on a long file
31
+ * — and a table with a hundred rows says nothing a reader can hold. What is
32
+ * being asked is coarse: was the thing waited for what the viewer needs NOW,
33
+ * what they reach shortly, or the rest of the film. So the top rank is its own
34
+ * band, the next few are the second, and everything below is the third.
35
+ *
36
+ * @param {number} rank
37
+ * @param {number} topRank - The highest rank the map currently states.
38
+ * @returns {"now" | "soon" | "later"}
39
+ */
40
+ export function bandOf(rank, topRank) {
41
+ if (!Number.isFinite(rank) || rank <= 0) {
42
+ return "later";
43
+ }
44
+ const top = Number.isFinite(topRank) && topRank > 0 ? topRank : rank;
45
+ if (rank >= top) {
46
+ return "now";
47
+ }
48
+ // Within a tenth of the top: what a viewer reaches while watching what they
49
+ // hold. A tenth is the map's own shape — its zones widen geometrically — and
50
+ // not a threshold chosen for this table.
51
+ return rank >= top - Math.max(1, Math.round(top / 10)) ? "soon" : "later";
52
+ }
53
+
54
+ export class WaitLedger {
55
+ /** @type {Map<string, Map<string, number[]>>} */
56
+ #waits = new Map();
57
+
58
+ /** @type {Map<string, Map<string, number>>} */
59
+ #counts = new Map();
60
+
61
+ /**
62
+ * Somebody waited this long for something the map ranked this highly.
63
+ *
64
+ * @param {string} key - What the waits belong to: an output, or a file.
65
+ * @param {number} waitedMs
66
+ * @param {number} rank - The map's rank for the thing waited for.
67
+ * @param {number} topRank - The highest rank the map states, so the rank can
68
+ * be read as a position rather than as an absolute number.
69
+ */
70
+ note(key, waitedMs, rank, topRank) {
71
+ if (!key || !Number.isFinite(waitedMs) || waitedMs < 0) {
72
+ return;
73
+ }
74
+ const band = bandOf(rank, topRank);
75
+ let byBand = this.#waits.get(key);
76
+ if (!byBand) {
77
+ byBand = new Map();
78
+ this.#waits.set(key, byBand);
79
+ }
80
+ const held = byBand.get(band) ?? [];
81
+ held.push(waitedMs);
82
+ while (held.length > HISTORY) {
83
+ held.shift();
84
+ }
85
+ byBand.set(band, held);
86
+
87
+ let counts = this.#counts.get(key);
88
+ if (!counts) {
89
+ counts = new Map();
90
+ this.#counts.set(key, counts);
91
+ }
92
+ counts.set(band, (counts.get(band) ?? 0) + 1);
93
+ }
94
+
95
+ /**
96
+ * What the waits say, in one line, or null while nothing has waited.
97
+ *
98
+ * The count is the whole run and the median and worst are the recent sample,
99
+ * because those answer different questions: how often, and how badly.
100
+ *
101
+ * @param {string} key
102
+ * @returns {string | null}
103
+ */
104
+ describe(key) {
105
+ const byBand = this.#waits.get(key);
106
+ if (!byBand || byBand.size === 0) {
107
+ return null;
108
+ }
109
+ const counts = this.#counts.get(key) ?? new Map();
110
+ const parts = [];
111
+ for (const band of ["now", "soon", "later"]) {
112
+ const held = byBand.get(band);
113
+ if (!held || held.length === 0) {
114
+ // Said out loud, because an absent band and a band that never waited
115
+ // are the same silence and neither is a zero.
116
+ parts.push(`${band} none`);
117
+ continue;
118
+ }
119
+ const sorted = [...held].sort((a, b) => a - b);
120
+ const median = sorted[Math.floor(sorted.length / 2)];
121
+ parts.push(
122
+ `${band} ${counts.get(band) ?? held.length} wait(s) median ${Math.round(median)}ms ` +
123
+ `worst ${Math.round(sorted[sorted.length - 1])}ms`
124
+ );
125
+ }
126
+ return parts.join(", ");
127
+ }
128
+
129
+ /**
130
+ * @param {string} key
131
+ */
132
+ forget(key) {
133
+ this.#waits.delete(key);
134
+ this.#counts.delete(key);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * One ledger for the process, because the question is asked in two layers and
140
+ * the answer is comparable only if the scale is the same.
141
+ */
142
+ export const waits = new WaitLedger();
@@ -110,8 +110,47 @@ export function requiredSpeedFrom(waits) {
110
110
  if (!(medianIntervalSec > 0)) {
111
111
  return null;
112
112
  }
113
+ // THE SHARE OF ITS TIME THE READING LOST, over the stretch actually observed.
114
+ //
115
+ // The model is unchanged and it was always right: if a fraction `f` of the
116
+ // time is spent not delivering, then producing one second of film takes
117
+ // `1/(1 - f)` seconds, so a step must run that much faster than realtime.
118
+ // What was wrong was the two quantities fed into it — the WORST single
119
+ // interruption divided by the MEDIAN gap between interruptions, a maximum
120
+ // over a median, from two populations that need not be the same events at
121
+ // all. It asks what would happen if the worst interruption recurred at the
122
+ // typical rate, which is a compound case that never occurs, and it divides by
123
+ // a gap that goes to zero whenever interruptions arrive in a burst.
124
+ //
125
+ // Field 2026-09-08: 0.79 s (one jump, on a file already downloaded whole)
126
+ // over 0.01 s (the gaps inside a burst of microsecond waits) gave 158.60x,
127
+ // and the quality budget refused every step against it forty times in one
128
+ // session. The same measurements as a share of time lost give 1.00x, because
129
+ // that is what a reading of a complete file loses.
130
+ //
131
+ // The denominator here cannot vanish: it is the span the interruptions are
132
+ // spread over, which contains them.
133
+ // OVER WHOLE CYCLES, from the first interruption's start to the last one's
134
+ // start. That window holds exactly one running stretch per interruption in
135
+ // it, so the share does not depend on where the window happens to be cut —
136
+ // measured to the last interruption's END it counts one interruption more
137
+ // than it counts running stretches, and the same supply then reads 1.54x or
138
+ // 1.45x according to nothing but the moment the log line was printed.
139
+ const last = interruptions.length - 1;
140
+ const spanSec = (interruptions[last].start - interruptions[0].start) / 1000;
141
+ const lostSec = interruptions
142
+ .slice(0, last)
143
+ .reduce((total, one) => total + (one.end - one.start), 0) / 1000;
144
+ // A span that is all interruption says the supply delivered nothing at all
145
+ // while it was watched. There is no speed that survives that, and saying a
146
+ // huge number is less honest than saying it is not a speed question — so the
147
+ // largest figure any step is ever compared against is stated and named.
148
+ const lostShare = spanSec > 0 ? Math.min(0.99, lostSec / spanSec) : 0;
113
149
  return {
114
- requiredSpeed: 1 + worstWaitSec / medianIntervalSec,
150
+ requiredSpeed: 1 / (1 - lostShare),
151
+ lostShare,
152
+ spanSec,
153
+ lostSec,
115
154
  worstWaitSec,
116
155
  medianIntervalSec,
117
156
  // Interruptions, not waits: what the figure is derived from. The two differ
@@ -15,7 +15,7 @@ import { rmSync, statfsSync } from "node:fs";
15
15
  import WebTorrent from "webtorrent";
16
16
  import { logger } from "../utils/logger.js";
17
17
  import { SharedPieceStore, findSharedStore } from "./piece-store/shared-piece-store.js";
18
- import { Urgency } from "./demand/index.js";
18
+ import { Urgency, urgencyName } from "./demand/index.js";
19
19
  import { demandFor, forgetTorrent, reconcileAll } from "./download/registry.js";
20
20
  import { isAtAWatchingViewer, isBehindEverybody, isNobodyComingNow } from "./priority/PriorityMap.js";
21
21
  import { deriveSourceKey } from "./torrent-source-key.js";
@@ -682,6 +682,12 @@ export function dhtNodeCount(client) {
682
682
  }
683
683
  }
684
684
 
685
+ // The last shape said out loud per torrent, so an unchanged one is not
686
+ // repeated. Held here rather than on the pool because `applyPriorityMap`
687
+ // reads nothing but its arguments — which is what lets it be exercised
688
+ // without building a pool, a torrent client or a thread.
689
+ const lastMapSaid = new WeakMap();
690
+
685
691
  export class TorrentPool {
686
692
  /**
687
693
  * In-flight `client.add()` promises keyed by the same key as `torrents`.
@@ -1099,6 +1105,37 @@ export class TorrentPool {
1099
1105
  register.withdraw(window.claimant);
1100
1106
  }
1101
1107
  }
1108
+ // WHAT THE SWARM WAS ACTUALLY TOLD, said on change and never on a timer.
1109
+ // The map was applied in silence: that it had been BUILT was visible in the
1110
+ // encoding's own line, and that the download had received it was visible
1111
+ // nowhere at all — so "is the map reaching the swarm" could only be taken
1112
+ // on trust. Field 2026-09-08: not one line about it in a whole session.
1113
+ //
1114
+ // Per LEVEL rather than per zone, because the register has five levels and
1115
+ // the map has as many bands as the film needs; the fit between them is the
1116
+ // one thing here that could be wrong, and this is what shows it. Megabytes,
1117
+ // because that is what a swarm delivers.
1118
+ const byLevel = new Map();
1119
+ for (const zone of ordered) {
1120
+ const level = levelOf(zone);
1121
+ const seconds = Math.max(0, zone.to - zone.from);
1122
+ const held = byLevel.get(level) ?? { zones: 0, megabytes: 0 };
1123
+ held.zones += 1;
1124
+ held.megabytes += (seconds / duration) * length / 1048576;
1125
+ byLevel.set(level, held);
1126
+ }
1127
+ const shape = [...byLevel.entries()]
1128
+ .sort((left, right) => right[0] - left[0])
1129
+ .map(([level, held]) => `${urgencyName(level)} ${held.zones} zone(s) ${held.megabytes.toFixed(0)}MB`)
1130
+ .join(", ");
1131
+ const said = `${fileIndex}:${shape}`;
1132
+ if (lastMapSaid.get(torrent) !== said) {
1133
+ lastMapSaid.set(torrent, said);
1134
+ logger.info(
1135
+ `torrent-pool: the swarm is told, for "${file.name}": ${shape || "nothing"} ` +
1136
+ `(${ordered.length} band(s) of the map, over ${Math.round(duration)}s of film)`
1137
+ );
1138
+ }
1102
1139
  }
1103
1140
 
1104
1141
  #reportStalledDownloads() {
@@ -228,12 +228,7 @@ export class TorrentWorkerClient {
228
228
  // Fail everything outstanding rather than leaving callers hanging: a dead
229
229
  // worker will never answer, and a stalled request is worse than an error
230
230
  // the loading flow can retry.
231
- const reason = new Error("Torrent worker stopped unexpectedly.");
232
- this.#caller.rejectAll(reason);
233
- for (const [, read] of this.#reads) {
234
- read.fail(reason);
235
- }
236
- this.#reads.clear();
231
+ this.#failEverythingOutstanding(new Error("Torrent worker stopped unexpectedly."));
237
232
  });
238
233
 
239
234
  // A worker that ENDS was, until now, not noticed at all: only `message` and
@@ -252,15 +247,35 @@ export class TorrentWorkerClient {
252
247
  `torrent-worker: thread ended on its own with code ${code} — nobody asked it to. ` +
253
248
  "Everything waiting on it is failed; the proxy has no torrent client until it is rebuilt."
254
249
  );
255
- const reason = new Error("Torrent worker ended unexpectedly.");
256
- this.#caller.rejectAll(reason);
257
- for (const [, read] of this.#reads) {
258
- read.fail(reason);
259
- }
260
- this.#reads.clear();
250
+ this.#failEverythingOutstanding(new Error("Torrent worker ended unexpectedly."));
261
251
  });
262
252
  }
263
253
 
254
+ /**
255
+ * A dead thread will never answer, so everything waiting on it is failed.
256
+ *
257
+ * INCLUDING THE FRAGMENT READS, which is what ffmpeg's input is. Both death
258
+ * handlers walked `#reads` alone and left `#fragmentReaders` untouched, so an
259
+ * encoder's input neither ended nor errored — it went quiet. Field 2026-08-31,
260
+ * three times: the thread died, both ffmpeg runs stayed ALIVE and stopped
261
+ * producing (167 `holding segment-00085.mp4 … encoder alive` lines), the
262
+ * browser drained its cushion and then retried a segment that would never
263
+ * exist, for ever, with nothing shown to the viewer.
264
+ *
265
+ * @param {Error} reason
266
+ */
267
+ #failEverythingOutstanding(reason) {
268
+ this.#caller.rejectAll(reason);
269
+ for (const [, read] of this.#reads) {
270
+ read.fail(reason);
271
+ }
272
+ this.#reads.clear();
273
+ for (const [, reader] of this.#fragmentReaders) {
274
+ reader.fail(reason);
275
+ }
276
+ this.#fragmentReaders.clear();
277
+ }
278
+
264
279
  /**
265
280
  * Add (or join) a torrent and register it under `sourceKey`.
266
281
  *
@@ -243,7 +243,7 @@ const supplyReportedAt = new Map();
243
243
  * Record one interruption and, at most twice a minute, say what it implies.
244
244
  *
245
245
  * The two figures are the whole of roadmap item 3: the speed a step must
246
- * sustain to survive this supply (`1 + worst wait / median interval`), and the
246
+ * sustain to survive this supply (`1 / (1 - the share of time lost)`), and the
247
247
  * smallest buffer that hides an interruption from the viewer. Both are printed
248
248
  * before either is USED, so the field says whether the arithmetic describes
249
249
  * reality before anything is decided by it.
@@ -265,7 +265,7 @@ const supplyReportedAt = new Map();
265
265
  * @param {string} infoHash
266
266
  * @param {string} fileName
267
267
  * @param {number} segmentSeconds - The session's own segment duration.
268
- * @returns {{ requiredSpeed: number, worstWaitSec: number, medianIntervalSec: number, samples: number, minimumBufferSec: number } | null}
268
+ * @returns {{ requiredSpeed: number, worstWaitSec: number, medianIntervalSec: number, lostShare: number, spanSec: number, lostSec: number, samples: number, minimumBufferSec: number } | null}
269
269
  */
270
270
  export function supplyFiguresFor(infoHash, fileName, segmentSeconds) {
271
271
  const history = supplyWaits.get(`${infoHash ?? "?"}/${fileName ?? "?"}`);
@@ -502,8 +502,13 @@ function noteSupplyWait(key, label, waitedMs) {
502
502
  // several waits. Saying how many of each is what makes the figure readable;
503
503
  // reporting the waits alone made `2 measured` look like two interruptions
504
504
  // 3 ms apart, and the demanded speed came out at 4422x.
505
- `to survive this swarm (worst stall ${demand.worstWaitSec.toFixed(2)}s, one every ` +
506
- `${demand.medianIntervalSec.toFixed(2)}s of running, ${demand.samples} stall(s) ` +
505
+ // THE NUMBERS THE FIGURE IS MADE OF, so a wrong one can be seen to be wrong.
506
+ // The share of time lost is what the speed now comes from; the worst stall
507
+ // and the typical gap are printed beside it because they are what the
508
+ // cushion is sized by and what the old formula divided one by the other.
509
+ `to survive this swarm (lost ${demand.lostSec.toFixed(2)}s of ${demand.spanSec.toFixed(2)}s ` +
510
+ `= ${(demand.lostShare * 100).toFixed(1)}%, worst stall ${demand.worstWaitSec.toFixed(2)}s, ` +
511
+ `one every ${demand.medianIntervalSec.toFixed(2)}s of running, ${demand.samples} stall(s) ` +
507
512
  `from ${demand.waits} wait(s)) — ` +
508
513
  `and the smallest buffer that hides it is ${buffer ? buffer.seconds.toFixed(1) : "?"}s` +
509
514
  // What steering the blocked piece onto another peer bought, as the
@@ -747,6 +747,11 @@ setInterval(() => {
747
747
  `piece-store "${stats.name.slice(0, 40)}" demand: ${demand.readers} reader(s) want ` +
748
748
  `${demand.unionPieces} piece(s) of ${demand.capacity} the store may hold ` +
749
749
  `(widest window ${demand.widestPieces})` +
750
+ // NAMED, because the count read as five encoders on a session that had
751
+ // two: a "reader" is whoever declared a range, and four of the five
752
+ // were zones of the priority map. Choosing between narrowing the
753
+ // windows and raising the allowance was guesswork without this.
754
+ ` [${demand.names.join(" ")}]` +
750
755
  (stats.evictedProtected > 0
751
756
  ? `; ${stats.evictedProtected} of ${stats.spills} eviction(s) took a piece a reader had declared`
752
757
  : "; no eviction has taken a declared piece") +