@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.
@@ -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
- `torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] uploading generously for ` +
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(label, torrent) {
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
- const trackerCount = Array.isArray(torrent.announce) ? torrent.announce.length : 0;
936
- logger.info(
937
- `torrent-pool: [${label}] added: files=${torrent.files?.length ?? 0} ` +
938
- `private=${torrent.private ? "yes" : "no"} trackers=${trackerCount}`
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=${data?.complete ?? "?"} leechers=${data?.incomplete ?? "?"}`
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
- // Key layout is `${sourceType}:${sha1}`; log with the sha1 prefix so
1104
- // lines correlate with the [stats] source key.
1105
- this.#attachSwarmDiagnostics(key.split(":")[1]?.slice(0, 8) ?? key, readyTorrent);
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
- const base = { numPeers, downloadSpeed, uploadSpeed };
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. */