@torrent-tv/proxy 2.9.103 → 2.9.105
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/services/torrent-pool.js
CHANGED
|
@@ -12,7 +12,7 @@ import path from "node:path";
|
|
|
12
12
|
import { rmSync, statfsSync } from "node:fs";
|
|
13
13
|
import WebTorrent from "webtorrent";
|
|
14
14
|
import { logger } from "../utils/logger.js";
|
|
15
|
-
import { SharedPieceStore } from "./piece-store/shared-piece-store.js";
|
|
15
|
+
import { SharedPieceStore, findSharedStore } from "./piece-store/shared-piece-store.js";
|
|
16
16
|
|
|
17
17
|
// WebTorrent's default download root (see webtorrent lib/torrent.js: TMP =
|
|
18
18
|
// path.join(os.tmpdir(), 'webtorrent')). We use the default store, so all
|
|
@@ -92,6 +92,73 @@ const UPLOAD_ADJUST_INTERVAL_MS = 5_000;
|
|
|
92
92
|
* outright, for two unchoke cycles, without waiting for evidence of failure.
|
|
93
93
|
*/
|
|
94
94
|
const UPLOAD_HURRY_MS = 25_000;
|
|
95
|
+
// A torrent somebody is reading, that is not finished, and is moving less than
|
|
96
|
+
// this, is not downloading. Well below the slowest real swarm seen in the field
|
|
97
|
+
// (470 KB/s two seconds after a cold add) and well above idle chatter.
|
|
98
|
+
const STALL_SPEED_BYTES = 32 * 1024;
|
|
99
|
+
// How long it must stay there before saying so, and how often to repeat.
|
|
100
|
+
const STALL_REPORT_AFTER_MS = 10_000;
|
|
101
|
+
const STALL_REPORT_INTERVAL_MS = 30_000;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What the swarm has been asked for, and what it is doing about it.
|
|
105
|
+
*
|
|
106
|
+
* Answers the question a stalled download cannot answer for itself: were the
|
|
107
|
+
* peers never told what we want, or told and not delivering? Reaches into
|
|
108
|
+
* WebTorrent's own bookkeeping because none of it is exposed — `_selections`
|
|
109
|
+
* is what the picker walks, `wire.requests` is what is actually outstanding.
|
|
110
|
+
*
|
|
111
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
function describeSwarmDemand(torrent) {
|
|
115
|
+
const items = Array.isArray(torrent?._selections?._items) ? torrent._selections._items : [];
|
|
116
|
+
let selectedPieces = 0;
|
|
117
|
+
let missingSelected = 0;
|
|
118
|
+
for (const item of items) {
|
|
119
|
+
const from = Number(item?.from);
|
|
120
|
+
const to = Number(item?.to);
|
|
121
|
+
if (!Number.isFinite(from) || !Number.isFinite(to)) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
selectedPieces += to - from + 1;
|
|
125
|
+
for (let index = from; index <= to; index += 1) {
|
|
126
|
+
if (!torrent.bitfield?.get(index)) {
|
|
127
|
+
missingSelected += 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
|
|
133
|
+
let inFlight = 0;
|
|
134
|
+
let asking = 0;
|
|
135
|
+
let choking = 0;
|
|
136
|
+
let interested = 0;
|
|
137
|
+
for (const wire of wires) {
|
|
138
|
+
const requests = Array.isArray(wire?.requests) ? wire.requests.length : 0;
|
|
139
|
+
inFlight += requests;
|
|
140
|
+
if (requests > 0) {
|
|
141
|
+
asking += 1;
|
|
142
|
+
}
|
|
143
|
+
if (wire?.peerChoking === true) {
|
|
144
|
+
choking += 1;
|
|
145
|
+
}
|
|
146
|
+
if (wire?.amInterested === true) {
|
|
147
|
+
interested += 1;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const critical = Array.isArray(torrent?._critical)
|
|
152
|
+
? torrent._critical.reduce((count, flag) => (flag ? count + 1 : count), 0)
|
|
153
|
+
: 0;
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
`${items.length} selection(s) covering ${selectedPieces} piece(s), ` +
|
|
157
|
+
`${missingSelected} of them missing, ${critical} marked critical; ` +
|
|
158
|
+
`${wires.length} peers, ${interested} we want data from, ${choking} choking us, ` +
|
|
159
|
+
`${asking} being asked, ${inFlight} blocks in flight`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
95
162
|
|
|
96
163
|
/**
|
|
97
164
|
* Decide the client-wide upload limit (bytes/sec) from the torrents that
|
|
@@ -383,6 +450,11 @@ export class TorrentPool {
|
|
|
383
450
|
*/
|
|
384
451
|
#readPositionByTorrent = new Map();
|
|
385
452
|
|
|
453
|
+
/** When each torrent's download first fell below the stall threshold. */
|
|
454
|
+
#stallSince = new Map();
|
|
455
|
+
/** When each torrent's stall was last reported, so it is not repeated hotly. */
|
|
456
|
+
#stallReportedAt = new Map();
|
|
457
|
+
|
|
386
458
|
/**
|
|
387
459
|
* Edge prefetches currently running, keyed by infoHash and file index, so two
|
|
388
460
|
* callers asking at the same time share one.
|
|
@@ -507,6 +579,120 @@ export class TorrentPool {
|
|
|
507
579
|
this.#adjustUploadLimit();
|
|
508
580
|
}
|
|
509
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Say when a torrent that somebody is reading has stopped downloading.
|
|
584
|
+
*
|
|
585
|
+
* Field 2026-08-05: a session died 32 minutes in because the download fell to
|
|
586
|
+
* **1 KB/s for five minutes** with 186 peer connections open and trackers
|
|
587
|
+
* reporting ~300 seeders, on a torrent that was not finished. ffmpeg then ran
|
|
588
|
+
* out of input and reported itself complete at segment 188 of 624. Not one
|
|
589
|
+
* line of the log said anything was wrong — the collapse had to be recovered
|
|
590
|
+
* afterwards by hand from three unrelated counters.
|
|
591
|
+
*
|
|
592
|
+
* So the stall reports itself, and it reports the two things that tell the
|
|
593
|
+
* candidates apart: whether the swarm was ASKED for anything (pieces selected
|
|
594
|
+
* and still missing, blocks in flight) or was asked and did not answer (peers
|
|
595
|
+
* holding what we want, how many are choking us).
|
|
596
|
+
*
|
|
597
|
+
* @returns {void}
|
|
598
|
+
*/
|
|
599
|
+
/**
|
|
600
|
+
* Put back the reader windows WebTorrent has quietly dropped.
|
|
601
|
+
*
|
|
602
|
+
* A reader claims its window as a stream selection and releases it when it
|
|
603
|
+
* ends. That claim is NOT durable: `_gcSelections` deletes a selection the
|
|
604
|
+
* moment every piece in it is present, so a window that has been satisfied
|
|
605
|
+
* stops existing — and with it, everything the swarm had been asked for.
|
|
606
|
+
*
|
|
607
|
+
* While a reader keeps moving that is invisible, because the next window is
|
|
608
|
+
* claimed immediately. It becomes fatal when the reader STOPS: the encoder is
|
|
609
|
+
* held back by the look-ahead cap, ffmpeg stops reading, the reader parks on a
|
|
610
|
+
* window that is fully downloaded, the selection disappears — and nothing our
|
|
611
|
+
* code runs can notice, because the reader is parked inside a write. Measured
|
|
612
|
+
* 2026-08-05: the encoder was suspended at 22:44:51, the download hit zero at
|
|
613
|
+
* 22:45:05 and stayed there for **eleven minutes** with 150 peer connections
|
|
614
|
+
* open, `0 selection(s) covering 0 piece(s), 0 being asked, 0 blocks in
|
|
615
|
+
* flight`. When the encoder was let go there was nothing ahead of it, and the
|
|
616
|
+
* viewer's picture stopped.
|
|
617
|
+
*
|
|
618
|
+
* So the claim is re-asserted from outside, on this timer, using the windows
|
|
619
|
+
* the store already knows about — those are declared by live readers and
|
|
620
|
+
* withdrawn when they end, which is exactly the set that should be selected.
|
|
621
|
+
*
|
|
622
|
+
* @returns {void}
|
|
623
|
+
*/
|
|
624
|
+
#reassertReaderWindows() {
|
|
625
|
+
for (const torrent of this.torrents.values()) {
|
|
626
|
+
const usage = this.fileUsageByTorrent.get(torrent);
|
|
627
|
+
if (!usage || usage.size === 0 || torrent?.done === true) {
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
const store = findSharedStore(torrent);
|
|
631
|
+
const ranges = typeof store?.protectedRanges === "function" ? store.protectedRanges() : [];
|
|
632
|
+
if (ranges.length === 0) {
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
const items = Array.isArray(torrent?._selections?._items) ? torrent._selections._items : [];
|
|
636
|
+
for (const range of ranges) {
|
|
637
|
+
const present = items.some((item) => item?.from === range.from && item?.to === range.to);
|
|
638
|
+
if (present) {
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
// Only worth re-claiming what is actually missing: re-claiming a window
|
|
642
|
+
// that is already complete would be deleted again on the next pass and
|
|
643
|
+
// the two would take turns forever.
|
|
644
|
+
let missing = false;
|
|
645
|
+
for (let index = range.from; index <= range.to && !missing; index += 1) {
|
|
646
|
+
if (!torrent.bitfield?.get(index)) {
|
|
647
|
+
missing = true;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
if (!missing) {
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
torrent._select(range.from, range.to, 1, null, true);
|
|
655
|
+
logger.info(
|
|
656
|
+
`torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] re-claimed reader window ` +
|
|
657
|
+
`${range.from}-${range.to} — the selection had been dropped once it was satisfied`
|
|
658
|
+
);
|
|
659
|
+
} catch {
|
|
660
|
+
// Best effort: a torrent being torn down is not worth failing over.
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
#reportStalledDownloads() {
|
|
667
|
+
const now = Date.now();
|
|
668
|
+
for (const torrent of this.torrents.values()) {
|
|
669
|
+
const usage = this.fileUsageByTorrent.get(torrent);
|
|
670
|
+
if (!usage || usage.size === 0 || torrent?.done === true) {
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
const speed = typeof torrent.downloadSpeed === "number" ? torrent.downloadSpeed : 0;
|
|
674
|
+
if (speed >= STALL_SPEED_BYTES) {
|
|
675
|
+
this.#stallSince.delete(torrent);
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
const since = this.#stallSince.get(torrent) ?? now;
|
|
679
|
+
this.#stallSince.set(torrent, since);
|
|
680
|
+
if (now - since < STALL_REPORT_AFTER_MS) {
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
const lastReport = this.#stallReportedAt.get(torrent) ?? 0;
|
|
684
|
+
if (now - lastReport < STALL_REPORT_INTERVAL_MS) {
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
this.#stallReportedAt.set(torrent, now);
|
|
688
|
+
logger.warn(
|
|
689
|
+
`torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] download stalled at ` +
|
|
690
|
+
`${Math.round(speed / 1024)}KB/s for ${Math.round((now - since) / 1000)}s — ` +
|
|
691
|
+
describeSwarmDemand(torrent)
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
510
696
|
#adjustUploadLimit() {
|
|
511
697
|
if (!this.client || this.client.destroyed || typeof this.client.throttleUpload !== "function") {
|
|
512
698
|
return;
|
|
@@ -516,6 +702,8 @@ export class TorrentPool {
|
|
|
516
702
|
this.fileUsageByTorrent,
|
|
517
703
|
Date.now()
|
|
518
704
|
);
|
|
705
|
+
this.#reassertReaderWindows();
|
|
706
|
+
this.#reportStalledDownloads();
|
|
519
707
|
const { bytesPerSec, reason } = decideUploadLimit(active);
|
|
520
708
|
if (bytesPerSec === this.#uploadLimit) {
|
|
521
709
|
return;
|