@torrent-tv/proxy 2.29.0 → 2.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 2.30.1
2
+
3
+ - **Fix**: A seek was undone a second after it was made. Measured 2026-08-17: the viewer jumped to 2083.4 s, both runs restarted at segment #373 — correctly — and then a request for #371, issued by the player BEFORE the jump and reissued a second later, dragged the encoder back to #370. The viewer sat at #374 waiting for it to return. Two things let that happen, and both are fixed. The behind-head repair refuses a request that is behind the position the VIEWER themselves reported: its existing guard only holds while a seek is still settling, which by then it was not. And a segment request may no longer move the recorded viewer position BACKWARDS past a reported seek — playback only ever moves forward from one, so nothing legitimate is lost, while a stale request can no longer rewrite the viewer's own statement, which is how the repair came to believe it. A reported seek is the viewer stating where they are; a request is evidence about where the player is reading, and evidence may refine a statement forward, never contradict it backwards. Pinned by `test/stale-request-after-seek.test.js`, whose control case shows the same traffic still repairing a genuinely misplaced run when the viewer has said nothing.
4
+
5
+ ## 2.30.0
6
+
7
+ - **New**: The speed a step must sustain, and the smallest buffer that hides an interruption, are now COMPUTED from the supply's own behaviour instead of being chosen by hand — printed first, used later. A step producing at `v` gains `v - 1` seconds of cushion per second and an interruption of `W` seconds costs `W`, so it survives its own supply only while `(v - 1) × T > W`, that is `v > 1 + W / T`, with `W` the worst recent wait for a piece and `T` the median interval between such waits. On the field torrent of 2026-08-17 that is **2.42x**, against the 1.5 assumed today and the 1.05 measured on the step that stalled; on the same file's copied stream it is 1.31 against 8x measured, which is why a copy never stalls. The buffer follows from the same readings: one whole segment — the one being played — plus the worst interruption that can arrive before it refills, whichever source it comes from, which was **7-9 s** where the browser waits for 25. Both figures are logged per file every half minute, so the next session says whether the arithmetic describes reality BEFORE anything is decided by it. The arithmetic is a pure module with the field session's own numbers as its tests (`services/supply-margin.js`).
8
+
1
9
  ## 2.29.0
2
10
 
3
11
  - **New**: A piece a reader is blocked on is handed to the fastest peers that hold it. Measured 2026-08-17: the swarm delivered 5.1-5.9 MB/s against a film consumed at about 1 MB/s — a fivefold surplus — and the reader still blocked 47 times in two minutes, 1.0-4.5 s each, on pieces a median of five peers already had. A block belongs to exactly one wire, so the read ends when the SLOWEST holder delivers, and `critical()` only lets the library take a block from a slow wire when its own picker happens to visit an idle one. This asks for it deliberately: when the wait starts, and again on the sampling tick that already runs while it lasts, the piece is pushed onto the three fastest unchoked holders through the library's own request entry with hotswap enabled. Nothing is duplicated — the library moves a block to a wire at least twice as fast, which bounds how often it can move at all. A refusal is counted rather than ignored (a full pipeline, or nothing reservable even with hotswap, means the piece waits on the wire and not on the picker), and a build that offers no such entry says so instead of failing silently. The wait line now reports `steered onto N of M holders`, so the next session says by number whether the tail shortened.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.29.0",
3
+ "version": "2.30.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -4263,6 +4263,26 @@ export class HlsSessionManager {
4263
4263
  if (session.seekSettleTimer != null) {
4264
4264
  return;
4265
4265
  }
4266
+ // And it outranks it AFTERWARDS too, which is what was missing. The guard
4267
+ // above only holds while the settle timer is armed — a second later it is
4268
+ // gone, and a request the browser issued BEFORE the seek is then treated as
4269
+ // fresh evidence. Field 2026-08-17: a seek to 2083.4 s put both runs at
4270
+ // #373, a request for #371 from before it arrived a second afterwards, and
4271
+ // this repair moved the encoder to #370 — three segments behind the viewer,
4272
+ // who waited for it to come back. A request BEHIND what the viewer
4273
+ // themselves reported cannot be describing where they are.
4274
+ const reportedSeconds = Number(session.viewerReportedSeconds);
4275
+ if (Number.isFinite(reportedSeconds)) {
4276
+ const reportedIndex = this.#segmentIndexForTime(session, reportedSeconds);
4277
+ if (index < reportedIndex) {
4278
+ this.#explainHold(
4279
+ session,
4280
+ session.segmentFormat.segmentFileName(index),
4281
+ `it is behind #${reportedIndex}, where the viewer said they are — answered, not obeyed`
4282
+ );
4283
+ return;
4284
+ }
4285
+ }
4266
4286
  // What separates a request the viewer is waiting for from the player
4267
4287
  // scanning the playlist is not TIME but what else it is asking for. On a
4268
4288
  // seek hls.js fires dozens of DIFFERENT indices within half a second (field
