@torrent-tv/proxy 2.9.41 → 2.9.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 2.9.43
2
+
3
+ - **Fix**: Torrents stalled at the metadata/download stage on WebTorrent 3.x — the adaptive upload throttle dropped the client-wide limit to `0` whenever no file had an active reader (e.g. the window before the first read is acquired). In WebTorrent 3.x `throttleUpload(0)` blocks the ENTIRE swarm exchange client-wide — even peer connections and DOWNLOAD — not just seeding (verified: `throttleUpload(0)` → 0 peers, 0 download; `throttleUpload(8KB/s)` → peers connect, multi-MB/s download). The idle branch now returns a minimal keep-alive floor (`UPLOAD_IDLE_FLOOR_BYTES` = 8 KB/s) instead of 0; still effectively no seeding, but the swarm stays alive.
4
+ - **Fix**: Spurious `uncaughtException: Cannot read properties of null (reading 'length')` every few seconds during playback. WebTorrent 3.x nulls `pieces[index]` for pieces we removed from the download set via `deselect` (file selection, seek-behind-playhead demotion), and its own `torrent.downloaded` / `file.downloaded` / `file.progress` getters do not guard that null — they threw in our disk-cap sweep and stats builder. Added null-safe `torrentDownloadedBytes` / `fileDownloadedBytes` helpers (a deselected piece = 0 downloaded, the correct value, while still counting every other piece) and use them in `#currentDiskBytes`/`#enforceDiskCap` and `getFileStats`. Verified byte-for-byte identical to WebTorrent's own getters when no piece is null. (The underlying WebTorrent getter bug is filed upstream; it is non-fatal — download survives it — but the throws were noisy and risky.)
5
+
6
+ ## 2.9.42
7
+
8
+ - **Fix**: Torrents failed to load with a proxy crash — the REAL root cause (2.9.41 misdiagnosed it). WebTorrent 2.8.5's `Torrent._onTorrentId` does `arr2hex(parsedTorrent.infoHash)`, but `parse-torrent` returns `infoHash` as a hex **string**. `uint8-util` **2.3.x** rewrote `arr2hex` to require a TypedArray (`Buffer.from(data.buffer …)`); a string's `.buffer` is `undefined` → `Buffer.from(undefined)` → `ERR_INVALID_ARG_TYPE` thrown in a detached microtask. `uint8-util` 2.2.x iterated the argument and tolerated a string, so it only broke once the addon's unpinned global `npm install` pulled 2.3.x. It hit **every** torrent (v1/v2/hybrid alike — `arr2hex` is always called). Diagnosed by reproducing `client.add` inside the addon container and isolating `arr2hex('<hex>')` throwing on 2.3.2 but not 2.2.6. Fix: update **WebTorrent 2.8.5 → 3.x**, where the maintainer replaced that line with `parsedTorrent.infoHash?.substring(0, 7)` (no `arr2hex` on the string) — a proper dependency-forward fix, not a version pin, so `uint8-util`/`parse-torrent` stay current. Verified: the exact broken combo (webtorrent 3.0.16 + uint8-util 2.3.2 + parse-torrent 11.0.23) now adds cleanly, and the full API the proxy uses (`select`/`deselect`/`critical`/`_critical`/`wires`/`throttleUpload`/`createReadStream`/`destroy({destroyStore})`) is unchanged in 3.x.
9
+ - **Fix**: Removed the 2.9.41 infohash pre-validation. It was based on the wrong diagnosis ("v2-only torrent") — the failing torrents were normal v1 — and it wrongly rejected legitimate v2/hybrid sources. WebTorrent (post-bump) handles v1, v2 and hybrid itself. The last-resort `uncaughtException`/`unhandledRejection` guard from 2.9.41 is kept as defense-in-depth.
10
+
1
11
  ## 2.9.41
2
12
 
