@torrent-tv/proxy 2.50.0 → 2.52.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.
- package/CHANGELOG.md +20 -0
- package/package.json +1 -1
- package/routes/api/sources/stats/get.js +40 -4
- package/routes/api/transcode-sessions/fragment-far/post.js +60 -0
- package/server.js +4 -0
- package/services/hls-session-manager.js +298 -22
- package/services/torrent-pool.js +440 -22
- package/services/torrent-worker/worker.js +9 -2
- package/test/dht-bootstrap.test.js +72 -0
- package/test/run-position-follows-published-grid.test.js +87 -0
- package/test/swarm-reach.test.js +125 -0
- package/test/viewer-position-on-open.test.js +53 -0
package/services/torrent-pool.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import crypto from "node:crypto";
|
|
10
|
+
import dns from "node:dns/promises";
|
|
10
11
|
import os from "node:os";
|
|
11
12
|
import path from "node:path";
|
|
12
13
|
import { rmSync, statfsSync } from "node:fs";
|
|
@@ -14,6 +15,39 @@ import WebTorrent from "webtorrent";
|
|
|
14
15
|
import { logger } from "../utils/logger.js";
|
|
15
16
|
import { SharedPieceStore, findSharedStore } from "./piece-store/shared-piece-store.js";
|
|
16
17
|
|
|
18
|
+
// The DHT's entry points. Two of the three the library ships answer nothing —
|
|
19
|
+
// measured 2026-08-21 from the addon host: `router.bittorrent.com` and
|
|
20
|
+
// `router.utorrent.com` did not reply to a hand-written `ping` at all, while a
|
|
21
|
+
// control datagram to a DNS server came back in 20 ms, so the silence is
|
|
22
|
+
// theirs. They stay in the list because they cost nothing and may come back;
|
|
23
|
+
// what the list needed was entries that answer today.
|
|
24
|
+
//
|
|
25
|
+
// The names are RESOLVED HERE, to IPv4, and the addresses are what the library
|
|
26
|
+
// is given. That is the second half of the fault: `dht.transmissionbt.com` is
|
|
27
|
+
// alive — it answered `find_node` with eight nodes — but on a host with global
|
|
28
|
+
// IPv6 its name resolves to an IPv6 address first, and the DHT's socket is
|
|
29
|
+
// IPv4, so by name the one live entry was never reached. Measured the same day:
|
|
30
|
+
// by name, 0 nodes after 21 s, every run; by address, 22 nodes in 5 s.
|
|
31
|
+
//
|
|
32
|
+
// Addresses are not written down. They rot exactly as the old list rotted.
|
|
33
|
+
const DHT_BOOTSTRAP_NAMES = [
|
|
34
|
+
"dht.transmissionbt.com:6881",
|
|
35
|
+
"dht.libtorrent.org:25401",
|
|
36
|
+
"router.bittorrent.com:6881",
|
|
37
|
+
"router.utorrent.com:6881"
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// How long after start a still-empty routing table is worth saying out loud.
|
|
41
|
+
// Bootstrapping takes seconds; a table empty after this is a list that has died
|
|
42
|
+
// and nobody has noticed, which is the state this host was found in.
|
|
43
|
+
const DHT_EMPTY_REPORT_MS = 60 * 1000;
|
|
44
|
+
|
|
45
|
+
// How long one bootstrap name may take to resolve. This is awaited before the
|
|
46
|
+
// torrent client is built, so it is time during which the thread answers
|
|
47
|
+
// nothing — and the DHT is best-effort, so a name that is slow to resolve is
|
|
48
|
+
// worth less than the delay of waiting for it.
|
|
49
|
+
const DHT_RESOLVE_TIMEOUT_MS = 2000;
|
|
50
|
+
|
|
17
51
|
// WebTorrent's default download root (see webtorrent lib/torrent.js: TMP =
|
|
18
52
|
// path.join(os.tmpdir(), 'webtorrent')). We use the default store, so all
|
|
19
53
|
// torrent data lives under here.
|
|
@@ -100,6 +134,100 @@ const STALL_SPEED_BYTES = 32 * 1024;
|
|
|
100
134
|
const STALL_REPORT_AFTER_MS = 10_000;
|
|
101
135
|
const STALL_REPORT_INTERVAL_MS = 30_000;
|
|
102
136
|
|
|
137
|
+
/**
|
|
138
|
+
* How far this torrent has got towards HAVING a swarm: connected, known, and
|
|
139
|
+
* waiting to be tried.
|
|
140
|
+
*
|
|
141
|
+
* The question a stalled download is asked is "were we offered anybody", and
|
|
142
|
+
* until 2026-08-21 nothing could answer it. The line printed `peers=` beside
|
|
143
|
+
* `wires=?`, which read as two quantities of which one was unknown — while in
|
|
144
|
+
* fact WebTorrent's `numPeers` IS `wires.length` (`lib/torrent.js`, both 2.8.5
|
|
145
|
+
* and 3.0.21), so the first was the connection count and the second was a
|
|
146
|
+
* field that has never printed anything. What was missing is the other side:
|
|
147
|
+
* how many peer addresses the client HOLDS but is not connected to. A tracker
|
|
148
|
+
* answering `seeders=5` while `connected=0, known=0` is a different fault from
|
|
149
|
+
* `connected=0, known=5`, and only the second is about connecting.
|
|
150
|
+
*
|
|
151
|
+
* `_peersLength` and `_numQueued` are WebTorrent internals, not its published
|
|
152
|
+
* interface — a cached counter and a getter in 2.8.5, both getters in 3.0.21,
|
|
153
|
+
* read the same way in each. Read defensively on purpose: if a later version
|
|
154
|
+
* drops them the field says nothing rather than breaking a poll the browser
|
|
155
|
+
* makes every two seconds.
|
|
156
|
+
*
|
|
157
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
158
|
+
* @returns {{ connectedPeers: number, knownPeers: number | null, queuedPeers: number | null }}
|
|
159
|
+
*/
|
|
160
|
+
export function describeSwarmReach(torrent) {
|
|
161
|
+
const wires = Array.isArray(torrent?.wires) ? torrent.wires.length : 0;
|
|
162
|
+
const read = (value) => (typeof value === "number" && Number.isFinite(value) ? value : null);
|
|
163
|
+
let knownPeers = null;
|
|
164
|
+
let queuedPeers = null;
|
|
165
|
+
try {
|
|
166
|
+
knownPeers = read(torrent?._peersLength);
|
|
167
|
+
queuedPeers = read(torrent?._numQueued);
|
|
168
|
+
} catch {
|
|
169
|
+
// silent-ok: these are internals behind getters that can throw on a
|
|
170
|
+
// destroyed torrent, and the reading is a diagnostic. Nothing here is
|
|
171
|
+
// worth failing a stats poll for.
|
|
172
|
+
}
|
|
173
|
+
return { connectedPeers: wires, knownPeers, queuedPeers };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* How long a torrent waited for its first connected peer.
|
|
178
|
+
*
|
|
179
|
+
* The whole of a cold start can be this one number and nothing else: measured
|
|
180
|
+
* 2026-08-21, a tracker answered `seeders=5` at 13:40:30 and the first wire
|
|
181
|
+
* arrived at 13:44:47 — 257 s during which the stats line repeated, unchanged,
|
|
182
|
+
* every two seconds. Once the peer connected the file's edges arrived at
|
|
183
|
+
* 6.8 MB/s and the plan finished in three seconds. It was never measured,
|
|
184
|
+
* never named, and the viewer saw it as an unexplained wait.
|
|
185
|
+
*
|
|
186
|
+
* @param {number} addedAtMs
|
|
187
|
+
* @param {number} firstPeerAtMs
|
|
188
|
+
* @returns {number | null} Seconds, or null while there is still no peer or
|
|
189
|
+
* the two moments cannot be compared.
|
|
190
|
+
*/
|
|
191
|
+
export function secondsToFirstPeer(addedAtMs, firstPeerAtMs) {
|
|
192
|
+
if (!Number.isFinite(addedAtMs) || !Number.isFinite(firstPeerAtMs)) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
const seconds = (firstPeerAtMs - addedAtMs) / 1000;
|
|
196
|
+
return seconds >= 0 ? Number(seconds.toFixed(3)) : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The best answer any tracker has given, out of the answers they have given.
|
|
201
|
+
*
|
|
202
|
+
* A torrent announces to every tracker it lists and each answers separately,
|
|
203
|
+
* so keeping "the last one" makes the reading depend on which tracker replied
|
|
204
|
+
* most recently. A live tracker saying `complete=500` followed two seconds
|
|
205
|
+
* later by a dead one saying `complete=0` would print "nobody offered" — and
|
|
206
|
+
* telling that from "several offered and we reached none" is the entire reason
|
|
207
|
+
* this figure is carried. The best answer is the honest one: a swarm has as
|
|
208
|
+
* many seeders as the most informed tracker knows about.
|
|
209
|
+
*
|
|
210
|
+
* @param {Iterable<{ seeders: number | null, leechers: number | null }>} answers
|
|
211
|
+
* @returns {{ seeders: number | null, leechers: number | null, trackers: number }}
|
|
212
|
+
*/
|
|
213
|
+
export function bestAnnounce(answers) {
|
|
214
|
+
let seeders = null;
|
|
215
|
+
let leechers = null;
|
|
216
|
+
let trackers = 0;
|
|
217
|
+
for (const answer of answers ?? []) {
|
|
218
|
+
trackers += 1;
|
|
219
|
+
if (typeof answer?.seeders === "number" && (seeders === null || answer.seeders > seeders)) {
|
|
220
|
+
seeders = answer.seeders;
|
|
221
|
+
// Taken from the SAME answer, including when that answer gave no leecher
|
|
222
|
+
// count. Carrying the previous tracker's figure forward would pair one
|
|
223
|
+
// tracker's seeders with another's leechers and present the pair as one
|
|
224
|
+
// reading.
|
|
225
|
+
leechers = typeof answer.leechers === "number" ? answer.leechers : null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { seeders, leechers, trackers };
|
|
229
|
+
}
|
|
230
|
+
|
|
103
231
|
/**
|
|
104
232
|
* What the swarm has been asked for, and what it is doing about it.
|
|
105
233
|
*
|
|
@@ -449,6 +577,103 @@ function isMagnetSource(torrentId) {
|
|
|
449
577
|
return typeof torrentId === "string" && /^magnet:\?/i.test(torrentId.trim());
|
|
450
578
|
}
|
|
451
579
|
|
|
580
|
+
/**
|
|
581
|
+
* Split a `host` or `host:port` bootstrap entry.
|
|
582
|
+
*
|
|
583
|
+
* @param {unknown} entry
|
|
584
|
+
* @returns {{ host: string, port: number } | null} Null for anything that is
|
|
585
|
+
* not a usable entry, so a typo drops one node instead of failing the client.
|
|
586
|
+
*/
|
|
587
|
+
export function parseBootstrapEntry(entry) {
|
|
588
|
+
if (typeof entry !== "string") {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
const trimmed = entry.trim();
|
|
592
|
+
if (trimmed.length === 0) {
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
const colon = trimmed.lastIndexOf(":");
|
|
596
|
+
if (colon < 0) {
|
|
597
|
+
return { host: trimmed, port: 6881 };
|
|
598
|
+
}
|
|
599
|
+
const host = trimmed.slice(0, colon);
|
|
600
|
+
const port = Number(trimmed.slice(colon + 1));
|
|
601
|
+
if (host.length === 0 || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
return { host, port };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Turn bootstrap names into `address:port` entries, keeping only IPv4.
|
|
609
|
+
*
|
|
610
|
+
* Why the resolution happens here rather than being left to the library: the
|
|
611
|
+
* DHT's socket is IPv4, and a name that resolves to IPv6 first is silently
|
|
612
|
+
* unreachable through it — which is how a live bootstrap node came to look
|
|
613
|
+
* dead on this host. See {@link DHT_BOOTSTRAP_NAMES}.
|
|
614
|
+
*
|
|
615
|
+
* Best-effort by construction. A name that does not resolve is dropped and
|
|
616
|
+
* said; if none resolve the caller gets an empty list and the library keeps its
|
|
617
|
+
* own defaults, which is no worse than before.
|
|
618
|
+
*
|
|
619
|
+
* @param {string[]} [names]
|
|
620
|
+
* @param {number} [timeoutMs] - How long one name may take before it is
|
|
621
|
+
* treated as unresolvable.
|
|
622
|
+
* @returns {Promise<string[]>}
|
|
623
|
+
*/
|
|
624
|
+
export async function resolveDhtBootstrap(names = DHT_BOOTSTRAP_NAMES, timeoutMs = DHT_RESOLVE_TIMEOUT_MS) {
|
|
625
|
+
const entries = names.map(parseBootstrapEntry).filter((entry) => entry !== null);
|
|
626
|
+
const resolved = [];
|
|
627
|
+
const failed = [];
|
|
628
|
+
await Promise.all(
|
|
629
|
+
entries.map(async ({ host, port }) => {
|
|
630
|
+
try {
|
|
631
|
+
// Capped, because this is awaited before the torrent client exists and
|
|
632
|
+
// therefore before this thread will answer anything. `dns.resolve4`
|
|
633
|
+
// talks to the host's resolver with c-ares' own defaults — about five
|
|
634
|
+
// seconds times four tries — so a black-holing resolver would hold the
|
|
635
|
+
// whole worker for twenty seconds with nothing said. A name that does
|
|
636
|
+
// not answer inside the cap is treated exactly like one that fails:
|
|
637
|
+
// dropped, named, and the rest of the list stands.
|
|
638
|
+
const addresses = await Promise.race([
|
|
639
|
+
dns.resolve4(host),
|
|
640
|
+
new Promise((_resolve, reject) => {
|
|
641
|
+
const timer = setTimeout(() => reject(new Error("timed out")), timeoutMs);
|
|
642
|
+
timer.unref?.();
|
|
643
|
+
})
|
|
644
|
+
]);
|
|
645
|
+
for (const address of addresses) {
|
|
646
|
+
resolved.push(`${address}:${port}`);
|
|
647
|
+
}
|
|
648
|
+
} catch {
|
|
649
|
+
failed.push(`${host}:${port}`);
|
|
650
|
+
}
|
|
651
|
+
})
|
|
652
|
+
);
|
|
653
|
+
if (failed.length > 0) {
|
|
654
|
+
logger.warn(`torrent-pool: DHT bootstrap names that do not resolve to IPv4: ${failed.join(", ")}`);
|
|
655
|
+
}
|
|
656
|
+
return resolved;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* How many nodes the client's DHT knows, or null when it has no DHT at all.
|
|
661
|
+
*
|
|
662
|
+
* @param {{ dht?: { nodes?: { toArray?: () => unknown[] } } }} client
|
|
663
|
+
* @returns {number | null}
|
|
664
|
+
*/
|
|
665
|
+
export function dhtNodeCount(client) {
|
|
666
|
+
const nodes = client?.dht?.nodes;
|
|
667
|
+
if (!nodes || typeof nodes.toArray !== "function") {
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
try {
|
|
671
|
+
return nodes.toArray().length;
|
|
672
|
+
} catch {
|
|
673
|
+
return null;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
452
677
|
export class TorrentPool {
|
|
453
678
|
/**
|
|
454
679
|
* In-flight `client.add()` promises keyed by the same key as `torrents`.
|
|
@@ -521,13 +746,36 @@ export class TorrentPool {
|
|
|
521
746
|
/** Periodic adaptive-upload adjustment timer. */
|
|
522
747
|
#uploadAdjustTimer = null;
|
|
523
748
|
|
|
749
|
+
/** One-shot timer that reports the size of the DHT's routing table. */
|
|
750
|
+
#dhtReportTimer = null;
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* The last announce answer per torrent — what the TRACKER says the swarm
|
|
754
|
+
* holds, as opposed to what we have managed to connect to. Kept because the
|
|
755
|
+
* two disagreeing is the whole diagnosis: five seeders offered and none
|
|
756
|
+
* connected is a connectivity fault, and nobody offered is a supply fault.
|
|
757
|
+
*
|
|
758
|
+
* Kept PER TRACKER, because each answers for itself and the most recent
|
|
759
|
+
* answer is not the most informed one.
|
|
760
|
+
*
|
|
761
|
+
* @type {WeakMap<import("webtorrent").Torrent, Map<string, { seeders: number | null, leechers: number | null, at: number }>>}
|
|
762
|
+
*/
|
|
763
|
+
#lastAnnounceByTorrent = new WeakMap();
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* When each torrent was added, and when its first peer connected.
|
|
767
|
+
*
|
|
768
|
+
* @type {WeakMap<import("webtorrent").Torrent, { addedAt: number, firstPeerAt: number | null }>}
|
|
769
|
+
*/
|
|
770
|
+
#swarmTimingByTorrent = new WeakMap();
|
|
771
|
+
|
|
524
772
|
/**
|
|
525
773
|
* @param {{ maxDiskBytes?: number }} [options]
|
|
526
774
|
* `maxDiskBytes` caps total downloaded torrent data; when omitted a
|
|
527
775
|
* default is computed from free disk (min(10 GB, half free)). Pass 0 to
|
|
528
776
|
* disable the cap.
|
|
529
777
|
*/
|
|
530
|
-
constructor({ maxDiskBytes, memoryBytes } = {}) {
|
|
778
|
+
constructor({ maxDiskBytes, memoryBytes, dhtBootstrap } = {}) {
|
|
531
779
|
this.#memoryBytes = Number.isFinite(memoryBytes) && memoryBytes > 0 ? memoryBytes : undefined;
|
|
532
780
|
|
|
533
781
|
// Sweep orphaned torrent data left by a previous hard kill (no graceful
|
|
@@ -541,8 +789,33 @@ export class TorrentPool {
|
|
|
541
789
|
logger.warn(`torrent-pool: could not sweep orphaned store at startup: ${message}`);
|
|
542
790
|
}
|
|
543
791
|
|
|
792
|
+
const bootstrap = Array.isArray(dhtBootstrap) ? dhtBootstrap.filter(Boolean) : [];
|
|
793
|
+
if (bootstrap.length > 0) {
|
|
794
|
+
logger.info(`torrent-pool: DHT bootstrap nodes: ${bootstrap.join(", ")}`);
|
|
795
|
+
} else {
|
|
796
|
+
logger.warn("torrent-pool: no DHT bootstrap nodes resolved; the library's own list is all there is");
|
|
797
|
+
}
|
|
544
798
|
/** @type {import("webtorrent").WebTorrent} */
|
|
545
|
-
this.client = new WebTorrent();
|
|
799
|
+
this.client = new WebTorrent(bootstrap.length > 0 ? { dht: { bootstrap } } : undefined);
|
|
800
|
+
// A bootstrap list rots, and it rots silently: the one the library ships
|
|
801
|
+
// had two dead entries and a third that could not be reached by name, and
|
|
802
|
+
// nothing said so for as long as that was true. Say it once, late enough
|
|
803
|
+
// that a slow bootstrap is not mistaken for a dead one.
|
|
804
|
+
this.#dhtReportTimer = setTimeout(() => {
|
|
805
|
+
const nodes = dhtNodeCount(this.client);
|
|
806
|
+
if (nodes === null) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (nodes === 0) {
|
|
810
|
+
logger.warn(
|
|
811
|
+
"torrent-pool: the DHT knows no nodes a minute after start — its bootstrap list is not " +
|
|
812
|
+
"answering, so a torrent has only its trackers to find peers with"
|
|
813
|
+
);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
logger.info(`torrent-pool: the DHT knows ${nodes} nodes`);
|
|
817
|
+
}, DHT_EMPTY_REPORT_MS);
|
|
818
|
+
this.#dhtReportTimer.unref?.();
|
|
546
819
|
|
|
547
820
|
/**
|
|
548
821
|
* Active torrents keyed by `"${sourceType}:${sha1(source)}"`.
|
|
@@ -616,7 +889,10 @@ export class TorrentPool {
|
|
|
616
889
|
}
|
|
617
890
|
torrent.hurryUntil = until;
|
|
618
891
|
logger.info(
|
|
619
|
-
`
|
|
892
|
+
// `?? "?"` because one caller is the moment of adding, and `client.add`
|
|
893
|
+
// returns before the torrent id has been parsed — without it the line
|
|
894
|
+
// printed the first eight characters of the word "undefined".
|
|
895
|
+
`torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] uploading generously for ` +
|
|
620
896
|
`${Math.round(UPLOAD_HURRY_MS / 1000)}s — ${why}`
|
|
621
897
|
);
|
|
622
898
|
this.#adjustUploadLimit();
|
|
@@ -924,24 +1200,102 @@ export class TorrentPool {
|
|
|
924
1200
|
* warnings (tracker rejections/errors surface here). Without these a
|
|
925
1201
|
* zero-peer torrent gives no clue WHY it has no peers.
|
|
926
1202
|
*
|
|
927
|
-
* @param {string} label - Short source label for log lines.
|
|
928
1203
|
* @param {import("webtorrent").Torrent} torrent
|
|
929
1204
|
* @returns {void}
|
|
930
1205
|
*/
|
|
931
|
-
#attachSwarmDiagnostics(
|
|
1206
|
+
#attachSwarmDiagnostics(torrent) {
|
|
1207
|
+
// Attached ONCE per torrent. WebTorrent answers a duplicate add by handing
|
|
1208
|
+
// back the torrent it already has, and this used to run again on it: a
|
|
1209
|
+
// torrent with twelve connections and a first peer five minutes old had its
|
|
1210
|
+
// timing record reset, so it began reporting "no peer yet" and the next
|
|
1211
|
+
// connection printed "first peer connected after 0.3s" — a false statement
|
|
1212
|
+
// about a swarm that had been healthy for minutes. The same film opened
|
|
1213
|
+
// once as a .torrent and once as a magnet is exactly that case, and the
|
|
1214
|
+
// roadmap already records it happening.
|
|
1215
|
+
if (this.#swarmTimingByTorrent.has(torrent)) {
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
932
1218
|
// A torrent nobody has asked for yet does not exist: this is called the
|
|
933
1219
|
// moment one is added, which is the moment a viewer started waiting.
|
|
934
1220
|
this.#markHurry(torrent, "just added");
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1221
|
+
// ONE name for a torrent, and it is the infohash. These lines used to be
|
|
1222
|
+
// labelled with the first eight characters of a sha1 of the SOURCE BYTES,
|
|
1223
|
+
// while the neighbouring lines used the infohash and the stats line used
|
|
1224
|
+
// the registry's own key — three different hashes of one film, in the same
|
|
1225
|
+
// second, none of them matching. Correlating a swarm across three lines
|
|
1226
|
+
// cost a guess every time. The infohash is the one identifier every side
|
|
1227
|
+
// of this system already shares.
|
|
1228
|
+
//
|
|
1229
|
+
// Read at the moment of PRINTING, not now. `client.add` returns before the
|
|
1230
|
+
// torrent id has been parsed — measured against the vendored 2.8.5, both a
|
|
1231
|
+
// magnet and a .torrent buffer have `infoHash === undefined` on the line
|
|
1232
|
+
// after `add` returns — so a label captured here would be the string "?"
|
|
1233
|
+
// for the whole life of the torrent, which is the same fault as three
|
|
1234
|
+
// different hashes with the hash removed.
|
|
1235
|
+
const label = () => String(torrent.infoHash ?? "?").slice(0, 8);
|
|
1236
|
+
const addedAt = Date.now();
|
|
1237
|
+
this.#swarmTimingByTorrent.set(torrent, { addedAt, firstPeerAt: null });
|
|
1238
|
+
|
|
1239
|
+
// The first connected peer, said once, because the wait for it can BE the
|
|
1240
|
+
// whole cold start and nothing else measures it.
|
|
1241
|
+
torrent.on("wire", () => {
|
|
1242
|
+
const timing = this.#swarmTimingByTorrent.get(torrent);
|
|
1243
|
+
if (!timing || timing.firstPeerAt !== null) {
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
timing.firstPeerAt = Date.now();
|
|
1247
|
+
const waited = secondsToFirstPeer(timing.addedAt, timing.firstPeerAt);
|
|
1248
|
+
const byTracker = this.#lastAnnounceByTorrent.get(torrent);
|
|
1249
|
+
const offered = bestAnnounce(byTracker ? byTracker.values() : []);
|
|
1250
|
+
logger.info(
|
|
1251
|
+
`torrent-pool: [${label()}] first peer connected after ${waited === null ? "?" : waited.toFixed(1)}s` +
|
|
1252
|
+
(offered.trackers > 0
|
|
1253
|
+
? ` (${offered.trackers} tracker(s) had answered, best seeders=${offered.seeders ?? "?"} ` +
|
|
1254
|
+
`leechers=${offered.leechers ?? "?"})`
|
|
1255
|
+
: " (no tracker answer had arrived)")
|
|
1256
|
+
);
|
|
1257
|
+
});
|
|
940
1258
|
|
|
941
1259
|
torrent.on("warning", (warning) => {
|
|
942
|
-
logger.warn(`torrent-pool: [${label}] warning: ${formatWarning(warning)}`);
|
|
1260
|
+
logger.warn(`torrent-pool: [${label()}] warning: ${formatWarning(warning)}`);
|
|
943
1261
|
});
|
|
944
1262
|
|
|
1263
|
+
// Everything below needs the torrent to have been PARSED, and `add` returns
|
|
1264
|
+
// before that: `announce`, `files` and `private` are all still empty, and
|
|
1265
|
+
// `discovery` — which owns the tracker client — is not created until
|
|
1266
|
+
// `_startDiscovery`, which runs immediately before `ready` is emitted.
|
|
1267
|
+
// Attaching the tracker listener at add-time therefore attaches it to
|
|
1268
|
+
// nothing at all, and every announce answer is lost. The two listeners
|
|
1269
|
+
// above stay where they are, because `wire` and `warning` live on the
|
|
1270
|
+
// torrent from construction and both fire before `ready`: the peer that
|
|
1271
|
+
// DELIVERS a magnet's metadata connects first, and `wire` is emitted on
|
|
1272
|
+
// connection and never replayed.
|
|
1273
|
+
const describeOnce = () => {
|
|
1274
|
+
const trackerCount = Array.isArray(torrent.announce) ? torrent.announce.length : 0;
|
|
1275
|
+
logger.info(
|
|
1276
|
+
`torrent-pool: [${label()}] added: files=${torrent.files?.length ?? 0} ` +
|
|
1277
|
+
`private=${torrent.private ? "yes" : "no"} trackers=${trackerCount}`
|
|
1278
|
+
);
|
|
1279
|
+
this.#attachTrackerDiagnostics(torrent, label);
|
|
1280
|
+
};
|
|
1281
|
+
if (torrent.ready === true) {
|
|
1282
|
+
describeOnce();
|
|
1283
|
+
} else {
|
|
1284
|
+
torrent.once("ready", describeOnce);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* Watch what the trackers answer.
|
|
1290
|
+
*
|
|
1291
|
+
* Separated from the rest because it can only be done once the torrent has
|
|
1292
|
+
* been parsed — see the reasoning at the call site.
|
|
1293
|
+
*
|
|
1294
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
1295
|
+
* @param {() => string} label
|
|
1296
|
+
* @returns {void}
|
|
1297
|
+
*/
|
|
1298
|
+
#attachTrackerDiagnostics(torrent, label) {
|
|
945
1299
|
// bittorrent-tracker's Client emits "update" with each announce response.
|
|
946
1300
|
// `complete`/`incomplete` are the tracker's seeder/leecher counts — the
|
|
947
1301
|
// authoritative answer to "does the tracker accept us and does the swarm
|
|
@@ -953,13 +1307,25 @@ export class TorrentPool {
|
|
|
953
1307
|
// strip the query string before logging.
|
|
954
1308
|
const announceUrl =
|
|
955
1309
|
typeof data?.announce === "string" ? data.announce.replace(/\?.*$/, "") : "?";
|
|
1310
|
+
const seeders = typeof data?.complete === "number" ? data.complete : null;
|
|
1311
|
+
const leechers = typeof data?.incomplete === "number" ? data.incomplete : null;
|
|
1312
|
+
// Kept, not only printed: what the tracker says the swarm holds is one
|
|
1313
|
+
// half of every later question about why nothing is arriving, and a
|
|
1314
|
+
// number that exists only in a log line cannot be put beside the other
|
|
1315
|
+
// half two minutes later.
|
|
1316
|
+
let byTracker = this.#lastAnnounceByTorrent.get(torrent);
|
|
1317
|
+
if (!byTracker) {
|
|
1318
|
+
byTracker = new Map();
|
|
1319
|
+
this.#lastAnnounceByTorrent.set(torrent, byTracker);
|
|
1320
|
+
}
|
|
1321
|
+
byTracker.set(announceUrl, { seeders, leechers, at: Date.now() });
|
|
956
1322
|
logger.info(
|
|
957
|
-
`torrent-pool: [${label}] announce ${announceUrl}: ` +
|
|
958
|
-
`seeders=${
|
|
1323
|
+
`torrent-pool: [${label()}] announce ${announceUrl}: ` +
|
|
1324
|
+
`seeders=${seeders ?? "?"} leechers=${leechers ?? "?"}`
|
|
959
1325
|
);
|
|
960
1326
|
});
|
|
961
1327
|
} else {
|
|
962
|
-
logger.info(`torrent-pool: [${label}] tracker client not exposed; announce results not logged`);
|
|
1328
|
+
logger.info(`torrent-pool: [${label()}] tracker client not exposed; announce results not logged`);
|
|
963
1329
|
}
|
|
964
1330
|
}
|
|
965
1331
|
|
|
@@ -1060,17 +1426,18 @@ export class TorrentPool {
|
|
|
1060
1426
|
this.#lastAccess.delete(existing);
|
|
1061
1427
|
this.#readPositionByTorrent.delete(existing);
|
|
1062
1428
|
this.client.remove(existing, { destroyStore: true }, () => {
|
|
1063
|
-
this.client.add(torrentId, {
|
|
1429
|
+
const addedReplacement = this.client.add(torrentId, {
|
|
1064
1430
|
store: SharedPieceStore,
|
|
1065
1431
|
storeCacheSlots: 0,
|
|
1066
1432
|
storeOpts: { memoryBytes: this.#memoryBytes }
|
|
1067
1433
|
}, (replacement) => {
|
|
1068
1434
|
this.torrents.set(key, replacement);
|
|
1069
1435
|
this.#lastAccess.set(replacement, Date.now());
|
|
1070
|
-
this.#attachSwarmDiagnostics(dupMatch[1].slice(0, 8), replacement);
|
|
1071
1436
|
this.#pending.delete(key);
|
|
1437
|
+
this.#attachSwarmDiagnostics(replacement);
|
|
1072
1438
|
resolve(replacement);
|
|
1073
1439
|
});
|
|
1440
|
+
this.#attachSwarmDiagnostics(addedReplacement);
|
|
1074
1441
|
});
|
|
1075
1442
|
return;
|
|
1076
1443
|
}
|
|
@@ -1091,7 +1458,7 @@ export class TorrentPool {
|
|
|
1091
1458
|
// piece across threads detached memory still in use. Ours owns what it
|
|
1092
1459
|
// hands out, holds pieces in shared memory the main thread can read
|
|
1093
1460
|
// directly, and spills to disk instead of losing them.
|
|
1094
|
-
this.client.add(torrentId, {
|
|
1461
|
+
const added = this.client.add(torrentId, {
|
|
1095
1462
|
store: SharedPieceStore,
|
|
1096
1463
|
storeCacheSlots: 0,
|
|
1097
1464
|
storeOpts: { memoryBytes: this.#memoryBytes }
|
|
@@ -1100,11 +1467,23 @@ export class TorrentPool {
|
|
|
1100
1467
|
this.torrents.set(key, readyTorrent);
|
|
1101
1468
|
this.#lastAccess.set(readyTorrent, Date.now());
|
|
1102
1469
|
this.#pending.delete(key);
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1105
|
-
|
|
1470
|
+
// The torrent this call ends up with is not always the one it added:
|
|
1471
|
+
// on a duplicate infohash WebTorrent destroys the new one and hands
|
|
1472
|
+
// back the one it already had. Watching only what `add` returned would
|
|
1473
|
+
// leave the survivor unwatched. Attaching twice costs nothing — the
|
|
1474
|
+
// guard makes the second call a no-op when it is the same object.
|
|
1475
|
+
this.#attachSwarmDiagnostics(readyTorrent);
|
|
1106
1476
|
resolve(readyTorrent);
|
|
1107
1477
|
});
|
|
1478
|
+
// Attached to what `add` returns, NOT inside its callback. That callback
|
|
1479
|
+
// is `torrent.once("ready")`, and for a magnet everything this watches
|
|
1480
|
+
// has already happened by then: peer discovery starts before the metadata
|
|
1481
|
+
// arrives, so the tracker's answers land before any listener exists, and
|
|
1482
|
+
// the peer that DELIVERED the metadata connected before `ready` fired —
|
|
1483
|
+
// `wire` is emitted at the moment of connection and never replayed. The
|
|
1484
|
+
// torrent that waited minutes for its first peer would have been the one
|
|
1485
|
+
// case this could not measure.
|
|
1486
|
+
this.#attachSwarmDiagnostics(added);
|
|
1108
1487
|
});
|
|
1109
1488
|
|
|
1110
1489
|
this.#pending.set(key, promise);
|
|
@@ -1280,7 +1659,15 @@ export class TorrentPool {
|
|
|
1280
1659
|
* uploadSpeed: number,
|
|
1281
1660
|
* fileProgress: number | null,
|
|
1282
1661
|
* fileDownloaded: number | null,
|
|
1283
|
-
* fileLength: number | null
|
|
1662
|
+
* fileLength: number | null,
|
|
1663
|
+
* connectedPeers: number,
|
|
1664
|
+
* knownPeers: number | null,
|
|
1665
|
+
* queuedPeers: number | null,
|
|
1666
|
+
* trackerSeeders: number | null,
|
|
1667
|
+
* trackerLeechers: number | null,
|
|
1668
|
+
* trackersAnswered: number,
|
|
1669
|
+
* secondsToFirstPeer: number | null,
|
|
1670
|
+
* secondsWaitingForFirstPeer: number | null
|
|
1284
1671
|
* }}
|
|
1285
1672
|
*/
|
|
1286
1673
|
getFileStats(torrent, fileIndex = null, options = {}) {
|
|
@@ -1288,7 +1675,34 @@ export class TorrentPool {
|
|
|
1288
1675
|
const downloadSpeed = typeof torrent?.downloadSpeed === "number" ? torrent.downloadSpeed : 0;
|
|
1289
1676
|
const uploadSpeed = typeof torrent?.uploadSpeed === "number" ? torrent.uploadSpeed : 0;
|
|
1290
1677
|
|
|
1291
|
-
|
|
1678
|
+
// Everything the caller needs to tell "nobody was offered" from "several
|
|
1679
|
+
// were offered and we connected to none". All of it is read HERE, on the
|
|
1680
|
+
// thread that owns the torrent: the handle the routes hold is a proxy
|
|
1681
|
+
// across a worker boundary, where `torrent.wires` simply does not exist —
|
|
1682
|
+
// which is why the line that tried to print it printed a question mark in
|
|
1683
|
+
// every line it has ever written.
|
|
1684
|
+
const reach = describeSwarmReach(torrent);
|
|
1685
|
+
const byTracker = this.#lastAnnounceByTorrent.get(torrent);
|
|
1686
|
+
const announce = bestAnnounce(byTracker ? byTracker.values() : []);
|
|
1687
|
+
const timing = this.#swarmTimingByTorrent.get(torrent) ?? null;
|
|
1688
|
+
|
|
1689
|
+
const base = {
|
|
1690
|
+
numPeers,
|
|
1691
|
+
downloadSpeed,
|
|
1692
|
+
uploadSpeed,
|
|
1693
|
+
connectedPeers: reach.connectedPeers,
|
|
1694
|
+
knownPeers: reach.knownPeers,
|
|
1695
|
+
queuedPeers: reach.queuedPeers,
|
|
1696
|
+
trackerSeeders: announce.seeders,
|
|
1697
|
+
trackerLeechers: announce.leechers,
|
|
1698
|
+
trackersAnswered: announce.trackers,
|
|
1699
|
+
secondsToFirstPeer: timing ? secondsToFirstPeer(timing.addedAt, timing.firstPeerAt) : null,
|
|
1700
|
+
// How long this torrent has been waiting, when it is still waiting. The
|
|
1701
|
+
// figure above answers "how long did it take"; this one answers "how long
|
|
1702
|
+
// has it been", which is the question during the wait itself.
|
|
1703
|
+
secondsWaitingForFirstPeer:
|
|
1704
|
+
timing && timing.firstPeerAt === null ? secondsToFirstPeer(timing.addedAt, Date.now()) : null
|
|
1705
|
+
};
|
|
1292
1706
|
|
|
1293
1707
|
if (fileIndex === null || !Number.isInteger(fileIndex) || !Array.isArray(torrent?.files)) {
|
|
1294
1708
|
return { ...base, fileProgress: null, fileDownloaded: null, fileLength: null };
|
|
@@ -1681,6 +2095,10 @@ export class TorrentPool {
|
|
|
1681
2095
|
clearInterval(this.#uploadAdjustTimer);
|
|
1682
2096
|
this.#uploadAdjustTimer = null;
|
|
1683
2097
|
}
|
|
2098
|
+
if (this.#dhtReportTimer) {
|
|
2099
|
+
clearTimeout(this.#dhtReportTimer);
|
|
2100
|
+
this.#dhtReportTimer = null;
|
|
2101
|
+
}
|
|
1684
2102
|
// Cancel any pending idle-removal timers — destroyAll handles teardown.
|
|
1685
2103
|
for (const timer of this.#idleTimers.values()) {
|
|
1686
2104
|
clearTimeout(timer);
|
|
@@ -35,12 +35,19 @@ import { Command, Event } from "./protocol.js";
|
|
|
35
35
|
// would drag in WebTorrent — and with it the real `webrtc-polyfill` — before
|
|
36
36
|
// the hook above had a chance to register. Verified the hard way: with a static
|
|
37
37
|
// import the process still aborted, and the stack named the genuine polyfill.
|
|
38
|
-
const { TorrentPool } = await import("../torrent-pool.js");
|
|
38
|
+
const { TorrentPool, resolveDhtBootstrap } = await import("../torrent-pool.js");
|
|
39
39
|
const { collectStoreStats, findSharedStore } = await import("../piece-store/shared-piece-store.js");
|
|
40
40
|
|
|
41
|
+
// Resolved before the client exists, because the client builds its DHT in its
|
|
42
|
+
// own constructor and the addresses have to be in hand by then. Awaiting here
|
|
43
|
+
// costs the few milliseconds of a DNS answer, once, on a thread that has not
|
|
44
|
+
// been asked for anything yet.
|
|
45
|
+
const dhtBootstrap = await resolveDhtBootstrap();
|
|
46
|
+
|
|
41
47
|
const pool = new TorrentPool({
|
|
42
48
|
maxDiskBytes: workerData?.maxDiskBytes,
|
|
43
|
-
memoryBytes: workerData?.memoryBytes
|
|
49
|
+
memoryBytes: workerData?.memoryBytes,
|
|
50
|
+
dhtBootstrap
|
|
44
51
|
});
|
|
45
52
|
|
|
46
53
|
/** Torrents by sourceKey — the main thread names them, this thread owns them. */
|