@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 +10 -0
- package/package.json +2 -2
- package/services/torrent-pool.js +112 -35
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.
|
|
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": "^
|
|
32
|
+
"webtorrent": "^3.0.16",
|
|
33
33
|
"ws": "^8.18.2"
|
|
34
34
|
}
|
|
35
35
|
}
|
package/services/torrent-pool.js
CHANGED
|
@@ -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:
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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 →
|
|
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,
|
|
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:
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
677
|
-
fileDownloaded
|
|
678
|
-
fileLength
|
|
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
|