3
13
  - **Fix**: A malformed or v2-only torrent source no longer crashes the whole proxy in a restart loop. WebTorrent's `Torrent._onTorrentId` does `arr2hex(parsedTorrent.infoHash)` assuming a BitTorrent v1 infohash exists; a v2-only / hybrid magnet (or a corrupt source) parses with `infoHash === undefined`, so that becomes `Buffer.from(undefined)` and throws in a microtask that bypasses the client `error` event — taking down the node and every viewer on it (observed: `ERR_INVALID_ARG_TYPE` → tunnel reconnect loop; the WebRTC session died ~6 s in as the process restarted under it). Two fixes: the torrent-add path now **pre-validates the infohash** with `parse-torrent` and rejects a source without a valid v1 40-hex infohash as a clean error the browser can show; and the process gained a **last-resort `uncaughtException`/`unhandledRejection` guard** that logs the full stack and keeps serving, so no single bad torrent can ever crash-loop the proxy. NOT a regression from the download-performance work (2.9.40) — those paths don't touch torrent parsing; it is a pre-existing crash surfaced by an unusual source.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.41",
3
+ "version": "2.9.43",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -29,7 +29,7 @@
29
29
  "franc": "^6.2.0",
30
30
  "get-port": "^7.1.0",
31
31
  "node-datachannel": "^0.32.0",
32
- "webtorrent": "^2.8.4",
32
+ "webtorrent": "^3.0.16",
33
33
  "ws": "^8.18.2"
34
34
  }
35
35
  }
@@ -11,7 +11,6 @@ import os from "node:os";
11
11
  import path from "node:path";
12
12
  import { rmSync, statfsSync } from "node:fs";
13
13
  import WebTorrent from "webtorrent";
14
- import parseTorrent from "parse-torrent";
15
14
  import { logger } from "../utils/logger.js";
16
15
 
17
16
  // WebTorrent's default download root (see webtorrent lib/torrent.js: TMP =
@@ -53,13 +52,19 @@ const DISK_CAP_SWEEP_INTERVAL_MS = 30_000;
53
52
 
54
53
  // Adaptive upload. Seeding to the BitTorrent swarm does not help our viewer (we
55
54
  // deliver over our own WebRTC/HTTPS channel) — it is pure uplink cost and the
56
- // riskiest legal act (active distribution). So the default is minimal: no
57
- // seeding when nothing is being watched, and only a token upload while actively
58
- // downloading. BUT zero upload can get us choked by tit-for-tat (peers re-rank
55
+ // riskiest legal act (active distribution). So the default is minimal: a
56
+ // near-zero keep-alive when nothing is being watched (NOT 0 that blocks the
57
+ // swarm in wt3.x), and only a token upload while actively downloading. BUT zero
58
+ // upload can get us choked by tit-for-tat (peers re-rank
59
59
  // and stop sending) → slower download → the exact starvation we fight. So the
60
60
  // limit is ADAPTIVE: raised only when download is starving AND the wires show
61
61
  // reciprocity is the cause (many peers we want data from are choking us).
62
62
  const UPLOAD_FLOOR_BYTES = 50 * 1024; // token upload while a reader is active
63
+ // Minimal keep-alive upload when NOTHING is being watched. It must NOT be 0:
64
+ // in webtorrent 3.x `throttleUpload(0)` blocks ALL swarm exchange client-wide
65
+ // (even peer connections and DOWNLOAD), so a torrent still fetching metadata /
66
+ // pieces would stall. A few KB/s is negligible seeding but keeps the swarm alive.
67
+ const UPLOAD_IDLE_FLOOR_BYTES = 8 * 1024;
63
68
  const UPLOAD_BOOST_BYTES = 512 * 1024; // raised to earn tit-for-tat unchoke slots
64
69
  const UPLOAD_STARVING_SPEED_BYTES = 200 * 1024; // download below this (with demand) = starving
65
70
  const UPLOAD_CHOKED_WIRE_THRESHOLD = 2; // interested-but-choked wires implying reciprocity