@@ -4370,6 +4390,14 @@ export class HlsSessionManager {
4370
4390
  // The browser holds one session id for the whole file and knows nothing of
4371
4391
  // variants, so a seek it reports means the stream on screen.
4372
4392
  named.viewerPositionSeconds = positionSeconds;
4393
+ // What the viewer SAID, kept apart from what requests imply. A request is
4394
+ // evidence about where the player is reading; a reported seek is the viewer
4395
+ // stating where they are, and after one, requests already in flight
4396
+ // describe a place that no longer exists. Field 2026-08-17: a seek to
4397
+ // 2083.4 s restarted both runs at #373, a request for #371 issued before it
4398
+ // arrived a second later, and the encoder was dragged back to #370 — three
4399
+ // segments behind the viewer, who then waited for it to return.
4400
+ named.viewerReportedSeconds = positionSeconds;
4373
4401
  named.lastAccessedAt = Date.now();
4374
4402
  // The audio the viewer is listening to moves with them. It is a separate
4375
4403
  // encoder on a separate session that the browser cannot name, and nothing
@@ -4415,6 +4443,7 @@ export class HlsSessionManager {
4415
4443
  return false;
4416
4444
  }
4417
4445
  session.viewerPositionSeconds = positionSeconds;
4446
+ session.viewerReportedSeconds = positionSeconds;
4418
4447
  session.lastAccessedAt = Date.now();
4419
4448
  // Every segment request being held right now was made for the position the
4420
4449
  // viewer has just left. Release them: hls.js keeps ONE fragment load
@@ -7021,7 +7050,16 @@ export class HlsSessionManager {
7021
7050
  // read when a quality change has to place the next variant's first
7022
7051
  // encode run. The freshest evidence wins: a seek overwrites this, and
7023
7052
  // the first request after the seek overwrites it back.
7024
- session.viewerPositionSeconds = this.#segmentStartTime(session, requested);
7053
+ // A request refines this only FORWARD of what the viewer reported.
7054
+ // Playback always moves forward from a seek, so nothing legitimate is
7055
+ // lost — while a stale request from before the seek can no longer
7056
+ // rewrite the viewer's own statement, which is what let the repair
7057
+ // below drag the encoder backwards.
7058
+ const requestedStart = this.#segmentStartTime(session, requested);
7059
+ const reported = Number(session.viewerReportedSeconds);
7060
+ if (!Number.isFinite(reported) || requestedStart >= reported) {
7061
+ session.viewerPositionSeconds = requestedStart;
7062
+ }
7025
7063
  // A viewer who has caught up must not wait out the monitor's interval —
7026
7064
  // but only if they HAVE caught up, which is why this re-evaluates the
7027
7065
  // same condition instead of resuming outright.
@@ -0,0 +1,159 @@
1
+ /**
2
+ * @file What speed a step must run at, and how much buffer a viewer needs —
3
+ * both derived from the supply's own interruptions rather than chosen by hand.
4
+ *
5
+ * The two numbers this replaces were guesses. The speed margin was 1.5, and on
6
+ * the field session of 2026-08-17 a step admitted by it ran at 1.05x and
7
+ * stalled; the pre-buffer target was 25 s, which is sixteen seconds of waiting
8
+ * before the picture starts that nobody had shown to be necessary.
9
+ *
10
+ * Both follow from the same two measured quantities, and from nothing else:
11
+ *
12
+ * W — how long a read waits for a piece it needs (the worst recent one);
13
+ * T — how long there is between such waits (the median recent interval).
14
+ *
15
+ * **The margin.** A step producing at speed `v` gains `v - 1` seconds of
16
+ * cushion for every second it runs, and an interruption of `W` seconds costs
17
+ * `W`. It therefore survives its own supply only if what it gains between
18
+ * interruptions exceeds what one costs:
19
+ *
20
+ * (v - 1) × T > W i.e. v > 1 + W / T
21
+ *
22
+ * On the field torrent: waits every 2.22 s, worst 3.16 s, so the honest bar is
23
+ * 2.42 — against the 1.5 that was assumed, and the 1.05 that was measured. The
24
+ * same arithmetic explains why a copied stream never stalls: at 8x it gains
25
+ * 15.5 s between interruptions and loses at most 4.8 s.
26
+ *
27
+ * **The buffer.** It must cover the worst interruption that can arrive before
28
+ * it can be refilled, whichever source that interruption comes from, plus the
29
+ * segment being played — which must be whole:
30
+ *
31
+ * B = segment duration + max(W_supply, D_production, T_transfer)
32
+ *
33
+ * On the same session that is 7-9 s rather than 25. It rises by itself when a
34
+ * session's interruptions grow, which is what makes it safe to lower: the
35
+ * figure is continuously measured, not chosen once.
36
+ *
37
+ * Every quantity here is measured. Nothing in this file is a coefficient, and
38
+ * nothing smooths, weights or decays anything.
39
+ */
40
+
41
+ /**
42
+ * One measured interruption: how long a read waited, and when the wait ended.
43
+ *
44
+ * @typedef {object} SupplyWait
45
+ * @property {number} waitedMs - How long the read was blocked.
46
+ * @property {number} at - Wall-clock ms when the wait ended.
47
+ */
48
+
49
+ /**
50
+ * The speed a step must sustain to survive this file's supply on this swarm.
51
+ *
52
+ * Returns null when the evidence does not exist yet — fewer than two waits
53
+ * means no interval has been observed, and an interval invented from one point
54
+ * would be exactly the kind of number this file exists to remove. A caller with
55
+ * null must say it does not know, never substitute a default.
56
+ *
57
+ * @param {SupplyWait[]} waits - Recent waits, in any order.
58
+ * @returns {{ requiredSpeed: number, worstWaitSec: number, medianIntervalSec: number, samples: number } | null}
59
+ */
60
+ export function requiredSpeedFrom(waits) {
61
+ const ordered = usableWaits(waits);
62
+ if (ordered.length < 2) {
63
+ return null;
64
+ }
65
+ const worstWaitSec = Math.max(...ordered.map((wait) => wait.waitedMs)) / 1000;
66
+ const intervals = [];
67
+ for (let index = 1; index < ordered.length; index += 1) {
68
+ const gapMs = ordered[index].at - ordered[index - 1].at;
69
+ if (gapMs > 0) {
70
+ intervals.push(gapMs / 1000);
71
+ }
72
+ }
73
+ if (intervals.length === 0) {
74
+ return null;
75
+ }
76
+ const medianIntervalSec = median(intervals);
77
+ if (!(medianIntervalSec > 0)) {
78
+ return null;
79
+ }
80
+ return {
81
+ requiredSpeed: 1 + worstWaitSec / medianIntervalSec,
82
+ worstWaitSec,
83
+ medianIntervalSec,
84
+ samples: ordered.length
85
+ };
86
+ }
87
+
88
+ /**
89
+ * The smallest buffer at which no interruption reaches the viewer.
90
+ *
91
+ * Each term is the worst OBSERVED value over a recent window, and a term with
92
+ * nothing observed contributes nothing rather than a guess. The segment is
93
+ * always included: the one being played has to be whole.
94
+ *
95
+ * @param {object} observed
96
+ * @param {number} observed.segmentSeconds - The session's segment duration.
97
+ * @param {number} [observed.worstSupplyWaitSec] - Longest wait for a piece.
98
+ * @param {number} [observed.worstProductionGapSec] - Longest gap between
99
+ * consecutive segments beyond their own length: what a step at 1.0x costs.
100
+ * @param {number} [observed.worstTransferSec] - Longest time to move one
101
+ * segment over the channel to the viewer.
102
+ * @returns {{ seconds: number, from: string } | null} Null when the segment
103
+ * duration is unknown, since then nothing here can be stated.
104
+ */
105
+ export function minimumBufferFrom(observed = {}) {
106
+ const segmentSeconds = Number(observed.segmentSeconds);
107
+ if (!Number.isFinite(segmentSeconds) || segmentSeconds <= 0) {
108
+ return null;
109
+ }
110
+ const terms = [
111
+ { name: "supply", seconds: positiveOrZero(observed.worstSupplyWaitSec) },
112
+ { name: "production", seconds: positiveOrZero(observed.worstProductionGapSec) },
113
+ { name: "transfer", seconds: positiveOrZero(observed.worstTransferSec) }
114
+ ];
115
+ const worst = terms.reduce(
116
+ (largest, term) => (term.seconds > largest.seconds ? term : largest),
117
+ { name: "none", seconds: 0 }
118
+ );
119
+ return {
120
+ seconds: segmentSeconds + worst.seconds,
121
+ // Which interruption sets the figure, so a session's log says what the
122
+ // viewer is actually waiting for rather than only how long.
123
+ from: worst.name
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Waits that can be reasoned about, oldest first.
129
+ *
130
+ * @param {SupplyWait[]} waits
131
+ * @returns {SupplyWait[]}
132
+ */
133
+ function usableWaits(waits) {
134
+ if (!Array.isArray(waits)) {
135
+ return [];
136
+ }
137
+ return waits
138
+ .filter((wait) => Number.isFinite(wait?.waitedMs) && wait.waitedMs > 0 && Number.isFinite(wait?.at))
139
+ .sort((left, right) => left.at - right.at);
140
+ }
141
+
142
+ /**
143
+ * @param {number[]} values - Non-empty.
144
+ * @returns {number}
145
+ */
146
+ function median(values) {
147
+ const sorted = [...values].sort((left, right) => left - right);
148
+ const middle = Math.floor(sorted.length / 2);
149
+ return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
150
+ }
151
+
152
+ /**
153
+ * @param {unknown} value
154
+ * @returns {number}
155
+ */
156
+ function positiveOrZero(value) {
157
+ const numeric = Number(value);
158
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
159
+ }
@@ -23,6 +23,7 @@
23
23
  import { findSharedStore } from "../piece-store/shared-piece-store.js";
24
24
  import { logger } from "../../utils/logger.js";
25
25
  import { askFastestWiresFor, canPlaceRequests } from "./fastest-wires.js";
26
+ import { minimumBufferFrom, requiredSpeedFrom } from "../supply-margin.js";
26
27
 
27
28
  /** Only waits at least this long are reported; sequential reading stays silent. */
28
29
  const PIECE_WAIT_LOG_MS = 1_000;
@@ -304,6 +305,77 @@ function whenPieceReady(torrent, index, cancellation) {
304
305
  * the media's byte rate should size it in seconds of playback instead.
305
306
  * @returns {AsyncGenerator<PieceFragment>}
306
307
  */
308
+ /**
309
+ * The last interruptions this file's readers met, newest last.
310
+ *
311
+ * Bounded and per file, because both figures derived from it describe THIS
312
+ * file on THIS swarm: a piece is 8 MiB here and 512 KiB elsewhere, and a swarm
313
+ * that answers in 200 ms today may not tomorrow. Nothing is stored beyond the
314
+ * process — a restart starts from no evidence, which is the honest state.
315
+ *
316
+ * @type {Map<string, Array<{ waitedMs: number, at: number }>>}
317
+ */
318
+ const supplyWaits = new Map();
319
+
320
+ /** How many interruptions are kept per file. */
321
+ const SUPPLY_WAIT_HISTORY = 40;
322
+
323
+ /** How often the derived figures are printed, at most. */
324
+ const SUPPLY_REPORT_INTERVAL_MS = 30_000;
325
+
326
+ /** When each file's figures were last printed. */
327
+ const supplyReportedAt = new Map();
328
+
329
+ /**
330
+ * Record one interruption and, at most twice a minute, say what it implies.
331
+ *
332
+ * The two figures are the whole of roadmap item 3: the speed a step must
333
+ * sustain to survive this supply (`1 + worst wait / median interval`), and the
334
+ * smallest buffer that hides an interruption from the viewer. Both are printed
335
+ * before either is USED, so the field says whether the arithmetic describes
336
+ * reality before anything is decided by it.
337
+ *
338
+ * @param {string} key - Something stable per file.
339
+ * @param {string} label - What to call it in the log.
340
+ * @param {number} waitedMs
341
+ * @returns {void}
342
+ */
343
+ function noteSupplyWait(key, label, waitedMs) {
344
+ const history = supplyWaits.get(key) ?? [];
345
+ history.push({ waitedMs, at: Date.now() });
346
+ while (history.length > SUPPLY_WAIT_HISTORY) {
347
+ history.shift();
348
+ }
349
+ supplyWaits.set(key, history);
350
+
351
+ const now = Date.now();
352
+ if (now - (supplyReportedAt.get(key) ?? 0) < SUPPLY_REPORT_INTERVAL_MS) {
353
+ return;
354
+ }
355
+ const demand = requiredSpeedFrom(history);
356
+ if (!demand) {
357
+ return;
358
+ }
359
+ supplyReportedAt.set(key, now);
360
+ const buffer = minimumBufferFrom({
361
+ segmentSeconds: SEGMENT_SECONDS_FOR_BUFFER,
362
+ worstSupplyWaitSec: demand.worstWaitSec
363
+ });
364
+ logger.info(
365
+ `supply "${label.slice(0, 40)}": a step must run at ${demand.requiredSpeed.toFixed(2)}x ` +
366
+ `to survive this swarm (worst wait ${demand.worstWaitSec.toFixed(2)}s, one every ` +
367
+ `${demand.medianIntervalSec.toFixed(2)}s, ${demand.samples} measured) — ` +
368
+ `and the smallest buffer that hides it is ${buffer ? buffer.seconds.toFixed(1) : "?"}s`
369
+ );
370
+ }
371
+
372
+ /**
373
+ * The segment length the buffer figure is stated against. The reader does not
374
+ * know the session's own, and this is a REPORT rather than a decision — the
375
+ * decision, when it is made, will use the session's real one.
376
+ */
377
+ const SEGMENT_SECONDS_FOR_BUFFER = 4;
378
+
307
379
  export async function* readFragments({
308
380
  torrent,
309
381
  fileIndex,
@@ -495,6 +567,7 @@ export async function* readFragments({
495
567
  // short, an immediate hit means it is longer than it needs to be. Applied
496
568
  // before the logging below so the line reports the window the next piece
497
569
  // will actually use.
570
+ noteSupplyWait(`${torrent?.infoHash ?? "?"}/${file?.name ?? "?"}`, file?.name ?? "", waitedMs);
498
571
  const widened = nextWindowPieces({
499
572
  current: windowPieces,
500
573
  base: basePieces,
@@ -0,0 +1,155 @@
1
+ /**
2
+ * @file What the supply's own interruptions require of a quality step, and of
3
+ * the buffer in front of the viewer.
4
+ *
5
+ * Both figures are chosen by hand today — a speed margin of 1.5 and a 25-second
6
+ * prebuffer — and both stand for a quantity that is measured every few seconds
7
+ * anyway: how long a read waits for a piece, and how often that happens.
8
+ *
9
+ * The arithmetic, from the measurements of 2026-08-17:
10
+ *
11
+ * A step producing at speed `v` gains `v - 1` seconds of cushion per second
12
+ * of playback. An interruption of `W` seconds costs `W`. So a step survives
13
+ * its own supply exactly when it can rebuild what one interruption takes
14
+ * before the next one arrives:
15
+ *
16
+ * (v - 1) × T > W ⇔ v > 1 + W / T
17
+ *
18
+ * On the field torrent that day: waits of 1.49 s median, 3.16 s worst, one
19
+ * every 2.22 s → a required speed of 1.67 against the 1.5 chosen by hand, and
20
+ * against 1.05 actually measured, which is why it stalled. A copy running at
21
+ * 8x gains 15.5 s between interruptions against 1.5-4.8 s lost, which is the
22
+ * same formula explaining why a copy never stalls.
23
+ *
24
+ * The buffer follows from the same numbers: it must hold the segment being
25
+ * played, whole, plus the worst interruption that can arrive before it can be
26
+ * refilled — whichever source that interruption comes from.
27
+ *
28
+ * B_min = segment duration + max(W_supply, D_production, T_transfer)
29
+ *
30
+ * 7-9 s on that torrent, against the 25 s in the browser today.
31
+ *
32
+ * Every term is measured, none is chosen, and the figures rise by themselves
33
+ * when a session's interruptions grow — which is what makes lowering the buffer
34
+ * safe. Pure: no torrent, no clock of its own, no state beyond the samples it
35
+ * is given.
36
+ */
37
+
38
+ /**
39
+ * How many recent interruptions are kept per file. Enough for a median to mean
40
+ * something, short enough that a swarm which has recovered is not judged by how
41
+ * it behaved ten minutes ago.
42
+ */
43
+ const SAMPLE_LIMIT = 24;
44
+
45
+ /**
46
+ * A reading of one interruption: how long the reader waited, and when.
47
+ *
48
+ * @typedef {object} Interruption
49
+ * @property {number} waitedMs
50
+ * @property {number} at - Epoch milliseconds when the wait ENDED.
51
+ */
52
+
53
+ /**
54
+ * Add one interruption to a record, keeping only the recent ones.
55
+ *
56
+ * @param {Interruption[]} samples - Existing readings, oldest first.
57
+ * @param {Interruption} interruption
58
+ * @returns {Interruption[]} A new array; the input is not modified.
59
+ */
60
+ export function withInterruption(samples, interruption) {
61
+ const kept = Array.isArray(samples) ? samples : [];
62
+ if (!Number.isFinite(interruption?.waitedMs) || !Number.isFinite(interruption?.at)) {
63
+ return kept;
64
+ }
65
+ const next = [...kept, { waitedMs: Math.max(0, interruption.waitedMs), at: interruption.at }];
66
+ return next.length > SAMPLE_LIMIT ? next.slice(next.length - SAMPLE_LIMIT) : next;
67
+ }
68
+
69
+ /**
70
+ * @param {number[]} values
71
+ * @returns {number}
72
+ */
73
+ function median(values) {
74
+ if (values.length === 0) {
75
+ return 0;
76
+ }
77
+ const sorted = [...values].sort((left, right) => left - right);
78
+ return sorted[Math.floor(sorted.length / 2)];
79
+ }
80
+
81
+ /**
82
+ * What the recent interruptions amount to.
83
+ *
84
+ * The interval between interruptions is measured between the readings
85
+ * themselves, so a file that is read steadily and rarely blocked reports a long
86
+ * interval and asks little of the step.
87
+ *
88
+ * @param {Interruption[]} samples
89
+ * @returns {{ samples: number, medianWaitSeconds: number, worstWaitSeconds: number, medianGapSeconds: number }}
90
+ */
91
+ export function summariseInterruptions(samples) {
92
+ const readings = Array.isArray(samples) ? samples : [];
93
+ if (readings.length === 0) {
94
+ return { samples: 0, medianWaitSeconds: 0, worstWaitSeconds: 0, medianGapSeconds: 0 };
95
+ }
96
+ const waits = readings.map((entry) => entry.waitedMs / 1000);
97
+ const gaps = [];
98
+ for (let index = 1; index < readings.length; index += 1) {
99
+ const gap = (readings[index].at - readings[index - 1].at) / 1000;
100
+ if (gap > 0) {
101
+ gaps.push(gap);
102
+ }
103
+ }
104
+ return {
105
+ samples: readings.length,
106
+ medianWaitSeconds: median(waits),
107
+ worstWaitSeconds: Math.max(...waits),
108
+ medianGapSeconds: median(gaps)
109
+ };
110
+ }
111
+
112
+ /**
113
+ * The speed a step must run at to survive this supply, or null when the supply
114
+ * has not interrupted often enough to say.
115
+ *
116
+ * Null is a real answer and must not be replaced by a number: with one reading
117
+ * there is no interval at all, and inventing one is how a margin comes to be
118
+ * chosen by hand again. A caller with no figure keeps whatever it used before
119
+ * and says so.
120
+ *
121
+ * The worst wait is used rather than the median, because a step that only
122
+ * survives the typical interruption stalls on the others — and a stall is what
123
+ * the viewer sees, not an average.
124
+ *
125
+ * @param {{ samples: number, worstWaitSeconds: number, medianGapSeconds: number }} summary
126
+ * @returns {number | null}
127
+ */
128
+ export function requiredSpeedFrom(summary) {
129
+ if (!summary || summary.samples < 2 || !(summary.medianGapSeconds > 0)) {
130
+ return null;
131
+ }
132
+ return 1 + summary.worstWaitSeconds / summary.medianGapSeconds;
133
+ }
134
+
135
+ /**
136
+ * The smallest buffer at which no spinner appears: the segment being played,
137
+ * whole, plus the worst interruption that can arrive before it can be refilled.
138
+ *
139
+ * Each term is the worst OBSERVED over a recent window, and a term nobody has
140
+ * measured contributes nothing rather than a guess.
141
+ *
142
+ * @param {{ segmentSeconds: number, supplySeconds?: number, productionSeconds?: number, transferSeconds?: number }} terms
143
+ * @returns {number}
144
+ */
145
+ export function minimumBufferSeconds(terms) {
146
+ const segment = Number.isFinite(terms?.segmentSeconds) && terms.segmentSeconds > 0
147
+ ? terms.segmentSeconds
148
+ : 0;
149
+ const worst = Math.max(
150
+ Number.isFinite(terms?.supplySeconds) ? terms.supplySeconds : 0,
151
+ Number.isFinite(terms?.productionSeconds) ? terms.productionSeconds : 0,
152
+ Number.isFinite(terms?.transferSeconds) ? terms.transferSeconds : 0
153
+ );
154
+ return segment + Math.max(0, worst);
155
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * @file A request issued before a seek must not steer the encoder.
3
+ *
4
+ * Field 2026-08-17: the viewer seeked to 2083.4 s, both runs restarted at
5
+ * segment #373, and a request for #371 — issued before the seek and reissued by
6
+ * the player a second later — moved the encoder to #370. The viewer was at
7
+ * #374 and waited for the encoder to come back to them.
8
+ *
9
+ * The rule pinned here: a reported seek is the viewer STATING where they are; a
10
+ * segment request is evidence about where the player is reading. Evidence may
11
+ * refine a statement forward, never contradict it backwards.
12
+ *
13
+ * Both cases go through `getFileStream`, the way production reaches the repair,
14
+ * and the second is the control: without a reported seek the very same traffic
15
+ * DOES move the encoder, which is what makes the first case a measurement of
16
+ * the guard rather than of the weather.
17
+ */
18
+
19
+ import assert from "node:assert/strict";
20
+ import test from "node:test";
21
+ import { mkdtemp, rm } from "node:fs/promises";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+
25
+ import { HlsSessionManager } from "../services/hls-session-manager.js";
26
+ import { ENCODE_RUN_STATE } from "../services/encode-run-state.js";
27
+ import { fmp4Format } from "../services/segment-formats/fmp4.js";
28
+
29
+ const SEGMENT_SECONDS = 4;
30
+ const RUN_STARTS_AT = 373;
31
+ const BEHIND_INDEX = 371;
32
+ const SESSION_ID = "22222222-3333-4444-5555-666666666666";
33
+
34
+ /**
35
+ * A live session whose run begins at #373 and whose directory is empty, so any
36
+ * segment request is a request for something not yet produced.
37
+ *
38
+ * @returns {Promise<{ manager: HlsSessionManager, session: object, dirPath: string }>}
39
+ */
40
+ async function sessionWithRunAt373() {
41
+ const dirPath = await mkdtemp(path.join(os.tmpdir(), "stale-seek-"));
42
+ const manager = new HlsSessionManager({
43
+ enabled: true,
44
+ ffmpegBin: "ffmpeg",
45
+ localBindHost: "127.0.0.1",
46
+ localPort: 9090
47
+ });
48
+ const boundaries = [];
49
+ for (let index = 0; index <= 600; index += 1) {
50
+ boundaries.push(index * SEGMENT_SECONDS);
51
+ }
52
+ const session = {
53
+ id: SESSION_ID,
54
+ dirPath,
55
+ state: "ready",
56
+ runState: ENCODE_RUN_STATE.PRODUCING,
57
+ fileName: "film.mkv",
58
+ createEntryMs: Date.now(),
59
+ lastAccessedAt: Date.now(),
60
+ consumers: new Set(),
61
+ segmentFormat: fmp4Format,
62
+ usesExplicitCuts: true,
63
+ useSyntheticPlaylist: true,
64
+ playlistText: "#EXTM3U\n",
65
+ segmentBoundaries: boundaries,
66
+ segmentCount: boundaries.length - 1,
67
+ encodeStartIndex: RUN_STARTS_AT,
68
+ // A live process: the repair refuses outright when nothing is encoding.
69
+ ffmpeg: { pid: 1234, killed: false, exitCode: null, signalCode: null, kill() { this.killed = true; } },
70
+ encodeRunGeneration: 0,
71
+ runSerial: 1,
72
+ behindHeadAsks: new Map(),
73
+ firstWantedAt: new Map(),
74
+ holdExplainedAt: new Map(),
75
+ seekSettleTimer: null,
76
+ seekTarget: null,
77
+ seekFailureTarget: -1,
78
+ seekFailureCount: 0,
79
+ waitEpoch: 0,
80
+ firstSegmentLogged: true,
81
+ progress: { processedSeconds: RUN_STARTS_AT * SEGMENT_SECONDS, speed: "1.0x", startPositionSeconds: RUN_STARTS_AT * SEGMENT_SECONDS }
82
+ };
83
+ manager.sessionsById.set(SESSION_ID, session);
84
+ return { manager, session, dirPath };
85
+ }
86
+
87
+ /**
88
+ * The traffic that dragged the encoder back: the same index asked for twice,
89
+ * first wanted long enough ago to pass the repair's patience guard.
90
+ *
91
+ * @param {object} session
92
+ */
93
+ function askedTwiceLongEnough(session) {
94
+ session.firstWantedAt.set(BEHIND_INDEX, Date.now() - 5_000);
95
+ session.behindHeadAsks.set(BEHIND_INDEX, { count: 3, at: Date.now() });
96
+ }
97
+
98
+ /**
99
+ * Put the session down without going through disposal.
100
+ *
101
+ * Disposal signals the encoder and waits for it to die, which a stub cannot do
102
+ * — and none of that is what these tests are about. Clearing the map and any
103
+ * armed timer leaves nothing running.
104
+ *
105
+ * @param {HlsSessionManager} manager
106
+ * @param {object} session
107
+ * @param {string} dirPath
108
+ * @returns {Promise<void>}
109
+ */
110
+ async function tidy(manager, session, dirPath) {
111
+ if (session.seekSettleTimer) {
112
+ clearTimeout(session.seekSettleTimer);
113
+ session.seekSettleTimer = null;
114
+ }
115
+ manager.sessionsById.clear();
116
+ manager.stop?.();
117
+ await rm(dirPath, { recursive: true, force: true });
118
+ }
119
+
120
+ test("a request behind where the viewer said they are does not move the encoder", async (t) => {
121
+ const { manager, session, dirPath } = await sessionWithRunAt373();
122
+ t.after(async () => {
123
+ await tidy(manager, session, dirPath);
124
+ });
125
+
126
+ // The viewer stated their position: 2083.4 s, which is segment #520 here.
127
+ manager.requestSeek(SESSION_ID, 2083.4);
128
+ session.encodeStartIndex = RUN_STARTS_AT;
129
+ askedTwiceLongEnough(session);
130
+
131
+ const answer = await manager.getFileStream(
132
+ SESSION_ID,
133
+ fmp4Format.segmentFileName(BEHIND_INDEX),
134
+ { requestSeq: 1 }
135
+ );
136
+
137
+ assert.equal(answer.kind, "warming-up", "the request is answered, not obeyed");
138
+ // The viewer's own seek legitimately armed a move to #519. What must NOT
139
+ // happen is the stale request replacing that with #370 — which is exactly
140
+ // what the field log shows: `seek settle → restart at segment #370`.
141
+ assert.notEqual(
142
+ session.seekTarget,
143
+ BEHIND_INDEX - 1,
144
+ "a request behind the viewer must not become the encoder's destination"
145
+ );
146
+ assert.equal(session.seekTarget, 519, "the viewer's own seek is what the encoder is going to");
147
+ });
148
+
149
+ test("the same traffic DOES move the encoder when the viewer has said nothing", async (t) => {
150
+ const { manager, session, dirPath } = await sessionWithRunAt373();
151
+ t.after(async () => {
152
+ await tidy(manager, session, dirPath);
153
+ });
154
+
155
+ // No reported seek: a run placed wrongly is exactly what the repair is for,
156
+ // and this is the case it must keep serving.
157
+ askedTwiceLongEnough(session);
158
+
159
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(BEHIND_INDEX), { requestSeq: 1 });
160
+
161
+ assert.equal(
162
+ session.seekTarget,
163
+ BEHIND_INDEX - 1,
164
+ "with nothing said by the viewer, a request stuck behind the head still repairs the run"
165
+ );
166
+ assert.notEqual(session.seekSettleTimer, null);
167
+ });
168
+
169
+ test("a request cannot move the viewer's position backwards", async (t) => {
170
+ const { manager, session, dirPath } = await sessionWithRunAt373();
171
+ t.after(async () => {
172
+ await tidy(manager, session, dirPath);
173
+ });
174
+
175
+ manager.requestSeek(SESSION_ID, 2083.4);
176
+ await manager.getFileStream(SESSION_ID, fmp4Format.segmentFileName(BEHIND_INDEX), { requestSeq: 1 });
177
+
178
+ assert.equal(
179
+ session.viewerPositionSeconds,
180
+ 2083.4,
181
+ "a stale request must not rewrite what the viewer reported — that is how the repair came to believe it"
182
+ );
183
+ });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @file The speed a step needs, and the buffer a viewer needs, from what the
3
+ * supply actually did.
4
+ *
5
+ * The field numbers these are checked against (2026-08-17, one torrent, one
6
+ * session): waits of 1.49 s median and 3.16 s worst, one every 2.22 s. The
7
+ * margin chosen by hand was 1.5; the arithmetic says 1.67; the step measured
8
+ * 1.05 and stalled.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import test from "node:test";
13
+
14
+ import {
15
+ minimumBufferSeconds,
16
+ requiredSpeedFrom,
17
+ summariseInterruptions,
18
+ withInterruption
19
+ } from "../services/torrent-worker/supply-interruptions.js";
20
+
21
+ /**
22
+ * Interruptions of `waitMs`, one every `gapMs`.
23
+ *
24
+ * @param {number[]} waitsMs
25
+ * @param {number} gapMs
26
+ * @returns {Array<{ waitedMs: number, at: number }>}
27
+ */
28
+ function series(waitsMs, gapMs) {
29
+ let samples = [];
30
+ let at = 1_000_000;
31
+ for (const waitedMs of waitsMs) {
32
+ samples = withInterruption(samples, { waitedMs, at });
33
+ at += gapMs;
34
+ }
35
+ return samples;
36
+ }
37
+
38
+ test("the field session's numbers produce the field session's answer", () => {
39
+ // Waits around 1.49 s with a worst of 3.16 s, one every 2.22 s.
40
+ const samples = series([1490, 1490, 3160, 1490, 1490], 2220);
41
+ const summary = summariseInterruptions(samples);
42
+
43
+ assert.equal(summary.worstWaitSeconds, 3.16);
44
+ assert.equal(summary.medianGapSeconds, 2.22);
45
+
46
+ const required = requiredSpeedFrom(summary);
47
+ assert.ok(required !== null);
48
+ assert.ok(
49
+ Math.abs(required - 2.42) < 0.01,
50
+ `1 + 3.16/2.22 = ${required?.toFixed(2)} — the supply asks for it, nobody chose it`
51
+ );
52
+ });
53
+
54
+ test("a supply that rarely interrupts asks for almost nothing", () => {
55
+ // One short wait every couple of minutes: a step barely faster than realtime
56
+ // rebuilds the cushion long before the next one.
57
+ const summary = summariseInterruptions(series([200, 200, 200], 120_000));
58
+ const required = requiredSpeedFrom(summary);
59
+ assert.ok(required !== null && required < 1.01, `asked ${required}`);
60
+ });
61
+
62
+ test("a supply that interrupts constantly asks for a great deal", () => {
63
+ const summary = summariseInterruptions(series([4000, 4000, 4000], 1000));
64
+ assert.equal(requiredSpeedFrom(summary), 5);
65
+ });
66
+
67
+ test("too little evidence is answered with nothing, never with a number", () => {
68
+ // The whole point of deriving the margin is that it stops being invented. One
69
+ // reading has no interval at all, and a caller must keep what it had.
70
+ assert.equal(requiredSpeedFrom(summariseInterruptions([])), null);
71
+ assert.equal(requiredSpeedFrom(summariseInterruptions(series([1000], 0))), null);
72
+ assert.equal(requiredSpeedFrom(null), null);
73
+ assert.equal(requiredSpeedFrom({ samples: 9, worstWaitSeconds: 3, medianGapSeconds: 0 }), null);
74
+ });
75
+
76
+ test("the worst wait decides, not the typical one", () => {
77
+ // A step that survives the median interruption still stalls on the others,
78
+ // and a stall is what the viewer sees.
79
+ const summary = summariseInterruptions(series([100, 100, 5000, 100], 1000));
80
+ assert.equal(summary.medianWaitSeconds, 0.1);
81
+ assert.equal(summary.worstWaitSeconds, 5);
82
+ assert.equal(requiredSpeedFrom(summary), 6);
83
+ });
84
+
85
+ test("only the recent interruptions are judged", () => {
86
+ // A swarm that has recovered must not be sentenced by how it behaved ten
87
+ // minutes ago, so the record is bounded.
88
+ let samples = series(Array.from({ length: 40 }, () => 9000), 1000);
89
+ samples = withInterruption(samples, { waitedMs: 10, at: 2_000_000 });
90
+ assert.ok(samples.length <= 24, `kept ${samples.length}`);
91
+ assert.equal(samples[samples.length - 1].waitedMs, 10);
92
+ });
93
+
94
+ test("a reading without a time or a duration is not a reading", () => {
95
+ const samples = withInterruption([], { waitedMs: Number.NaN, at: 1 });
96
+ assert.deepEqual(samples, []);
97
+ assert.deepEqual(withInterruption([], { waitedMs: 100 }), []);
98
+ });
99
+
100
+ test("the minimum buffer is one segment plus the worst interruption", () => {
101
+ // The field torrent: 4 s segments, a worst supply wait of 3.16 s, production
102
+ // gaps within the segment length, transfer measured in milliseconds.
103
+ const seconds = minimumBufferSeconds({
104
+ segmentSeconds: 4,
105
+ supplySeconds: 3.16,
106
+ productionSeconds: 1.2,
107
+ transferSeconds: 0.066
108
+ });
109
+ assert.ok(Math.abs(seconds - 7.16) < 0.001, `${seconds}s against the 25 s chosen by hand`);
110
+ });
111
+
112
+ test("whichever source is worst is the one that sizes the buffer", () => {
113
+ // A step at 1.0x makes production the binding term even on a swarm that never
114
+ // stalls, which is exactly the case a supply-only figure would miss.
115
+ assert.equal(
116
+ minimumBufferSeconds({ segmentSeconds: 4, supplySeconds: 0.2, productionSeconds: 6 }),
117
+ 10
118
+ );
119
+ });
120
+
121
+ test("a term nobody measured contributes nothing, not a guess", () => {
122
+ assert.equal(minimumBufferSeconds({ segmentSeconds: 4 }), 4);
123
+ assert.equal(minimumBufferSeconds({ segmentSeconds: 4, supplySeconds: Number.NaN }), 4);
124
+ assert.equal(minimumBufferSeconds({}), 0);
125
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @file The margin and the buffer, checked against the session they were
3
+ * derived from.
4
+ *
5
+ * The figures in these tests are the field measurements of 2026-08-17, so a
6
+ * change that breaks the arithmetic fails against reality rather than against
7
+ * an example someone invented.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import test from "node:test";
12
+
13
+ import { minimumBufferFrom, requiredSpeedFrom } from "../services/supply-margin.js";
14
+
15
+ /**
16
+ * Waits spaced by `intervalSec`, each lasting `waitSec`.
17
+ *
18
+ * @param {number} count
19
+ * @param {number} intervalSec
20
+ * @param {number} waitSec
21
+ * @returns {Array<{ waitedMs: number, at: number }>}
22
+ */
23
+ function evenlySpaced(count, intervalSec, waitSec) {
24
+ const waits = [];
25
+ for (let index = 0; index < count; index += 1) {
26
+ waits.push({ waitedMs: waitSec * 1000, at: 1_000_000 + index * intervalSec * 1000 });
27
+ }
28
+ return waits;
29
+ }
30
+
31
+ test("the margin is what the supply's own interruptions demand", () => {
32
+ // The field torrent: a wait every 2.22 s, the worst of them 3.16 s.
33
+ const waits = evenlySpaced(10, 2.22, 1.49);
34
+ waits[4].waitedMs = 3160;
35
+
36
+ const answer = requiredSpeedFrom(waits);
37
+
38
+ assert.ok(answer);
39
+ assert.equal(answer.worstWaitSec, 3.16);
40
+ assert.equal(answer.medianIntervalSec, 2.22);
41
+ // 1 + 3.16 / 2.22 = 2.42. The step that was admitted by the hand-chosen 1.5
42
+ // ran at 1.05x and stalled; this is the bar it should have been held to.
43
+ assert.ok(Math.abs(answer.requiredSpeed - 2.4234) < 0.001, `got ${answer.requiredSpeed}`);
44
+ });
45
+
46
+ test("a copy at 8x clears its own supply with room to spare", () => {
47
+ // The same file's copied stream: waits up to 4.82 s, and it never stalled.
48
+ const waits = evenlySpaced(6, 15.5, 4.82);
49
+ const answer = requiredSpeedFrom(waits);
50
+ assert.ok(answer);
51
+ // 1 + 4.82/15.5 = 1.31, against 8x measured. Which is why a copy is the step
52
+ // a stranded viewer is always able to return to.
53
+ assert.ok(answer.requiredSpeed < 1.35, `got ${answer.requiredSpeed}`);
54
+ });
55
+
56
+ test("with too little evidence it says so instead of inventing a number", () => {
57
+ assert.equal(requiredSpeedFrom([]), null);
58
+ assert.equal(requiredSpeedFrom([{ waitedMs: 1500, at: 1 }]), null, "one wait shows no interval");
59
+ assert.equal(requiredSpeedFrom(null), null);
60
+ // Every wait at the same instant: no interval was observed, so no interval
61
+ // may be stated.
62
+ assert.equal(
63
+ requiredSpeedFrom([
64
+ { waitedMs: 1000, at: 5 },
65
+ { waitedMs: 1000, at: 5 }
66
+ ]),
67
+ null
68
+ );
69
+ });
70
+
71
+ test("readings that are not measurements are ignored, not averaged in", () => {
72
+ const answer = requiredSpeedFrom([
73
+ { waitedMs: 0, at: 1000 },
74
+ { waitedMs: Number.NaN, at: 2000 },
75
+ { waitedMs: 1000, at: 3000 },
76
+ { waitedMs: 2000, at: 5000 },
77
+ { waitedMs: 1500, at: 7000 }
78
+ ]);
79
+ assert.ok(answer);
80
+ assert.equal(answer.samples, 3, "only the three real waits count");
81
+ });
82
+
83
+ test("the buffer is one segment plus the worst interruption, and names which", () => {
84
+ const answer = minimumBufferFrom({
85
+ segmentSeconds: 4,
86
+ worstSupplyWaitSec: 3.16,
87
+ worstProductionGapSec: 1.2,
88
+ worstTransferSec: 0.066
89
+ });
90
+ assert.ok(answer);
91
+ assert.equal(answer.seconds, 7.16, "7.16 s against the 25 s that was chosen by hand");
92
+ assert.equal(answer.from, "supply");
93
+ });
94
+
95
+ test("a step that cannot keep up sets the buffer itself", () => {
96
+ const answer = minimumBufferFrom({
97
+ segmentSeconds: 4,
98
+ worstSupplyWaitSec: 1.4,
99
+ worstProductionGapSec: 6.5,
100
+ worstTransferSec: 0.05
101
+ });
102
+ assert.equal(answer.seconds, 10.5);
103
+ assert.equal(answer.from, "production", "the encoder, not the swarm, is what the viewer is waiting for");
104
+ });
105
+
106
+ test("a term with nothing observed contributes nothing", () => {
107
+ const answer = minimumBufferFrom({ segmentSeconds: 4 });
108
+ assert.equal(answer.seconds, 4, "one whole segment is the floor: the one being played");
109
+ assert.equal(answer.from, "none");
110
+ assert.equal(minimumBufferFrom({}), null, "without a segment duration nothing can be said");
111
+ assert.equal(minimumBufferFrom({ segmentSeconds: 0 }), null);
112
+ });