@torrent-tv/proxy 2.39.1 → 2.40.0

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.
@@ -1,755 +1,1114 @@
1
- /**
2
- * @file Reading a byte range as positions in shared memory, not as bytes.
3
- *
4
- * The pieces already live in a `SharedArrayBuffer` the main thread can map. So
5
- * the torrent thread does not need to hand over any bytes at all: it can say
6
- * *where* a piece sits and let the other side read it there. What crosses the
7
- * boundary is two numbers per piece.
8
- *
9
- * That is the whole point of the exercise. The alternative — copying each piece
10
- * into memory we own and transferring it — costs 18.84 ms per 10 MB segment on
11
- * the field host, and costs it **on the critical path**, in the thread that is
12
- * also running the torrent, at the moment a viewer is waiting for that segment.
13
- * Here the copy is gone entirely rather than moved.
14
- *
15
- * Two obligations come with it, and both are enforced rather than assumed:
16
- *
17
- * - a piece being read is **pinned**, so eviction cannot take the memory out
18
- * from under the reader mid-read;
19
- * - the pin is released only once the other thread reports it has finished
20
- * with those bytes — not when they were sent, because nothing was sent.
21
- */
22
-
23
- import { findSharedStore } from "../piece-store/shared-piece-store.js";
24
- import { logger } from "../../utils/logger.js";
25
- import { askFastestWiresFor, canPlaceRequests, describePieceTail } from "./fastest-wires.js";
26
- import { minimumBufferFrom, requiredSpeedFrom } from "../supply-margin.js";
27
-
28
- /** Only waits at least this long are reported; sequential reading stays silent. */
29
- const PIECE_WAIT_LOG_MS = 1_000;
30
-
31
- /** Distinguishes concurrent readers to the piece store. Never reused. */
32
- let readerSequence = 0;
33
-
34
- /**
35
- * How far ahead of the read head pieces are asked for.
36
- *
37
- * A read is open-ended — ffmpeg opens its input as `bytes <position>-<EOF>` and
38
- * keeps it for the whole film — so taking the requested range literally asks
39
- * for everything from the seek point to the end of the file at once. That is
40
- * what a seek used to do: the swarm was told the entire tail was wanted, went
41
- * at it from its first missing piece, and the one piece the decoder was blocked
42
- * on arrived only when the sequential scan reached it. Measured on a 4.7 GB
43
- * film: a seek to 89.1% took 93 s and pulled 2.47 GB.
44
- *
45
- * So the reader asks for a window and moves it as it goes. The size is a
46
- * compromise the caller cannot yet express: the right unit is seconds of
47
- * playback (duration and size are both known to the transcode session, not to
48
- * this thread), and 32 MB is about 34 s of a 1080p film but only a few seconds
49
- * of a disc remux. Sizing it from the real byte rate is a follow-up; what
50
- * matters here is that it is bounded and moving rather than "to the end".
51
- */
52
- const READ_WINDOW_BYTES = 32 * 1024 * 1024;
53
-
54
- /**
55
- * The pieces a reader at `pieceIndex` wants next, clamped to its own range.
56
- *
57
- * @param {{ pieceIndex: number, lastPiece: number, windowPieces: number }} params
58
- * @returns {{ from: number, to: number }}
59
- */
60
- export function readWindowFor({ pieceIndex, lastPiece, windowPieces }) {
61
- const span = Math.max(1, windowPieces);
62
- return { from: pieceIndex, to: Math.min(lastPiece, pieceIndex + span - 1) };
63
- }
64
-
65
- /**
66
- * How wide the window should be after a piece that made the reader wait — or
67
- * did not.
68
- *
69
- * The swarm's surplus is what pays for this. Measured 2026-08-17 on the field
70
- * torrent: 5.1-5.9 MB/s delivered against a film consumed at about 1 MB/s, and
71
- * the reader still blocked 47 times in two minutes, median 1.5 s, worst 4.5 s.
72
- * A fivefold surplus never became distance ahead of the head, because the
73
- * window is a fixed number of seconds of playback and everything past it is
74
- * ordinary background fill at no priority.
75
- *
76
- * So the window follows the evidence: every wait that mattered widens it by a
77
- * piece, every piece that was already there narrows it back toward the size the
78
- * caller asked for. Nothing here is chosen — the wait is measured, the
79
- * threshold is the one that already defines "a wait worth recording", and the
80
- * ceiling is this reader's share of the store's memory, so widening can never
81
- * cost more than the store can hold.
82
- *
83
- * @param {{ current: number, base: number, ceiling: number, waitedMs: number, waitThresholdMs: number }} params
84
- * @returns {number}
85
- */
86
- export function nextWindowPieces({ current, base, ceiling, waitedMs, waitThresholdMs }) {
87
- const floor = Math.max(1, Math.floor(base));
88
- const top = Math.max(floor, Math.floor(ceiling));
89
- const now = Math.min(top, Math.max(floor, Math.floor(current)));
90
- if (waitedMs >= waitThresholdMs) {
91
- return Math.min(top, now + 1);
92
- }
93
- return Math.max(floor, now - 1);
94
- }
95
-
96
- /**
97
- * Add this reader's window to the download set as a stream selection.
98
- *
99
- * `_select`/`_deselect` with the stream flag are what WebTorrent's own
100
- * `FileIterator` uses; there is no public call for it, because the public
101
- * `select` produces the merging, interval-subtracted kind whose bookkeeping
102
- * cannot express "one of several readers wants this". Falls back to the public
103
- * call if a future version drops the private one.
104
- *
105
- * @param {import("webtorrent").Torrent} torrent
106
- * @param {{ from: number, to: number }} window
107
- * @param {number} [priority] - 1 for what a reader needs next, 0 for the
108
- * background fill of the rest of the file.
109
- * @returns {void}
110
- */
111
- function claimWindow(torrent, { from, to }, priority = 1) {
112
- try {
113
- if (typeof torrent._select === "function") {
114
- torrent._select(from, to, priority, null, true);
115
- } else if (typeof torrent.select === "function") {
116
- torrent.select(from, to, priority);
117
- }
118
- } catch {
119
- // Best effort — never fail a read because selection bookkeeping refused.
120
- }
121
- }
122
-
123
- /**
124
- * Take this reader's window back out of the download set.
125
- *
126
- * The bounds must match the ones given to {@link claimWindow} exactly: a stream
127
- * selection is removed by equality, not by overlap.
128
- *
129
- * @param {import("webtorrent").Torrent} torrent
130
- * @param {{ from: number, to: number }} window
131
- * @returns {void}
132
- */
133
- function releaseWindow(torrent, { from, to }) {
134
- try {
135
- if (typeof torrent._deselect === "function") {
136
- torrent._deselect(from, to, true);
137
- } else if (typeof torrent.deselect === "function") {
138
- torrent.deselect(from, to);
139
- }
140
- } catch {
141
- // Best effort.
142
- }
143
- }
144
-
145
- /**
146
- * Mark the piece a reader is blocked on, clearing the mark it set before.
147
- *
148
- * Criticality is never cleared by WebTorrent itself, so a reader that walked a
149
- * film would leave every piece of it marked. Only the indices this reader set
150
- * are cleared, so a second reader's mark on the same piece is not stolen — and
151
- * the flag is advisory anyway.
152
- *
153
- * @param {import("webtorrent").Torrent} torrent
154
- * @param {number} from
155
- * @param {number} to
156
- * @param {{ from: number, to: number } | null} previous
157
- * @returns {{ from: number, to: number } | null}
158
- */
159
- function markCritical(torrent, from, to, previous) {
160
- if (previous && previous.from === from && previous.to === to) {
161
- return previous;
162
- }
163
- if (previous) {
164
- clearCritical(torrent, previous);
165
- }
166
- try {
167
- torrent.critical?.(from, to);
168
- } catch {
169
- return null;
170
- }
171
- return { from, to };
172
- }
173
-
174
- /**
175
- * Drop critical marks this reader set.
176
- *
177
- * @param {import("webtorrent").Torrent} torrent
178
- * @param {{ from: number, to: number }} mark
179
- * @returns {void}
180
- */
181
- function clearCritical(torrent, { from, to }) {
182
- if (!Array.isArray(torrent._critical)) {
183
- return;
184
- }
185
- for (let index = from; index <= to; index += 1) {
186
- torrent._critical[index] = false;
187
- }
188
- }
189
-
190
- /**
191
- * Who is working on the piece a reader is blocked on, right now.
192
- *
193
- * The open question about a seek: a single 8 MiB piece takes 3.0-4.6 s to
194
- * arrive while the swarm as a whole is moving 4-6 MB/s, so roughly 2 MB/s is
195
- * reaching the piece that is actually being waited for. Whether that is because
196
- * few peers hold it, few are being asked, or each is slow cannot be told apart
197
- * from the outside these three counts tell them apart.
198
- *
199
- * `wire.requests` is what has been asked of that peer and not yet answered; a
200
- * block is 16 KB, so `blocks x 16 KB` is the work in flight on this piece.
201
- *
202
- * @param {import("webtorrent").Torrent} torrent
203
- * @param {number} pieceIndex
204
- * @returns {{ peers: number, holders: number, askedOf: number, blocks: number }}
205
- */
206
- export function pieceSupply(torrent, pieceIndex) {
207
- const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
208
- let holders = 0;
209
- let askedOf = 0;
210
- let blocks = 0;
211
- for (const wire of wires) {
212
- if (wire?.peerPieces?.get?.(pieceIndex)) {
213
- holders += 1;
214
- }
215
- const requests = Array.isArray(wire?.requests) ? wire.requests : [];
216
- const forThisPiece = requests.filter((request) => request?.piece === pieceIndex).length;
217
- if (forThisPiece > 0) {
218
- askedOf += 1;
219
- blocks += forThisPiece;
220
- }
221
- }
222
- return { peers: wires.length, holders, askedOf, blocks };
223
- }
224
-
225
- /**
226
- * Wait until a piece has been downloaded and verified.
227
- *
228
- * WebTorrent announces this as `verified`. The bitfield is re-checked after the
229
- * listener is attached because the piece can complete in between, and a missed
230
- * event here would wait forever.
231
- *
232
- * @param {import("webtorrent").Torrent} torrent
233
- * @param {number} index
234
- * @param {{ isCancelled: () => boolean }} cancellation
235
- * @returns {Promise<void>}
236
- */
237
- function whenPieceReady(torrent, index, cancellation) {
238
- if (torrent.bitfield?.get(index)) {
239
- return Promise.resolve();
240
- }
241
-
242
- return new Promise((resolve, reject) => {
243
- /** @param {number} verifiedIndex */
244
- const onVerified = (verifiedIndex) => {
245
- if (verifiedIndex === index) {
246
- cleanup();
247
- resolve();
248
- }
249
- };
250
- const onDestroyed = () => {
251
- cleanup();
252
- reject(new Error(`Torrent went away while waiting for piece ${index}.`));
253
- };
254
- // Cancellation is polled rather than pushed: a superseded seek destroys the
255
- // read, and without this the wait would outlive it and hold a pin.
256
- const poll = setInterval(() => {
257
- if (cancellation.isCancelled()) {
258
- cleanup();
259
- reject(new Error(`Read cancelled while waiting for piece ${index}.`));
260
- }
261
- }, 250);
262
-
263
- function cleanup() {
264
- clearInterval(poll);
265
- torrent.removeListener("verified", onVerified);
266
- torrent.removeListener("close", onDestroyed);
267
- }
268
-
269
- torrent.on("verified", onVerified);
270
- torrent.once("close", onDestroyed);
271
-
272
- // The piece may have arrived between the check above and this listener.
273
- if (torrent.bitfield?.get(index)) {
274
- cleanup();
275
- resolve();
276
- }
277
- });
278
- }
279
-
280
- /**
281
- * A fragment of a read: where to find it, and how to let it go.
282
- *
283
- * @typedef {object} PieceFragment
284
- * @property {number} pieceIndex
285
- * @property {number} offset - Byte offset into the shared pool.
286
- * @property {number} length
287
- * @property {() => void} release - Drops this fragment's pin. Call exactly once.
288
- */
289
-
290
- /**
291
- * Walk a byte range of a file, yielding each piece's position in shared memory.
292
- *
293
- * Yields at most one fragment per piece; the first and last are usually partial.
294
- * The caller must `release()` every fragment it receives, including on failure —
295
- * an unreleased pin permanently costs a slot.
296
- *
297
- * @param {object} params
298
- * @param {import("webtorrent").Torrent} params.torrent
299
- * @param {number} params.fileIndex
300
- * @param {number} params.start - Inclusive, relative to the file.
301
- * @param {number} params.end - Inclusive, relative to the file.
302
- * @param {{ isCancelled: () => boolean }} params.cancellation
303
- * @param {number} [params.windowBytes] - How far ahead of the read head to ask
304
- * the swarm for. Defaults to {@link READ_WINDOW_BYTES}; a caller that knows
305
- * the media's byte rate should size it in seconds of playback instead.
306
- * @returns {AsyncGenerator<PieceFragment>}
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
- /**
344
- * What this file's recent interruptions demand, for a caller that has to decide
345
- * something with them.
346
- *
347
- * Exported because the figures are measured HERE — the reader is the only place
348
- * that knows how long it waited — while the decisions they feed are made
349
- * elsewhere: the smallest buffer that hides an interruption goes to the browser,
350
- * and the speed a step must sustain goes to the quality offer.
351
- *
352
- * @param {string} infoHash
353
- * @param {string} fileName
354
- * @param {number} segmentSeconds - The session's own segment duration.
355
- * @returns {{ requiredSpeed: number, worstWaitSec: number, medianIntervalSec: number, samples: number, minimumBufferSec: number } | null}
356
- */
357
- export function supplyFiguresFor(infoHash, fileName, segmentSeconds) {
358
- const history = supplyWaits.get(`${infoHash ?? "?"}/${fileName ?? "?"}`);
359
- const demand = requiredSpeedFrom(history ?? []);
360
- if (!demand) {
361
- return null;
362
- }
363
- const buffer = minimumBufferFrom({
364
- segmentSeconds,
365
- worstSupplyWaitSec: demand.worstWaitSec
366
- });
367
- return {
368
- requiredSpeed: demand.requiredSpeed,
369
- worstWaitSec: demand.worstWaitSec,
370
- medianIntervalSec: demand.medianIntervalSec,
371
- samples: demand.samples,
372
- minimumBufferSec: buffer ? buffer.seconds : null
373
- };
374
- }
375
-
376
- function noteSupplyWait(key, label, waitedMs) {
377
- const history = supplyWaits.get(key) ?? [];
378
- history.push({ waitedMs, at: Date.now() });
379
- while (history.length > SUPPLY_WAIT_HISTORY) {
380
- history.shift();
381
- }
382
- supplyWaits.set(key, history);
383
-
384
- const now = Date.now();
385
- if (now - (supplyReportedAt.get(key) ?? 0) < SUPPLY_REPORT_INTERVAL_MS) {
386
- return;
387
- }
388
- const demand = requiredSpeedFrom(history);
389
- if (!demand) {
390
- return;
391
- }
392
- supplyReportedAt.set(key, now);
393
- const buffer = minimumBufferFrom({
394
- segmentSeconds: SEGMENT_SECONDS_FOR_BUFFER,
395
- worstSupplyWaitSec: demand.worstWaitSec
396
- });
397
- logger.info(
398
- `supply "${label.slice(0, 40)}": a step must run at ${demand.requiredSpeed.toFixed(2)}x ` +
399
- `to survive this swarm (worst wait ${demand.worstWaitSec.toFixed(2)}s, one every ` +
400
- `${demand.medianIntervalSec.toFixed(2)}s, ${demand.samples} measured) — ` +
401
- `and the smallest buffer that hides it is ${buffer ? buffer.seconds.toFixed(1) : "?"}s`
402
- );
403
- }
404
-
405
- /**
406
- * The segment length the buffer figure is stated against. The reader does not
407
- * know the session's own, and this is a REPORT rather than a decision — the
408
- * decision, when it is made, will use the session's real one.
409
- */
410
- const SEGMENT_SECONDS_FOR_BUFFER = 4;
411
-
412
- export async function* readFragments({
413
- torrent,
414
- fileIndex,
415
- start,
416
- end,
417
- cancellation,
418
- windowBytes = READ_WINDOW_BYTES
419
- }) {
420
- const store = findSharedStore(torrent);
421
- if (!store) {
422
- throw new Error("This torrent is not backed by a shared piece store.");
423
- }
424
-
425
- const file = torrent.files?.[fileIndex];
426
- if (!file) {
427
- throw new Error(`File ${fileIndex} not found.`);
428
- }
429
-
430
- const pieceLength = torrent.pieceLength;
431
- // Piece numbers are torrent-wide, so a file's own offsets have to be lifted
432
- // into the torrent's address space first.
433
- const absoluteStart = file.offset + start;
434
- const absoluteEnd = file.offset + end;
435
- const firstPiece = Math.floor(absoluteStart / pieceLength);
436
- const lastPiece = Math.floor(absoluteEnd / pieceLength);
437
-
438
- // This reader owns what it asks for, and gives it back when it is done. The
439
- // window is a STREAM selection: those are removed by exact bounds and several
440
- // identical ones coexist WebTorrent's own source calls that "in a way a
441
- // count" — so N readers on one torrent produce the union of their windows,
442
- // and each one leaving takes away only its own. That is what makes several
443
- // parallel readers (the codec probe's head and tail, subtitles, one input per
444
- // viewer) cooperate instead of overwrite each other.
445
- //
446
- // The previous code selected the whole requested range, marked all of it
447
- // critical, and never deselected anything so ffmpeg's opening
448
- // `bytes 0-<EOF>` left a permanent selection over the entire file, and no
449
- // later prioritisation could outrank it.
450
- const basePieces = Math.max(1, Math.ceil(Math.max(1, windowBytes) / pieceLength));
451
- // What the window is RIGHT NOW. It starts at what the caller sized in seconds
452
- // of playback and grows while the reader keeps being made to wait — see
453
- // `nextWindowPieces`.
454
- let windowPieces = basePieces;
455
- /**
456
- * The widest this reader may go: its share of what the store can hold in
457
- * memory. Measured rather than chosen the capacity is the store's own, and
458
- * the number of readers is how many windows are declared on it right now.
459
- *
460
- * @returns {number}
461
- */
462
- const ceilingPieces = () => {
463
- const capacity = Number(store?.capacity);
464
- if (!Number.isFinite(capacity) || capacity <= 0) {
465
- return basePieces;
466
- }
467
- const readers = Math.max(1, store.protectedRanges?.().length ?? 1);
468
- return Math.max(basePieces, Math.floor(capacity / readers));
469
- };
470
- /** @type {{ from: number, to: number } | null} */
471
- let window = null;
472
- /** @type {{ from: number, to: number } | null} */
473
- let criticalMark = null;
474
- /**
475
- * Drops the pin of the fragment currently in the consumer's hands, if it
476
- * still holds one. See where it is assigned.
477
- *
478
- * @type {(() => void) | null}
479
- */
480
- let releaseHeldPin = null;
481
-
482
- // Identity of this read, so the store can tell one reader's window from
483
- // another's. Each read gets its own; `readerSequence` never repeats within a
484
- // process.
485
- const readerId = `read-${(readerSequence += 1)}`;
486
-
487
- /**
488
- * Set when the window JUMPS, cleared by the first wait after it.
489
- *
490
- * The wait that follows a jump is the cost of the jump: the pieces at the new
491
- * position have not been asked for yet, and the encoder is restarting. It is
492
- * not evidence about how well this swarm SUSTAINS a read, which is the only
493
- * thing `requiredSpeed` is about — and letting it in is what collapsed the
494
- * quality offer 131 ms after the seek measured on 2026-08-18, refusing every
495
- * re-encoded rung on the strength of one jump.
496
- *
497
- * @type {boolean}
498
- */
499
- let waitBelongsToJump = false;
500
-
501
- const moveWindowTo = (pieceIndex) => {
502
- const next = readWindowFor({ pieceIndex, lastPiece, windowPieces });
503
- if (window && window.from === next.from && window.to === next.to) {
504
- return;
505
- }
506
- const isJump = !window || next.from > window.to || next.from < window.from;
507
- if (window) {
508
- releaseWindow(torrent, window);
509
- }
510
- claimWindow(torrent, next);
511
- window = next;
512
- // Tell the store these pieces are wanted, so it evicts something else.
513
- // Without it the piece the decoder reads next looks exactly as stale as one
514
- // the encoder fetched forty minutes ahead, and the second kind is what
515
- // fills the store while the encoder runs ahead of the viewer.
516
- store.protectRange?.(readerId, next.from, next.to);
517
- if (isJump) {
518
- waitBelongsToJump = true;
519
- // A jump a seek, not the window sliding along — can land on pieces that
520
- // are already downloaded but have been spilled to disk. Bring the whole
521
- // window back at once instead of one disk round trip per piece as the
522
- // reader reaches them.
523
- const revived = store.warmRange?.(next.from, next.to) ?? 0;
524
- if (revived > 0) {
525
- logger.info(
526
- `piece-reader: reviving ${revived} spilled piece(s) of ${next.from}-${next.to} ` +
527
- `for a jump to ${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}"`
528
- );
529
- }
530
- }
531
- };
532
-
533
- try {
534
- for (let pieceIndex = firstPiece; pieceIndex <= lastPiece; pieceIndex += 1) {
535
- if (cancellation.isCancelled()) {
536
- return;
537
- }
538
-
539
- const pieceStart = pieceIndex * pieceLength;
540
- const fromWithinPiece = Math.max(absoluteStart, pieceStart) - pieceStart;
541
- const toWithinPiece = Math.min(absoluteEnd, pieceStart + pieceLength - 1) - pieceStart;
542
-
543
- moveWindowTo(pieceIndex);
544
-
545
- if (!torrent.bitfield?.get(pieceIndex)) {
546
- // Everything from here to the end of the window is wanted NOW, so all
547
- // of it is marked, not just the piece under the head. `critical`
548
- // enables hotswap: a block reserved by a slow peer is re-requested from
549
- // a faster one instead of holding up the reader. Measured 2026-08-04
550
- // with only the blocked piece marked, the first segment after a seek
551
- // took 7.2 s while its four 4 MB pieces arrived one after another at
552
- // ~2.2 MB/s, with waits of 1.3 s and 2.8 s on single pieces.
553
- //
554
- // This is not the old behaviour returning: that marked the whole
555
- // REQUESTED RANGE, which for ffmpeg's input means every piece to the
556
- // end of the file hundreds of them, at which point the flag says
557
- // nothing. A window is what a reader genuinely needs next.
558
- criticalMark = markCritical(torrent, pieceIndex, window.to, criticalMark);
559
- }
560
-
561
- const waitStartedAt = Date.now();
562
- // The reader is blocked, so this piece is now the only thing that matters
563
- // on this torrent: hand it to the fastest wires that hold it. A block is
564
- // reserved for exactly one wire, and the read ends when the slowest
565
- // holder delivers — measured 2026-08-17, the swarm had a fivefold surplus
566
- // of bandwidth and the reader still waited 1.0-4.5 s, 47 times in two
567
- // minutes, on pieces five peers already had.
568
- let pushed = { asked: 0, attempted: 0, considered: 0, fastestBytesPerSecond: 0 };
569
- // The tail as it stood at an attempt that placed NOTHING — the state the
570
- // duplication work has to answer, and the only one worth a line. Sampled
571
- // at that instant rather than once up front, because the steering runs
572
- // again every half second and the piece changes under it; the last such
573
- // reading is kept, so the line describes the most recent failure.
574
- let tailWhenNothingPlaced = null;
575
- const pushToFastest = () => {
576
- try {
577
- const result = askFastestWiresFor(torrent, pieceIndex);
578
- if (result.asked === 0) {
579
- tailWhenNothingPlaced = describePieceTail(torrent, pieceIndex);
580
- }
581
- pushed = {
582
- asked: pushed.asked + result.asked,
583
- // Summed like the successes, so the line compares two totals over
584
- // the same attempts instead of a total against a snapshot.
585
- attempted: (pushed.attempted ?? 0) + result.attempted,
586
- considered: result.considered,
587
- fastestBytesPerSecond: result.fastestBytesPerSecond
588
- };
589
- } catch (error) {
590
- // The entry is internal to the library; if a version changes it, this
591
- // lever stops working and that must be visible rather than silent.
592
- logger.warn(`piece-reader: could not steer piece ${pieceIndex} — ${error?.message ?? error}`);
593
- }
594
- };
595
- if (canPlaceRequests(torrent)) {
596
- pushToFastest();
597
- } else {
598
- // Nothing can be placed at all on this build, so the tail is the whole
599
- // of the answer.
600
- tailWhenNothingPlaced = describePieceTail(torrent, pieceIndex);
601
- logger.warn(
602
- "piece-reader: this webtorrent build offers no way to place a request; " +
603
- "the blocked piece cannot be steered onto a faster peer"
604
- );
605
- }
606
- // Sampled while waiting rather than after: once the piece lands, nothing
607
- // is outstanding on it any more and every count reads zero.
608
- let supply = null;
609
- const supplyProbe = setInterval(() => {
610
- const sample = pieceSupply(torrent, pieceIndex);
611
- if (!supply || sample.blocks > supply.blocks) {
612
- supply = sample;
613
- }
614
- // Wires come and go, and their speeds change: a holder that was slow a
615
- // moment ago may now be the fastest one available.
616
- pushToFastest();
617
- }, 500);
618
- try {
619
- await whenPieceReady(torrent, pieceIndex, cancellation);
620
- } finally {
621
- clearInterval(supplyProbe);
622
- }
623
- // What a reader spent waiting for data, attributed to the exact piece. A
624
- // seek's cost is dominated by the first segment after the encoder
625
- // restarts (measured 9.2-9.4 s), and without this there is no way to say
626
- // whether that is the swarm, the picker, or ffmpeg. Logged only when the
627
- // wait is long enough to matter, so ordinary sequential reading is silent.
628
- const waitedMs = Date.now() - waitStartedAt;
629
- // The window answers to what just happened: a wait means the lead was too
630
- // short, an immediate hit means it is longer than it needs to be. Applied
631
- // before the logging below so the line reports the window the next piece
632
- // will actually use.
633
- if (waitBelongsToJump) {
634
- // Recorded nowhere: see `waitBelongsToJump`. Said out loud, because a
635
- // gap in the supply history is otherwise indistinguishable from a swarm
636
- // that never made the reader wait.
637
- logger.info(
638
- `piece-reader: ${waitedMs}ms on the first piece after a jump — the cost of moving, ` +
639
- `not of this swarm's supply, so it is not counted against the quality offer`
640
- );
641
- waitBelongsToJump = false;
642
- } else {
643
- noteSupplyWait(`${torrent?.infoHash ?? "?"}/${file?.name ?? "?"}`, file?.name ?? "", waitedMs);
644
- }
645
- const widened = nextWindowPieces({
646
- current: windowPieces,
647
- base: basePieces,
648
- ceiling: ceilingPieces(),
649
- waitedMs,
650
- waitThresholdMs: PIECE_WAIT_LOG_MS
651
- });
652
- if (widened !== windowPieces) {
653
- windowPieces = widened;
654
- }
655
- if (waitedMs >= PIECE_WAIT_LOG_MS) {
656
- const rateKbps = Math.round(pieceLength / 1024 / (waitedMs / 1000));
657
- logger.info(
658
- `piece-reader: waited ${waitedMs}ms for piece ${pieceIndex} ` +
659
- `(${pieceIndex - firstPiece + 1} of ${lastPiece - firstPiece + 1} in a read from ` +
660
- `${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}") ` +
661
- `— ${rateKbps}KB/s on this piece; ` +
662
- (supply
663
- ? `${supply.holders}/${supply.peers} peers had it, ${supply.askedOf} were asked, ` +
664
- `${supply.blocks} blocks (${Math.round((supply.blocks * 16384) / 1024)}KB) in flight at peak`
665
- : "no sample taken") +
666
- // What WE did about it, so the next session says whether steering
667
- // the piece onto faster holders shortens the tail — by number
668
- // rather than by impression.
669
- `; steered onto ${pushed.asked} of ${pushed.attempted} asks (${pushed.considered} peers held it)` +
670
- (pushed.fastestBytesPerSecond > 0
671
- ? `, fastest ${Math.round(pushed.fastestBytesPerSecond / 1024)}KB/s`
672
- : "") +
673
- // Only when the steering placed nothing, which is the case that
674
- // decides whether duplicating the tail is worth building: it says
675
- // how much of the piece is still missing and which wires are
676
- // holding it, slowest first.
677
- (tailWhenNothingPlaced
678
- ? `; tail ${tailWhenNothingPlaced.missing}/${tailWhenNothingPlaced.chunks} blocks missing, held by ` +
679
- (tailWhenNothingPlaced.outstanding.length > 0
680
- ? tailWhenNothingPlaced.outstanding
681
- .map((wire) => `${wire.blocks}@${Math.round(wire.bytesPerSecond / 1024)}KB/s` +
682
- (wire.choking ? " (choking)" : ""))
683
- .join(" ")
684
- : "nobody")
685
- : "")
686
- );
687
- }
688
-
689
- // Pinned BEFORE it is located, and before any await that could let an
690
- // eviction run: the offset is only meaningful while the piece is held.
691
- store.pin(pieceIndex);
692
- let located = null;
693
- try {
694
- located = await store.reside(pieceIndex);
695
- } catch (error) {
696
- store.unpin(pieceIndex);
697
- throw error;
698
- }
699
-
700
- if (!located) {
701
- store.unpin(pieceIndex);
702
- throw new Error(`Piece ${pieceIndex} is verified but absent from the store.`);
703
- }
704
-
705
- let releasedThisPiece = false;
706
- // Remembered so the generator can drop it itself. The pin is taken here
707
- // and the consumer is expected to release it but a consumer that
708
- // ABANDONS the iterator never gets the chance, and a seek abandons it
709
- // every time: the encoder is killed, the response is torn down, and the
710
- // loop is left between two fragments. Field 2026-08-06: after one seek
711
- // every slot in the store was pinned, the store answered
712
- // `Every resident piece is pinned; no slot can be freed` — to the
713
- // WebTorrent client, which closed the store and destroyed the torrent —
714
- // and the session died with `File 0 not found`.
715
- releaseHeldPin = () => {
716
- if (!releasedThisPiece) {
717
- releasedThisPiece = true;
718
- store.unpin(pieceIndex);
719
- }
720
- };
721
- yield {
722
- pieceIndex,
723
- offset: located.offset + fromWithinPiece,
724
- length: toWithinPiece - fromWithinPiece + 1,
725
- release() {
726
- if (releasedThisPiece) {
727
- return;
728
- }
729
- releasedThisPiece = true;
730
- store.unpin(pieceIndex);
731
- }
732
- };
733
- // Handed back, and released by the consumer or not at all — either way
734
- // this reader no longer owes anything for it.
735
- releaseHeldPin = null;
736
- }
737
- } finally {
738
- // A fragment handed out and never released is a slot lost for the life of
739
- // the process. Reached on every exit, including the consumer walking away.
740
- if (releaseHeldPin) {
741
- releaseHeldPin();
742
- releaseHeldPin = null;
743
- }
744
- // Reached on completion, on cancellation, on a throw, and when the consumer
745
- // stops iterating a window left behind would keep the swarm fetching for
746
- // a reader that no longer exists.
747
- if (window) {
748
- releaseWindow(torrent, window);
749
- }
750
- store.releaseProtection?.(readerId);
751
- if (criticalMark) {
752
- clearCritical(torrent, criticalMark);
753
- }
754
- }
755
- }
1
+ /**
2
+ * @file Reading a byte range as positions in shared memory, not as bytes.
3
+ *
4
+ * The pieces already live in a `SharedArrayBuffer` the main thread can map. So
5
+ * the torrent thread does not need to hand over any bytes at all: it can say
6
+ * *where* a piece sits and let the other side read it there. What crosses the
7
+ * boundary is two numbers per piece.
8
+ *
9
+ * That is the whole point of the exercise. The alternative — copying each piece
10
+ * into memory we own and transferring it — costs 18.84 ms per 10 MB segment on
11
+ * the field host, and costs it **on the critical path**, in the thread that is
12
+ * also running the torrent, at the moment a viewer is waiting for that segment.
13
+ * Here the copy is gone entirely rather than moved.
14
+ *
15
+ * Two obligations come with it, and both are enforced rather than assumed:
16
+ *
17
+ * - a piece being read is **pinned**, so eviction cannot take the memory out
18
+ * from under the reader mid-read;
19
+ * - the pin is released only once the other thread reports it has finished
20
+ * with those bytes — not when they were sent, because nothing was sent.
21
+ */
22
+
23
+ import { findSharedStore } from "../piece-store/shared-piece-store.js";
24
+ import { logger } from "../../utils/logger.js";
25
+ import {
26
+ askFastestWiresFor,
27
+ canPlaceRequests,
28
+ describePieceTail,
29
+ duplicateTailFor
30
+ } from "./fastest-wires.js";
31
+ import { minimumBufferFrom, requiredSpeedFrom } from "../supply-margin.js";
32
+
33
+ /** Only waits at least this long are reported; sequential reading stays silent. */
34
+ const PIECE_WAIT_LOG_MS = 1_000;
35
+
36
+ /** Distinguishes concurrent readers to the piece store. Never reused. */
37
+ let readerSequence = 0;
38
+
39
+ /**
40
+ * How far ahead of the read head pieces are asked for.
41
+ *
42
+ * A read is open-ended ffmpeg opens its input as `bytes <position>-<EOF>` and
43
+ * keeps it for the whole film so taking the requested range literally asks
44
+ * for everything from the seek point to the end of the file at once. That is
45
+ * what a seek used to do: the swarm was told the entire tail was wanted, went
46
+ * at it from its first missing piece, and the one piece the decoder was blocked
47
+ * on arrived only when the sequential scan reached it. Measured on a 4.7 GB
48
+ * film: a seek to 89.1% took 93 s and pulled 2.47 GB.
49
+ *
50
+ * So the reader asks for a window and moves it as it goes. The size is a
51
+ * compromise the caller cannot yet express: the right unit is seconds of
52
+ * playback (duration and size are both known — to the transcode session, not to
53
+ * this thread), and 32 MB is about 34 s of a 1080p film but only a few seconds
54
+ * of a disc remux. Sizing it from the real byte rate is a follow-up; what
55
+ * matters here is that it is bounded and moving rather than "to the end".
56
+ */
57
+ const READ_WINDOW_BYTES = 32 * 1024 * 1024;
58
+
59
+ /**
60
+ * How urgently each band is wanted, most urgent first.
61
+ *
62
+ * Distinct numbers, and none of them zero: WebTorrent selects the whole torrent
63
+ * at priority 0 for the background fill, so a band at 0 would be indistinguishable
64
+ * from it. Equal non-zero priorities are deliberately shuffled against each other
65
+ * by the library (`shufflePriority`), so bands that must keep their order have to
66
+ * differ.
67
+ *
68
+ * 4 is what the viewer reaches in seconds; 3 and 2 are the lead being built ahead
69
+ * of them; 1 is what was never downloaded BEHIND the position, which only a
70
+ * backward seek needs.
71
+ */
72
+ /**
73
+ * Which way this proxy claims what a reader wants, when the deployment names it:
74
+ * `flat` is the single band every release before this used, `bands` is the four
75
+ * described above. Anything else — the default — alternates per read, so the two
76
+ * accumulate side by side from real viewing and the log can compare them.
77
+ */
78
+ const READ_MODE_SETTING = process.env.TORRENT_TV_READ_MODE ?? "";
79
+
80
+ const BAND_PRIORITIES = [4, 3, 2, 1];
81
+
82
+ /**
83
+ * Where each band sits, given the urgent one and how far the trailing bands have
84
+ * been allowed to grow.
85
+ *
86
+ * The urgent band is the reader's own window, anchored at the first piece it does
87
+ * not already hold. Behind it the lead is built in two steps rather than one, so
88
+ * the nearer half is asked for before the farther half; behind THOSE, once they
89
+ * have reached the end of the file, comes whatever was never downloaded before the
90
+ * position — needed only if the viewer seeks backwards, and never ahead of the
91
+ * picture they are watching.
92
+ *
93
+ * @param {{ urgent: { from: number, to: number }, pieceIndex: number, firstPiece: number, lastPiece: number, widths: { near: number, far: number } }} params
94
+ * @returns {Array<{ from: number, to: number, priority: number }>}
95
+ */
96
+ export function bandsFrom({ urgent, pieceIndex, firstPiece, lastPiece, widths }) {
97
+ const bands = [{ from: urgent.from, to: urgent.to, priority: BAND_PRIORITIES[0] }];
98
+ let edge = urgent.to;
99
+ for (let index = 0; index < 2; index += 1) {
100
+ if (edge >= lastPiece) {
101
+ break;
102
+ }
103
+ const width = index === 0 ? widths.near : widths.far;
104
+ if (width <= 0) {
105
+ break;
106
+ }
107
+ const from = edge + 1;
108
+ const to = Math.min(lastPiece, edge + width);
109
+ bands.push({ from, to, priority: BAND_PRIORITIES[index + 1] });
110
+ edge = to;
111
+ }
112
+ // Only once the lead has nothing left to cover: asking for the past while the
113
+ // future is still missing would take capacity from the picture being watched.
114
+ if (edge >= lastPiece && pieceIndex > firstPiece) {
115
+ bands.push({ from: firstPiece, to: pieceIndex - 1, priority: BAND_PRIORITIES[3] });
116
+ }
117
+ return bands;
118
+ }
119
+
120
+ /**
121
+ * How wide the two lead bands should be, in pieces, from what has been measured
122
+ * about this file on this swarm.
123
+ *
124
+ * Nothing here is chosen. The near band has to cover the worst interruption this
125
+ * reader has actually met, because that is what must already be in hand for one
126
+ * not to reach the viewer: `worstWait × consumeRate`. The far band has to cover
127
+ * what the swarm can put ahead of the viewer between interruptions, which is the
128
+ * surplus it delivers over what the film eats, for as long as it typically runs
129
+ * without stopping: `(downloadRate - consumeRate) × medianInterval`. A swarm
130
+ * with no surplus produces no far band, which is correct — there is nothing to
131
+ * get ahead with.
132
+ *
133
+ * Until a reader has met two interruptions there are no figures, and both bands
134
+ * fall back to the width of the urgent one; the log says so in words.
135
+ *
136
+ * @param {{ worstWaitSec: number | null, medianIntervalSec: number | null, downloadBytesPerSec: number, consumeBytesPerSec: number, pieceLength: number, basePieces: number }} params
137
+ * @returns {{ near: number, far: number, measured: boolean }}
138
+ */
139
+ export function bandWidthsFrom({
140
+ worstWaitSec,
141
+ medianIntervalSec,
142
+ downloadBytesPerSec,
143
+ consumeBytesPerSec,
144
+ pieceLength,
145
+ basePieces
146
+ }) {
147
+ const usable = Number.isFinite(worstWaitSec) && worstWaitSec > 0 &&
148
+ Number.isFinite(medianIntervalSec) && medianIntervalSec > 0 &&
149
+ Number.isFinite(consumeBytesPerSec) && consumeBytesPerSec > 0 &&
150
+ Number.isFinite(pieceLength) && pieceLength > 0;
151
+ if (!usable) {
152
+ return { near: basePieces, far: basePieces, measured: false };
153
+ }
154
+ const near = Math.max(1, Math.ceil((worstWaitSec * consumeBytesPerSec) / pieceLength));
155
+ const surplus = Math.max(0, (Number(downloadBytesPerSec) || 0) - consumeBytesPerSec);
156
+ const far = Math.max(0, Math.ceil((surplus * medianIntervalSec) / pieceLength));
157
+ return { near, far, measured: true };
158
+ }
159
+
160
+ /**
161
+ * Whether two sets of bands are the same, so an unchanged claim is not released
162
+ * and re-made on every read.
163
+ *
164
+ * @param {Array<{ from: number, to: number, priority: number }>} left
165
+ * @param {Array<{ from: number, to: number, priority: number }>} right
166
+ * @returns {boolean}
167
+ */
168
+ export function sameBands(left, right) {
169
+ if (left.length !== right.length) {
170
+ return false;
171
+ }
172
+ return left.every((band, index) =>
173
+ band.from === right[index].from &&
174
+ band.to === right[index].to &&
175
+ band.priority === right[index].priority);
176
+ }
177
+
178
+ /**
179
+ * The pieces a reader at `pieceIndex` wants next, clamped to its own range.
180
+ *
181
+ * @param {{ pieceIndex: number, lastPiece: number, windowPieces: number }} params
182
+ * @returns {{ from: number, to: number }}
183
+ */
184
+ export function readWindowFor({ pieceIndex, lastPiece, windowPieces }) {
185
+ const span = Math.max(1, windowPieces);
186
+ return { from: pieceIndex, to: Math.min(lastPiece, pieceIndex + span - 1) };
187
+ }
188
+
189
+ /**
190
+ * How wide the window should be after a piece that made the reader wait — or
191
+ * did not.
192
+ *
193
+ * The swarm's surplus is what pays for this. Measured 2026-08-17 on the field
194
+ * torrent: 5.1-5.9 MB/s delivered against a film consumed at about 1 MB/s, and
195
+ * the reader still blocked 47 times in two minutes, median 1.5 s, worst 4.5 s.
196
+ * A fivefold surplus never became distance ahead of the head, because the
197
+ * window is a fixed number of seconds of playback and everything past it is
198
+ * ordinary background fill at no priority.
199
+ *
200
+ * So the window follows the evidence: every wait that mattered widens it by a
201
+ * piece, every piece that was already there narrows it back toward the size the
202
+ * caller asked for. Nothing here is chosen — the wait is measured, the
203
+ * threshold is the one that already defines "a wait worth recording", and the
204
+ * ceiling is this reader's share of the store's memory, so widening can never
205
+ * cost more than the store can hold.
206
+ *
207
+ * @param {{ current: number, base: number, ceiling: number, waitedMs: number, waitThresholdMs: number }} params
208
+ * @returns {number}
209
+ */
210
+ export function nextWindowPieces({ current, base, ceiling, waitedMs, waitThresholdMs }) {
211
+ const floor = Math.max(1, Math.floor(base));
212
+ const top = Math.max(floor, Math.floor(ceiling));
213
+ const now = Math.min(top, Math.max(floor, Math.floor(current)));
214
+ if (waitedMs >= waitThresholdMs) {
215
+ return Math.min(top, now + 1);
216
+ }
217
+ return Math.max(floor, now - 1);
218
+ }
219
+
220
+ /**
221
+ * Add this reader's window to the download set as a stream selection.
222
+ *
223
+ * `_select`/`_deselect` with the stream flag are what WebTorrent's own
224
+ * `FileIterator` uses; there is no public call for it, because the public
225
+ * `select` produces the merging, interval-subtracted kind whose bookkeeping
226
+ * cannot express "one of several readers wants this". Falls back to the public
227
+ * call if a future version drops the private one.
228
+ *
229
+ * @param {import("webtorrent").Torrent} torrent
230
+ * @param {{ from: number, to: number }} window
231
+ * @param {number} [priority] - 1 for what a reader needs next, 0 for the
232
+ * background fill of the rest of the file.
233
+ * @returns {void}
234
+ */
235
+ function claimWindow(torrent, { from, to }, priority = 1) {
236
+ try {
237
+ if (typeof torrent._select === "function") {
238
+ torrent._select(from, to, priority, null, true);
239
+ } else if (typeof torrent.select === "function") {
240
+ torrent.select(from, to, priority);
241
+ }
242
+ } catch {
243
+ // Best effort never fail a read because selection bookkeeping refused.
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Take this reader's window back out of the download set.
249
+ *
250
+ * The bounds must match the ones given to {@link claimWindow} exactly: a stream
251
+ * selection is removed by equality, not by overlap.
252
+ *
253
+ * @param {import("webtorrent").Torrent} torrent
254
+ * @param {{ from: number, to: number }} window
255
+ * @returns {void}
256
+ */
257
+ function releaseWindow(torrent, { from, to }) {
258
+ try {
259
+ if (typeof torrent._deselect === "function") {
260
+ torrent._deselect(from, to, true);
261
+ } else if (typeof torrent.deselect === "function") {
262
+ torrent.deselect(from, to);
263
+ }
264
+ } catch {
265
+ // Best effort.
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Mark the piece a reader is blocked on, clearing the mark it set before.
271
+ *
272
+ * Criticality is never cleared by WebTorrent itself, so a reader that walked a
273
+ * film would leave every piece of it marked. Only the indices this reader set
274
+ * are cleared, so a second reader's mark on the same piece is not stolen — and
275
+ * the flag is advisory anyway.
276
+ *
277
+ * @param {import("webtorrent").Torrent} torrent
278
+ * @param {number} from
279
+ * @param {number} to
280
+ * @param {{ from: number, to: number } | null} previous
281
+ * @returns {{ from: number, to: number } | null}
282
+ */
283
+ function markCritical(torrent, from, to, previous) {
284
+ if (previous && previous.from === from && previous.to === to) {
285
+ return previous;
286
+ }
287
+ if (previous) {
288
+ clearCritical(torrent, previous);
289
+ }
290
+ try {
291
+ torrent.critical?.(from, to);
292
+ } catch {
293
+ return null;
294
+ }
295
+ return { from, to };
296
+ }
297
+
298
+ /**
299
+ * Drop critical marks this reader set.
300
+ *
301
+ * @param {import("webtorrent").Torrent} torrent
302
+ * @param {{ from: number, to: number }} mark
303
+ * @returns {void}
304
+ */
305
+ function clearCritical(torrent, { from, to }) {
306
+ if (!Array.isArray(torrent._critical)) {
307
+ return;
308
+ }
309
+ for (let index = from; index <= to; index += 1) {
310
+ torrent._critical[index] = false;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Who is working on the piece a reader is blocked on, right now.
316
+ *
317
+ * The open question about a seek: a single 8 MiB piece takes 3.0-4.6 s to
318
+ * arrive while the swarm as a whole is moving 4-6 MB/s, so roughly 2 MB/s is
319
+ * reaching the piece that is actually being waited for. Whether that is because
320
+ * few peers hold it, few are being asked, or each is slow cannot be told apart
321
+ * from the outside — these three counts tell them apart.
322
+ *
323
+ * `wire.requests` is what has been asked of that peer and not yet answered; a
324
+ * block is 16 KB, so `blocks x 16 KB` is the work in flight on this piece.
325
+ *
326
+ * @param {import("webtorrent").Torrent} torrent
327
+ * @param {number} pieceIndex
328
+ * @returns {{ peers: number, holders: number, askedOf: number, blocks: number }}
329
+ */
330
+ export function pieceSupply(torrent, pieceIndex) {
331
+ const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
332
+ let holders = 0;
333
+ let askedOf = 0;
334
+ let blocks = 0;
335
+ for (const wire of wires) {
336
+ if (wire?.peerPieces?.get?.(pieceIndex)) {
337
+ holders += 1;
338
+ }
339
+ const requests = Array.isArray(wire?.requests) ? wire.requests : [];
340
+ const forThisPiece = requests.filter((request) => request?.piece === pieceIndex).length;
341
+ if (forThisPiece > 0) {
342
+ askedOf += 1;
343
+ blocks += forThisPiece;
344
+ }
345
+ }
346
+ return { peers: wires.length, holders, askedOf, blocks };
347
+ }
348
+
349
+ /**
350
+ * Wait until a piece has been downloaded and verified.
351
+ *
352
+ * WebTorrent announces this as `verified`. The bitfield is re-checked after the
353
+ * listener is attached because the piece can complete in between, and a missed
354
+ * event here would wait forever.
355
+ *
356
+ * @param {import("webtorrent").Torrent} torrent
357
+ * @param {number} index
358
+ * @param {{ isCancelled: () => boolean }} cancellation
359
+ * @returns {Promise<void>}
360
+ */
361
+ function whenPieceReady(torrent, index, cancellation) {
362
+ if (torrent.bitfield?.get(index)) {
363
+ return Promise.resolve();
364
+ }
365
+
366
+ return new Promise((resolve, reject) => {
367
+ /** @param {number} verifiedIndex */
368
+ const onVerified = (verifiedIndex) => {
369
+ if (verifiedIndex === index) {
370
+ cleanup();
371
+ resolve();
372
+ }
373
+ };
374
+ const onDestroyed = () => {
375
+ cleanup();
376
+ reject(new Error(`Torrent went away while waiting for piece ${index}.`));
377
+ };
378
+ // Cancellation is polled rather than pushed: a superseded seek destroys the
379
+ // read, and without this the wait would outlive it and hold a pin.
380
+ const poll = setInterval(() => {
381
+ if (cancellation.isCancelled()) {
382
+ cleanup();
383
+ reject(new Error(`Read cancelled while waiting for piece ${index}.`));
384
+ }
385
+ }, 250);
386
+
387
+ function cleanup() {
388
+ clearInterval(poll);
389
+ torrent.removeListener("verified", onVerified);
390
+ torrent.removeListener("close", onDestroyed);
391
+ }
392
+
393
+ torrent.on("verified", onVerified);
394
+ torrent.once("close", onDestroyed);
395
+
396
+ // The piece may have arrived between the check above and this listener.
397
+ if (torrent.bitfield?.get(index)) {
398
+ cleanup();
399
+ resolve();
400
+ }
401
+ });
402
+ }
403
+
404
+ /**
405
+ * A fragment of a read: where to find it, and how to let it go.
406
+ *
407
+ * @typedef {object} PieceFragment
408
+ * @property {number} pieceIndex
409
+ * @property {number} offset - Byte offset into the shared pool.
410
+ * @property {number} length
411
+ * @property {() => void} release - Drops this fragment's pin. Call exactly once.
412
+ */
413
+
414
+ /**
415
+ * Walk a byte range of a file, yielding each piece's position in shared memory.
416
+ *
417
+ * Yields at most one fragment per piece; the first and last are usually partial.
418
+ * The caller must `release()` every fragment it receives, including on failure —
419
+ * an unreleased pin permanently costs a slot.
420
+ *
421
+ * @param {object} params
422
+ * @param {import("webtorrent").Torrent} params.torrent
423
+ * @param {number} params.fileIndex
424
+ * @param {number} params.start - Inclusive, relative to the file.
425
+ * @param {number} params.end - Inclusive, relative to the file.
426
+ * @param {{ isCancelled: () => boolean }} params.cancellation
427
+ * @param {number} [params.windowBytes] - How far ahead of the read head to ask
428
+ * the swarm for. Defaults to {@link READ_WINDOW_BYTES}; a caller that knows
429
+ * the media's byte rate should size it in seconds of playback instead.
430
+ * @returns {AsyncGenerator<PieceFragment>}
431
+ */
432
+ /**
433
+ * The last interruptions this file's readers met, newest last.
434
+ *
435
+ * Bounded and per file, because both figures derived from it describe THIS
436
+ * file on THIS swarm: a piece is 8 MiB here and 512 KiB elsewhere, and a swarm
437
+ * that answers in 200 ms today may not tomorrow. Nothing is stored beyond the
438
+ * process a restart starts from no evidence, which is the honest state.
439
+ *
440
+ * @type {Map<string, Array<{ waitedMs: number, at: number }>>}
441
+ */
442
+ const supplyWaits = new Map();
443
+
444
+ /** How many interruptions are kept per file. */
445
+ const SUPPLY_WAIT_HISTORY = 40;
446
+
447
+ /** How often the derived figures are printed, at most. */
448
+ const SUPPLY_REPORT_INTERVAL_MS = 30_000;
449
+
450
+ /** When each file's figures were last printed. */
451
+ const supplyReportedAt = new Map();
452
+
453
+ /**
454
+ * Record one interruption and, at most twice a minute, say what it implies.
455
+ *
456
+ * The two figures are the whole of roadmap item 3: the speed a step must
457
+ * sustain to survive this supply (`1 + worst wait / median interval`), and the
458
+ * smallest buffer that hides an interruption from the viewer. Both are printed
459
+ * before either is USED, so the field says whether the arithmetic describes
460
+ * reality before anything is decided by it.
461
+ *
462
+ * @param {string} key - Something stable per file.
463
+ * @param {string} label - What to call it in the log.
464
+ * @param {number} waitedMs
465
+ * @returns {void}
466
+ */
467
+ /**
468
+ * What this file's recent interruptions demand, for a caller that has to decide
469
+ * something with them.
470
+ *
471
+ * Exported because the figures are measured HERE — the reader is the only place
472
+ * that knows how long it waited while the decisions they feed are made
473
+ * elsewhere: the smallest buffer that hides an interruption goes to the browser,
474
+ * and the speed a step must sustain goes to the quality offer.
475
+ *
476
+ * @param {string} infoHash
477
+ * @param {string} fileName
478
+ * @param {number} segmentSeconds - The session's own segment duration.
479
+ * @returns {{ requiredSpeed: number, worstWaitSec: number, medianIntervalSec: number, samples: number, minimumBufferSec: number } | null}
480
+ */
481
+ export function supplyFiguresFor(infoHash, fileName, segmentSeconds) {
482
+ const history = supplyWaits.get(`${infoHash ?? "?"}/${fileName ?? "?"}`);
483
+ const demand = requiredSpeedFrom(history ?? []);
484
+ if (!demand) {
485
+ return null;
486
+ }
487
+ const buffer = minimumBufferFrom({
488
+ segmentSeconds,
489
+ worstSupplyWaitSec: demand.worstWaitSec
490
+ });
491
+ return {
492
+ requiredSpeed: demand.requiredSpeed,
493
+ worstWaitSec: demand.worstWaitSec,
494
+ medianIntervalSec: demand.medianIntervalSec,
495
+ samples: demand.samples,
496
+ minimumBufferSec: buffer ? buffer.seconds : null
497
+ };
498
+ }
499
+
500
+ /**
501
+ * Waits split by whether the blocked piece was steered onto another peer.
502
+ *
503
+ * The steering is visible per wait already how many peers held the piece, how
504
+ * many were asked, what the tail looked like. What was NOT visible is what it
505
+ * bought, and that cannot be read off one line: it is the difference between
506
+ * the waits where a second peer was asked and the waits where none could be.
507
+ * Kept per file, reported with the same summary, so a session says by number
508
+ * whether the swap shortens the tail instead of leaving it to impression.
509
+ *
510
+ * @type {Map<string, { swapped: number[], alone: number[] }>}
511
+ */
512
+ const waitsBySteering = new Map();
513
+
514
+ /**
515
+ * Record one wait against whether anything was steered during it.
516
+ *
517
+ * @param {string} key
518
+ * @param {number} waitedMs
519
+ * @param {boolean} steered
520
+ * @returns {void}
521
+ */
522
+ function noteReadMode(key, waitedMs, mode) {
523
+ let split = waitsByMode.get(key);
524
+ if (!split) {
525
+ split = { flat: [], bands: [] };
526
+ waitsByMode.set(key, split);
527
+ }
528
+ const into = mode === "bands" ? split.bands : split.flat;
529
+ into.push(waitedMs);
530
+ while (into.length > SUPPLY_WAIT_HISTORY) {
531
+ into.shift();
532
+ }
533
+ }
534
+
535
+ /**
536
+ * What the two ways of claiming amount to, or null while either has no sample.
537
+ *
538
+ * This is the whole of the comparison: the same file, the same swarm, the same
539
+ * viewer, waits sorted by which arm was in force. It appears in the periodic
540
+ * summary so a session can be read without collecting lines by hand.
541
+ *
542
+ * @param {string} key
543
+ * @returns {string | null}
544
+ */
545
+ function describeReadModes(key) {
546
+ const split = waitsByMode.get(key);
547
+ if (!split || split.flat.length === 0 || split.bands.length === 0) {
548
+ return null;
549
+ }
550
+ const middle = (values) => {
551
+ const sorted = [...values].sort((left, right) => left - right);
552
+ return sorted[Math.floor(sorted.length / 2)];
553
+ };
554
+ const worst = (values) => Math.max(...values);
555
+ return (
556
+ `flat ${split.flat.length} waits median ${middle(split.flat)}ms worst ${worst(split.flat)}ms, ` +
557
+ `bands ${split.bands.length} waits median ${middle(split.bands)}ms worst ${worst(split.bands)}ms`
558
+ );
559
+ }
560
+
561
+ /**
562
+ * Waits split by which way the reader was claiming.
563
+ *
564
+ * @type {Map<string, { flat: number[], bands: number[] }>}
565
+ */
566
+ const waitsByMode = new Map();
567
+
568
+ function noteSteeringOutcome(key, waitedMs, steered) {
569
+ let split = waitsBySteering.get(key);
570
+ if (!split) {
571
+ split = { swapped: [], alone: [] };
572
+ waitsBySteering.set(key, split);
573
+ }
574
+ const into = steered ? split.swapped : split.alone;
575
+ into.push(waitedMs);
576
+ while (into.length > SUPPLY_WAIT_HISTORY) {
577
+ into.shift();
578
+ }
579
+ }
580
+
581
+ /**
582
+ * What the split says, or null while one side of it is still empty — a
583
+ * comparison needs both.
584
+ *
585
+ * @param {string} key
586
+ * @returns {string | null}
587
+ */
588
+ function describeSteering(key) {
589
+ const split = waitsBySteering.get(key);
590
+ if (!split || split.swapped.length === 0 || split.alone.length === 0) {
591
+ return null;
592
+ }
593
+ const middle = (values) => {
594
+ const sorted = [...values].sort((left, right) => left - right);
595
+ return sorted[Math.floor(sorted.length / 2)];
596
+ };
597
+ return (
598
+ `steered ${split.swapped.length} waits median ${middle(split.swapped)}ms, ` +
599
+ `unsteered ${split.alone.length} waits median ${middle(split.alone)}ms`
600
+ );
601
+ }
602
+
603
+ function noteSupplyWait(key, label, waitedMs) {
604
+ const history = supplyWaits.get(key) ?? [];
605
+ history.push({ waitedMs, at: Date.now() });
606
+ while (history.length > SUPPLY_WAIT_HISTORY) {
607
+ history.shift();
608
+ }
609
+ supplyWaits.set(key, history);
610
+
611
+ const now = Date.now();
612
+ if (now - (supplyReportedAt.get(key) ?? 0) < SUPPLY_REPORT_INTERVAL_MS) {
613
+ return;
614
+ }
615
+ const demand = requiredSpeedFrom(history);
616
+ if (!demand) {
617
+ return;
618
+ }
619
+ supplyReportedAt.set(key, now);
620
+ const buffer = minimumBufferFrom({
621
+ segmentSeconds: SEGMENT_SECONDS_FOR_BUFFER,
622
+ worstSupplyWaitSec: demand.worstWaitSec
623
+ });
624
+ logger.info(
625
+ `supply "${label.slice(0, 40)}": a step must run at ${demand.requiredSpeed.toFixed(2)}x ` +
626
+ `to survive this swarm (worst wait ${demand.worstWaitSec.toFixed(2)}s, one every ` +
627
+ `${demand.medianIntervalSec.toFixed(2)}s, ${demand.samples} measured) ` +
628
+ `and the smallest buffer that hides it is ${buffer ? buffer.seconds.toFixed(1) : "?"}s` +
629
+ // What steering the blocked piece onto another peer bought, as the
630
+ // difference between the waits where it placed something and the waits
631
+ // where it could not. Absent until both sides have a sample, because a
632
+ // comparison with one side empty is not a comparison.
633
+ (describeSteering(key) ? ` — ${describeSteering(key)}` : "") +
634
+ // The comparison this release exists to make. Read it first.
635
+ (describeReadModes(key) ? ` ${describeReadModes(key)}` : "")
636
+ );
637
+ }
638
+
639
+ /**
640
+ * The segment length the buffer figure is stated against. The reader does not
641
+ * know the session's own, and this is a REPORT rather than a decision — the
642
+ * decision, when it is made, will use the session's real one.
643
+ */
644
+ const SEGMENT_SECONDS_FOR_BUFFER = 4;
645
+
646
+ export async function* readFragments({
647
+ torrent,
648
+ fileIndex,
649
+ start,
650
+ end,
651
+ cancellation,
652
+ windowBytes = READ_WINDOW_BYTES
653
+ }) {
654
+ const store = findSharedStore(torrent);
655
+ if (!store) {
656
+ throw new Error("This torrent is not backed by a shared piece store.");
657
+ }
658
+
659
+ const file = torrent.files?.[fileIndex];
660
+ if (!file) {
661
+ throw new Error(`File ${fileIndex} not found.`);
662
+ }
663
+
664
+ const pieceLength = torrent.pieceLength;
665
+ // Piece numbers are torrent-wide, so a file's own offsets have to be lifted
666
+ // into the torrent's address space first.
667
+ const absoluteStart = file.offset + start;
668
+ const absoluteEnd = file.offset + end;
669
+ const firstPiece = Math.floor(absoluteStart / pieceLength);
670
+ const lastPiece = Math.floor(absoluteEnd / pieceLength);
671
+
672
+ // This reader owns what it asks for, and gives it back when it is done. The
673
+ // window is a STREAM selection: those are removed by exact bounds and several
674
+ // identical ones coexist WebTorrent's own source calls that "in a way a
675
+ // count" so N readers on one torrent produce the union of their windows,
676
+ // and each one leaving takes away only its own. That is what makes several
677
+ // parallel readers (the codec probe's head and tail, subtitles, one input per
678
+ // viewer) cooperate instead of overwrite each other.
679
+ //
680
+ // The previous code selected the whole requested range, marked all of it
681
+ // critical, and never deselected anything — so ffmpeg's opening
682
+ // `bytes 0-<EOF>` left a permanent selection over the entire file, and no
683
+ // later prioritisation could outrank it.
684
+ const basePieces = Math.max(1, Math.ceil(Math.max(1, windowBytes) / pieceLength));
685
+ /**
686
+ * How this reader claims what it wants: `flat` is one band of equal urgency,
687
+ * which is what every release before this did; `bands` puts the pieces the
688
+ * viewer is about to reach above the fill behind them, anchored at the first
689
+ * piece not already held.
690
+ *
691
+ * Alternated per read unless the deployment names one, so the comparison
692
+ * accumulates from real viewing instead of from a synthetic swarm — three
693
+ * such experiments in one day measured regimes the levers were not for and
694
+ * cost more than they settled.
695
+ *
696
+ * @type {"flat" | "bands"}
697
+ */
698
+ const readMode = READ_MODE_SETTING === "flat" || READ_MODE_SETTING === "bands"
699
+ ? READ_MODE_SETTING
700
+ : (Math.random() < 0.5 ? "flat" : "bands");
701
+ // What the window is RIGHT NOW. It starts at what the caller sized in seconds
702
+ // of playback and grows while the reader keeps being made to wait — see
703
+ // `nextWindowPieces`.
704
+ let windowPieces = basePieces;
705
+ /**
706
+ * The widest this reader may go: its share of what the store can hold in
707
+ * memory. Measured rather than chosen the capacity is the store's own, and
708
+ * the number of readers is how many windows are declared on it right now.
709
+ *
710
+ * @returns {number}
711
+ */
712
+ const ceilingPieces = () => {
713
+ const capacity = Number(store?.capacity);
714
+ if (!Number.isFinite(capacity) || capacity <= 0) {
715
+ return basePieces;
716
+ }
717
+ const readers = Math.max(1, store.protectedRanges?.().length ?? 1);
718
+ return Math.max(basePieces, Math.floor(capacity / readers));
719
+ };
720
+ /** @type {{ from: number, to: number } | null} */
721
+ let window = null;
722
+ /** @type {{ from: number, to: number } | null} */
723
+ let criticalMark = null;
724
+ /**
725
+ * Drops the pin of the fragment currently in the consumer's hands, if it
726
+ * still holds one. See where it is assigned.
727
+ *
728
+ * @type {(() => void) | null}
729
+ */
730
+ let releaseHeldPin = null;
731
+
732
+ // Identity of this read, so the store can tell one reader's window from
733
+ // another's. Each read gets its own; `readerSequence` never repeats within a
734
+ // process.
735
+ const readerId = `read-${(readerSequence += 1)}`;
736
+
737
+ /**
738
+ * Set when the window JUMPS, cleared by the first wait after it.
739
+ *
740
+ * The wait that follows a jump is the cost of the jump: the pieces at the new
741
+ * position have not been asked for yet, and the encoder is restarting. It is
742
+ * not evidence about how well this swarm SUSTAINS a read, which is the only
743
+ * thing `requiredSpeed` is about — and letting it in is what collapsed the
744
+ * quality offer 131 ms after the seek measured on 2026-08-18, refusing every
745
+ * re-encoded rung on the strength of one jump.
746
+ *
747
+ * @type {boolean}
748
+ */
749
+ let waitBelongsToJump = false;
750
+
751
+ /**
752
+ * The bands currently claimed from the swarm, most urgent first.
753
+ *
754
+ * @type {Array<{ from: number, to: number, priority: number }>}
755
+ */
756
+ let claimed = [];
757
+ /**
758
+ * What the consumer is taking from this read, in bytes a second, measured as
759
+ * it goes. For a viewer that is the film's own byte rate, which is exactly the
760
+ * quantity the band widths are derived against — and measuring it here needs
761
+ * nothing passed in and no assumption about who is reading.
762
+ */
763
+ let deliveredBytes = 0;
764
+ const readStartedAt = Date.now();
765
+ const consumeBytesPerSec = () => {
766
+ const seconds = (Date.now() - readStartedAt) / 1000;
767
+ return seconds > 0 ? deliveredBytes / seconds : 0;
768
+ };
769
+
770
+ /**
771
+ * Where the reader's own claim starts in `bands` mode: the first piece it
772
+ * does not already have. Everything between the read position and that piece
773
+ * is on disk or in memory, so claiming it asks the swarm for what we hold.
774
+ *
775
+ * @param {number} pieceIndex
776
+ * @returns {number}
777
+ */
778
+ const firstMissingFrom = (pieceIndex) => {
779
+ for (let index = pieceIndex; index <= lastPiece; index += 1) {
780
+ if (!torrent.bitfield?.get?.(index)) {
781
+ return index;
782
+ }
783
+ }
784
+ return pieceIndex;
785
+ };
786
+
787
+
788
+ /**
789
+ * The widths behind the bands currently claimed, for the log to state.
790
+ *
791
+ * @type {{ near: number, far: number, measured: boolean }}
792
+ */
793
+ let lastWidths = { near: basePieces, far: basePieces, measured: false };
794
+
795
+ const bandWidths = () => {
796
+ const figures = supplyFiguresFor(torrent?.infoHash ?? "?", file?.name ?? "", SEGMENT_SECONDS_FOR_BUFFER);
797
+ lastWidths = bandWidthsFrom({
798
+ worstWaitSec: figures?.worstWaitSec ?? null,
799
+ medianIntervalSec: figures?.medianIntervalSec ?? null,
800
+ downloadBytesPerSec: Number(torrent?.downloadSpeed) || 0,
801
+ consumeBytesPerSec: consumeBytesPerSec(),
802
+ pieceLength,
803
+ basePieces
804
+ });
805
+ return lastWidths;
806
+ };
807
+
808
+ const moveWindowTo = (pieceIndex) => {
809
+ const anchor = readMode === "bands" ? firstMissingFrom(pieceIndex) : pieceIndex;
810
+ const next = readWindowFor({ pieceIndex: anchor, lastPiece, windowPieces });
811
+ const sameUrgent = window && window.from === next.from && window.to === next.to;
812
+ if (sameUrgent && readMode === "flat") {
813
+ return;
814
+ }
815
+ const isJump = !window || next.from > window.to || next.from < window.from;
816
+ // A seek makes every width behind the urgent band meaningless: they were
817
+ // grown against a position the viewer has left, and what lies beyond the
818
+ // new one has to be earned from a standing start.
819
+ const wanted = readMode === "bands"
820
+ ? bandsFrom({
821
+ urgent: next,
822
+ pieceIndex,
823
+ firstPiece,
824
+ lastPiece,
825
+ widths: bandWidths()
826
+ })
827
+ : [{ ...next, priority: BAND_PRIORITIES[0] }];
828
+ if (sameUrgent && sameBands(claimed, wanted)) {
829
+ return;
830
+ }
831
+ for (const band of claimed) {
832
+ releaseWindow(torrent, band);
833
+ }
834
+ for (const band of wanted) {
835
+ claimWindow(torrent, band, band.priority);
836
+ }
837
+ claimed = wanted;
838
+ window = next;
839
+ // Tell the store these pieces are wanted, so it evicts something else.
840
+ // Without it the piece the decoder reads next looks exactly as stale as one
841
+ // the encoder fetched forty minutes ahead, and the second kind is what
842
+ // fills the store while the encoder runs ahead of the viewer.
843
+ store.protectRange?.(readerId, next.from, next.to);
844
+ if (isJump) {
845
+ waitBelongsToJump = true;
846
+ // A jump — a seek, not the window sliding along — can land on pieces that
847
+ // are already downloaded but have been spilled to disk. Bring the whole
848
+ // window back at once instead of one disk round trip per piece as the
849
+ // reader reaches them.
850
+ const revived = store.warmRange?.(next.from, next.to) ?? 0;
851
+ if (revived > 0) {
852
+ logger.info(
853
+ `piece-reader: reviving ${revived} spilled piece(s) of ${next.from}-${next.to} ` +
854
+ `for a jump to ${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}"`
855
+ );
856
+ }
857
+ }
858
+ };
859
+
860
+ try {
861
+ for (let pieceIndex = firstPiece; pieceIndex <= lastPiece; pieceIndex += 1) {
862
+ if (cancellation.isCancelled()) {
863
+ return;
864
+ }
865
+
866
+ const pieceStart = pieceIndex * pieceLength;
867
+ const fromWithinPiece = Math.max(absoluteStart, pieceStart) - pieceStart;
868
+ const toWithinPiece = Math.min(absoluteEnd, pieceStart + pieceLength - 1) - pieceStart;
869
+
870
+ moveWindowTo(pieceIndex);
871
+
872
+ if (!torrent.bitfield?.get(pieceIndex)) {
873
+ // Everything from here to the end of the window is wanted NOW, so all
874
+ // of it is marked, not just the piece under the head. `critical`
875
+ // enables hotswap: a block reserved by a slow peer is re-requested from
876
+ // a faster one instead of holding up the reader. Measured 2026-08-04
877
+ // with only the blocked piece marked, the first segment after a seek
878
+ // took 7.2 s while its four 4 MB pieces arrived one after another at
879
+ // ~2.2 MB/s, with waits of 1.3 s and 2.8 s on single pieces.
880
+ //
881
+ // This is not the old behaviour returning: that marked the whole
882
+ // REQUESTED RANGE, which for ffmpeg's input means every piece to the
883
+ // end of the file — hundreds of them, at which point the flag says
884
+ // nothing. A window is what a reader genuinely needs next.
885
+ criticalMark = markCritical(torrent, pieceIndex, window.to, criticalMark);
886
+ }
887
+
888
+ const waitStartedAt = Date.now();
889
+ // The reader is blocked, so this piece is now the only thing that matters
890
+ // on this torrent: hand it to the fastest wires that hold it. A block is
891
+ // reserved for exactly one wire, and the read ends when the slowest
892
+ // holder delivers — measured 2026-08-17, the swarm had a fivefold surplus
893
+ // of bandwidth and the reader still waited 1.0-4.5 s, 47 times in two
894
+ // minutes, on pieces five peers already had.
895
+ let pushed = { asked: 0, attempted: 0, considered: 0, fastestBytesPerSecond: 0 };
896
+ // The tail as it stood at an attempt that placed NOTHING — the state the
897
+ // duplication work has to answer, and the only one worth a line. Sampled
898
+ // at that instant rather than once up front, because the steering runs
899
+ // again every half second and the piece changes under it; the last such
900
+ // reading is kept, so the line describes the most recent failure.
901
+ let tailWhenNothingPlaced = null;
902
+ // What duplicating the tail placed, summed over the wait. Measured
903
+ // 2026-08-19 on a real swarm: the blocks a reader waits on are 2-14 of
904
+ // 512 and sit on wires the library considers fast, so its own hotswap
905
+ // never fires for them — see `duplicateTailFor`.
906
+ let duplicated = 0;
907
+ const pushToFastest = () => {
908
+ try {
909
+ const result = askFastestWiresFor(torrent, pieceIndex);
910
+ if (result.asked === 0) {
911
+ tailWhenNothingPlaced = describePieceTail(torrent, pieceIndex);
912
+ // Nothing could be placed the ordinary way, which means every block
913
+ // is spoken for. That is exactly when a second copy of the last
914
+ // blocks is worth asking for.
915
+ duplicated += duplicateTailFor(torrent, pieceIndex).duplicated;
916
+ }
917
+ pushed = {
918
+ asked: pushed.asked + result.asked,
919
+ // Summed like the successes, so the line compares two totals over
920
+ // the same attempts instead of a total against a snapshot.
921
+ attempted: (pushed.attempted ?? 0) + result.attempted,
922
+ considered: result.considered,
923
+ fastestBytesPerSecond: result.fastestBytesPerSecond
924
+ };
925
+ } catch (error) {
926
+ // The entry is internal to the library; if a version changes it, this
927
+ // lever stops working and that must be visible rather than silent.
928
+ logger.warn(`piece-reader: could not steer piece ${pieceIndex} — ${error?.message ?? error}`);
929
+ }
930
+ };
931
+ if (canPlaceRequests(torrent)) {
932
+ pushToFastest();
933
+ } else {
934
+ // Nothing can be placed at all on this build, so the tail is the whole
935
+ // of the answer.
936
+ tailWhenNothingPlaced = describePieceTail(torrent, pieceIndex);
937
+ logger.warn(
938
+ "piece-reader: this webtorrent build offers no way to place a request; " +
939
+ "the blocked piece cannot be steered onto a faster peer"
940
+ );
941
+ }
942
+ // Sampled while waiting rather than after: once the piece lands, nothing
943
+ // is outstanding on it any more and every count reads zero.
944
+ let supply = null;
945
+ const supplyProbe = setInterval(() => {
946
+ const sample = pieceSupply(torrent, pieceIndex);
947
+ if (!supply || sample.blocks > supply.blocks) {
948
+ supply = sample;
949
+ }
950
+ // Wires come and go, and their speeds change: a holder that was slow a
951
+ // moment ago may now be the fastest one available.
952
+ pushToFastest();
953
+ }, 500);
954
+ try {
955
+ await whenPieceReady(torrent, pieceIndex, cancellation);
956
+ } finally {
957
+ clearInterval(supplyProbe);
958
+ }
959
+ // What a reader spent waiting for data, attributed to the exact piece. A
960
+ // seek's cost is dominated by the first segment after the encoder
961
+ // restarts (measured 9.2-9.4 s), and without this there is no way to say
962
+ // whether that is the swarm, the picker, or ffmpeg. Logged only when the
963
+ // wait is long enough to matter, so ordinary sequential reading is silent.
964
+ const waitedMs = Date.now() - waitStartedAt;
965
+ // The window answers to what just happened: a wait means the lead was too
966
+ // short, an immediate hit means it is longer than it needs to be. Applied
967
+ // before the logging below so the line reports the window the next piece
968
+ // will actually use.
969
+ if (waitBelongsToJump) {
970
+ // Recorded nowhere: see `waitBelongsToJump`. Said out loud, because a
971
+ // gap in the supply history is otherwise indistinguishable from a swarm
972
+ // that never made the reader wait.
973
+ logger.info(
974
+ `piece-reader: ${waitedMs}ms on the first piece after a jump — the cost of moving, ` +
975
+ `not of this swarm's supply, so it is not counted against the quality offer`
976
+ );
977
+ waitBelongsToJump = false;
978
+ } else {
979
+ const supplyKey = `${torrent?.infoHash ?? "?"}/${file?.name ?? "?"}`;
980
+ noteSteeringOutcome(supplyKey, waitedMs, pushed.asked > 0 || duplicated > 0);
981
+ noteReadMode(supplyKey, waitedMs, readMode);
982
+ noteSupplyWait(supplyKey, file?.name ?? "", waitedMs);
983
+ }
984
+ const widened = nextWindowPieces({
985
+ current: windowPieces,
986
+ base: basePieces,
987
+ ceiling: ceilingPieces(),
988
+ waitedMs,
989
+ waitThresholdMs: PIECE_WAIT_LOG_MS
990
+ });
991
+ if (widened !== windowPieces) {
992
+ windowPieces = widened;
993
+ }
994
+ if (waitedMs >= PIECE_WAIT_LOG_MS) {
995
+ const rateKbps = Math.round(pieceLength / 1024 / (waitedMs / 1000));
996
+ logger.info(
997
+ `piece-reader: waited ${waitedMs}ms for piece ${pieceIndex} ` +
998
+ `(${pieceIndex - firstPiece + 1} of ${lastPiece - firstPiece + 1} in a read from ` +
999
+ `${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}") ` +
1000
+ `— ${rateKbps}KB/s on this piece; ` +
1001
+ (supply
1002
+ ? `${supply.holders}/${supply.peers} peers had it, ${supply.askedOf} were asked, ` +
1003
+ `${supply.blocks} blocks (${Math.round((supply.blocks * 16384) / 1024)}KB) in flight at peak`
1004
+ : "no sample taken") +
1005
+ // What WE did about it, so the next session says whether steering
1006
+ // the piece onto faster holders shortens the tail — by number
1007
+ // rather than by impression.
1008
+ `; steered onto ${pushed.asked} of ${pushed.attempted} asks (${pushed.considered} peers held it)` +
1009
+ (pushed.fastestBytesPerSecond > 0
1010
+ ? `, fastest ${Math.round(pushed.fastestBytesPerSecond / 1024)}KB/s`
1011
+ : "") +
1012
+ // Only when the steering placed nothing, which is the case that
1013
+ // decides whether duplicating the tail is worth building: it says
1014
+ // how much of the piece is still missing and which wires are
1015
+ // holding it, slowest first.
1016
+ (tailWhenNothingPlaced
1017
+ ? `; tail ${tailWhenNothingPlaced.missing}/${tailWhenNothingPlaced.chunks} blocks missing, held by ` +
1018
+ (tailWhenNothingPlaced.outstanding.length > 0
1019
+ ? tailWhenNothingPlaced.outstanding
1020
+ .map((wire) => `${wire.blocks}@${Math.round(wire.bytesPerSecond / 1024)}KB/s` +
1021
+ (wire.choking ? " (choking)" : ""))
1022
+ .join(" ")
1023
+ : "nobody")
1024
+ : "") +
1025
+ // What we did about the tail, so the next session says by number
1026
+ // whether a second copy of those blocks shortens the wait.
1027
+ (duplicated > 0 ? `; duplicated ${duplicated} blocks` : "") +
1028
+ // Which way the reader was claiming while this wait happened, and
1029
+ // the bands themselves. Without it a number in the log belongs to
1030
+ // neither arm and the comparison cannot be made afterwards.
1031
+ `; mode=${readMode}` +
1032
+ (readMode === "bands"
1033
+ ? ` ${claimed.map((band) => `p${band.priority}:${band.from}-${band.to}`).join(" ")}` +
1034
+ (lastWidths.measured
1035
+ ? ` (near ${lastWidths.near} far ${lastWidths.far} pieces, from the measured wait and surplus)`
1036
+ : " (widths not measured yet)")
1037
+ : "")
1038
+ );
1039
+ }
1040
+
1041
+ // Pinned BEFORE it is located, and before any await that could let an
1042
+ // eviction run: the offset is only meaningful while the piece is held.
1043
+ store.pin(pieceIndex);
1044
+ let located = null;
1045
+ try {
1046
+ located = await store.reside(pieceIndex);
1047
+ } catch (error) {
1048
+ store.unpin(pieceIndex);
1049
+ throw error;
1050
+ }
1051
+
1052
+ if (!located) {
1053
+ store.unpin(pieceIndex);
1054
+ throw new Error(`Piece ${pieceIndex} is verified but absent from the store.`);
1055
+ }
1056
+
1057
+ let releasedThisPiece = false;
1058
+ // Remembered so the generator can drop it itself. The pin is taken here
1059
+ // and the consumer is expected to release it — but a consumer that
1060
+ // ABANDONS the iterator never gets the chance, and a seek abandons it
1061
+ // every time: the encoder is killed, the response is torn down, and the
1062
+ // loop is left between two fragments. Field 2026-08-06: after one seek
1063
+ // every slot in the store was pinned, the store answered
1064
+ // `Every resident piece is pinned; no slot can be freed` — to the
1065
+ // WebTorrent client, which closed the store and destroyed the torrent —
1066
+ // and the session died with `File 0 not found`.
1067
+ releaseHeldPin = () => {
1068
+ if (!releasedThisPiece) {
1069
+ releasedThisPiece = true;
1070
+ store.unpin(pieceIndex);
1071
+ }
1072
+ };
1073
+ // What the consumer takes, counted as it is handed over: for a viewer this
1074
+ // is the film's own byte rate, which is what the band widths are derived
1075
+ // against.
1076
+ deliveredBytes += toWithinPiece - fromWithinPiece + 1;
1077
+ yield {
1078
+ pieceIndex,
1079
+ offset: located.offset + fromWithinPiece,
1080
+ length: toWithinPiece - fromWithinPiece + 1,
1081
+ release() {
1082
+ if (releasedThisPiece) {
1083
+ return;
1084
+ }
1085
+ releasedThisPiece = true;
1086
+ store.unpin(pieceIndex);
1087
+ }
1088
+ };
1089
+ // Handed back, and released by the consumer or not at all — either way
1090
+ // this reader no longer owes anything for it.
1091
+ releaseHeldPin = null;
1092
+ }
1093
+ } finally {
1094
+ // A fragment handed out and never released is a slot lost for the life of
1095
+ // the process. Reached on every exit, including the consumer walking away.
1096
+ if (releaseHeldPin) {
1097
+ releaseHeldPin();
1098
+ releaseHeldPin = null;
1099
+ }
1100
+ // Reached on completion, on cancellation, on a throw, and when the consumer
1101
+ // stops iterating — a window left behind would keep the swarm fetching for
1102
+ // a reader that no longer exists.
1103
+ // Reached on completion, on cancellation, on a throw, and when the consumer
1104
+ // stops iterating — a band left behind would keep the swarm fetching for a
1105
+ // reader that no longer exists.
1106
+ for (const band of claimed) {
1107
+ releaseWindow(torrent, band);
1108
+ }
1109
+ store.releaseProtection?.(readerId);
1110
+ if (criticalMark) {
1111
+ clearCritical(torrent, criticalMark);
1112
+ }
1113
+ }
1114
+ }