@@ -70,24 +75,27 @@ const UPLOAD_ADJUST_INTERVAL_MS = 5_000;
70
75
  * currently have an active reader. Pure function so the policy is unit-testable
71
76
  * without a live swarm.
72
77
  *
73
- * - No active readers → 0 (stop seeding entirely; nothing is being watched).
78
+ * - No active readers → a MINIMAL keep-alive floor (NOT 0: `throttleUpload(0)`
79
+ * blocks the whole swarm client-wide in webtorrent 3.x, stalling any torrent
80
+ * still downloading). Effectively no seeding, just enough to keep peers.
74
81
  * - Any active torrent starving (still wants data, download barely trickling)
75
82
  * AND showing reciprocity choke (>= threshold wires we are interested in that
76
83
  * are choking us) → boost, to earn unchoke slots.
77
84
  * - Otherwise → floor (token upload, avoids an immediate choke without seeding).
78
85
  *
79
- * @param {Array<{ wires?: Array<{ amInterested?: boolean, peerChoking?: boolean }>, downloadSpeed?: number, progress?: number, name?: string }>} activeTorrents
80
- * @param {{ floor?: number, boost?: number, starvingSpeed?: number, chokedThreshold?: number }} [opts]
86
+ * @param {Array<{ wires?: Array<{ amInterested?: boolean, peerChoking?: boolean }>, downloadSpeed?: number, done?: boolean, name?: string }>} activeTorrents
87
+ * @param {{ floor?: number, idleFloor?: number, boost?: number, starvingSpeed?: number, chokedThreshold?: number }} [opts]
81
88
  * @returns {{ bytesPerSec: number, reason: string }}
82
89
  */
83
90
  export function decideUploadLimit(activeTorrents, opts = {}) {
84
91
  const floor = opts.floor ?? UPLOAD_FLOOR_BYTES;
92
+ const idleFloor = opts.idleFloor ?? UPLOAD_IDLE_FLOOR_BYTES;
85
93
  const boost = opts.boost ?? UPLOAD_BOOST_BYTES;
86
94
  const starvingSpeed = opts.starvingSpeed ?? UPLOAD_STARVING_SPEED_BYTES;
87
95
  const chokedThreshold = opts.chokedThreshold ?? UPLOAD_CHOKED_WIRE_THRESHOLD;
88
96
 
89
97
  if (!Array.isArray(activeTorrents) || activeTorrents.length === 0) {
90
- return { bytesPerSec: 0, reason: "idle: no active readers stop seeding" };
98
+ return { bytesPerSec: idleFloor, reason: "idle: minimal keep-alive (0 blocks the swarm in wt3.x)" };
91
99
  }
92
100
 
93
101
  for (const torrent of activeTorrents) {
@@ -96,8 +104,11 @@ export function decideUploadLimit(activeTorrents, opts = {}) {
96
104
  (wire) => wire && wire.amInterested === true && wire.peerChoking === true
97
105
  ).length;
98
106
  const downloadSpeed = typeof torrent?.downloadSpeed === "number" ? torrent.downloadSpeed : 0;
99
- const progress = typeof torrent?.progress === "number" ? torrent.progress : 0;
100
- const starving = progress < 1 && downloadSpeed < starvingSpeed;
107
+ // Use `done` (a plain boolean) NOT `progress`/`downloaded`, whose getters
108
+ // iterate the piece array and throw on webtorrent 3.x when a piece is null
109
+ // (deselected / mid-verify), which would crash this timer every cycle.
110
+ const notDone = torrent?.done !== true;
111
+ const starving = notDone && downloadSpeed < starvingSpeed;
101
112
  if (starving && chokedInterested >= chokedThreshold) {
102
113
  const name = typeof torrent?.name === "string" ? torrent.name : "?";
103
114
  return {
@@ -169,6 +180,87 @@ function decodeTorrentSource(sourceType, source) {
169
180
  throw new Error("Unsupported sourceType. Expected magnet or torrent.");
170
181
  }
171
182
 
183
+ /**
184
+ * Downloaded bytes of a single piece, treating a null (deselected) piece as 0.
185
+ *
186
+ * webtorrent 3.x sets `pieces[index] = null` for pieces we removed from the
187
+ * download set via `deselect` (file selection, seek-behind-playhead demotion).
188
+ * Its own `get downloaded` / `get progress` do NOT guard that null and throw
189
+ * `Cannot read properties of null (reading 'length')`, which crashed our
190
+ * disk-cap and stats timers every cycle. A deselected piece has 0 downloaded
191
+ * bytes, so treating null as 0 is the correct value — and keeps the OTHER
192
+ * pieces counted (a blanket try/catch that returned 0 for the whole torrent
193
+ * would under-report disk usage and freeze the progress bar at 0%).
194
+ *
195
+ * @param {import("webtorrent").Torrent} torrent
196
+ * @param {number} index
197
+ * @returns {number}
198
+ */
199
+ function pieceDownloadedBytes(torrent, index) {
200
+ const len = index === torrent.pieces.length - 1 ? torrent.lastPieceLength : torrent.pieceLength;
201
+ if (torrent.bitfield.get(index)) {
202
+ return len; // verified
203
+ }
204
+ const piece = torrent.pieces[index];
205
+ return piece ? len - piece.missing : 0; // in-progress, or null (deselected) → 0
206
+ }
207
+
208
+ /**
209
+ * Total downloaded bytes across ALL pieces, null-safe (mirrors webtorrent's
210
+ * `torrent.downloaded` but never throws on a deselected null piece).
211
+ *
212
+ * @param {import("webtorrent").Torrent} torrent
213
+ * @returns {number}
214
+ */
215
+ export function torrentDownloadedBytes(torrent) {
216
+ if (!torrent?.bitfield || !Array.isArray(torrent.pieces)) {
217
+ return 0;
218
+ }
219
+ let downloaded = 0;
220
+ for (let index = 0; index < torrent.pieces.length; index += 1) {
221
+ downloaded += pieceDownloadedBytes(torrent, index);
222
+ }
223
+ return downloaded;
224
+ }
225
+
226
+ /**
227
+ * Downloaded bytes of a single file, null-safe (mirrors webtorrent's
228
+ * `file.downloaded`, including the first/last-piece offset trims, but never
229
+ * throws on a deselected null piece).
230
+ *
231
+ * @param {import("webtorrent").Torrent} torrent
232
+ * @param {import("webtorrent").TorrentFile & { _startPiece?: number, _endPiece?: number, offset?: number, length?: number }} file
233
+ * @returns {number}
234
+ */
235
+ export function fileDownloadedBytes(torrent, file) {
236
+ if (!torrent?.bitfield || !Array.isArray(torrent.pieces) || !file) {
237
+ return 0;
238
+ }
239
+ const start = file._startPiece;
240
+ const end = file._endPiece;
241
+ const pieceLength = torrent.pieceLength;
242
+ if (!Number.isInteger(start) || !Number.isInteger(end) || !(pieceLength > 0)) {
243
+ return 0;
244
+ }
245
+ let downloaded = 0;
246
+ for (let index = start; index <= end; index += 1) {
247
+ const pieceDownloaded = pieceDownloadedBytes(torrent, index);
248
+ downloaded += pieceDownloaded;
249
+ if (index === start) {
250
+ // First piece may carry irrelevant bytes from the previous file.
251
+ const irrelevant = file.offset % pieceLength;
252
+ downloaded -= Math.min(irrelevant, pieceDownloaded);
253
+ }
254
+ if (index === end) {
255
+ // Last piece may carry irrelevant bytes from the next file.
256
+ const lastLen = index === torrent.pieces.length - 1 ? torrent.lastPieceLength : pieceLength;
257
+ const irrelevant = lastLen - ((file.offset + file.length) % pieceLength);
258
+ downloaded -= Math.min(irrelevant, pieceDownloaded);
259
+ }
260
+ }
261
+ return downloaded;
262
+ }
263
+
172
264
  /**
173
265
  * Shared WebTorrent pool.
174
266
  *
@@ -313,8 +405,7 @@ export class TorrentPool {
313
405
  #currentDiskBytes() {
314
406
  let total = 0;
315
407
  for (const torrent of this.torrents.values()) {
316
- const downloaded = typeof torrent?.downloaded === "number" ? torrent.downloaded : 0;
317
- total += Math.max(0, downloaded);
408
+ total += Math.max(0, torrentDownloadedBytes(torrent));
318
409
  }
319
410
  return total;
320
411
  }
@@ -346,7 +437,7 @@ export class TorrentPool {
346
437
  if (used <= this.#maxDiskBytes) {
347
438
  break;
348
439
  }
349
- const freed = typeof torrent?.downloaded === "number" ? Math.max(0, torrent.downloaded) : 0;
440
+ const freed = Math.max(0, torrentDownloadedBytes(torrent));
350
441
  const name = typeof torrent?.name === "string" ? torrent.name : "(unknown)";
351
442
  const gb = (this.#maxDiskBytes / (1024 * 1024 * 1024)).toFixed(1);
352
443
  logger.info(
@@ -428,25 +519,6 @@ export class TorrentPool {
428
519
 
429
520
  const torrentId = decodeTorrentSource(sourceType, source);
430
521
 
431
- // Pre-validate the infohash BEFORE handing the source to WebTorrent.
432
- // WebTorrent's Torrent._onTorrentId does `arr2hex(parsedTorrent.infoHash)`
433
- // assuming a BitTorrent v1 infohash exists; a v2-only / hybrid magnet (or a
434
- // malformed source) parses with `infoHash === undefined`, so that call does
435
- // `Buffer.from(undefined)` and throws in a microtask that bypasses the
436
- // client "error" event — crashing the whole process. Reject cleanly here so
437
- // the browser gets an error it can show, and the proxy stays up.
438
- let parsed;
439
- try {
440
- parsed = await parseTorrent(torrentId);
441
- } catch {
442
- parsed = null;
443
- }
444
- if (!parsed || typeof parsed.infoHash !== "string" || !/^[0-9a-f]{40}$/i.test(parsed.infoHash)) {
445
- throw new Error(
446
- "Unsupported torrent source: no BitTorrent v1 infohash (v2-only or malformed torrents are not supported)."
447
- );
448
- }
449
-
450
522
  const promise = new Promise((resolve, reject) => {
451
523
  const onError = (error) => {
452
524
  this.client.off("error", onError);
@@ -671,11 +743,16 @@ export class TorrentPool {
671
743
 
672
744
  const header = this.#getHeaderRangeProgress(torrent, file);
673
745
 
746
+ // Null-safe downloaded/progress (webtorrent's own getters throw on a
747
+ // deselected null piece — see fileDownloadedBytes).
748
+ const fileLength = typeof file.length === "number" ? file.length : 0;
749
+ const fileDownloaded = fileDownloadedBytes(torrent, file);
750
+
674
751
  return {
675
752
  ...base,
676
- fileProgress: typeof file.progress === "number" ? file.progress : 0,
677
- fileDownloaded: typeof file.downloaded === "number" ? file.downloaded : 0,
678
- fileLength: typeof file.length === "number" ? file.length : 0,
753
+ fileProgress: fileLength > 0 ? Math.max(0, Math.min(1, fileDownloaded / fileLength)) : 0,
754
+ fileDownloaded,
755
+ fileLength,
679
756
  // Phase-1 progress: how much of the header/index region (the bytes the
680
757
  // codec probe needs before transcoding can start) is downloaded. Counted
681
758
  // by whole pieces from the torrent bitfield, so it advances coarsely