@torrent-tv/proxy 2.9.109 → 2.9.110

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,7 @@
1
+ ## 2.9.110
2
+
3
+ - **Fix**: A torrent the pool had destroyed was still being handed to readers, which killed every later session for that source. The torrent thread remembers each source as a promise and only ever forgot one when the ADD failed — but the pool destroys a torrent that has gone unread for a quarter of an hour, and under disk pressure, clearing its own map and knowing nothing about this one. The promise then resolved to a corpse: a destroyed torrent keeps its object and loses its files. Nothing noticed, because by then everything else answers from cache — measured 2026-08-06 on two sessions in a row, the plan came back in 23 ms and the session was created in 2 ms, so no step waited for metadata, and ffmpeg's first read died 130 ms in with `File 0 not found in torrent:…`; every request for the playlist then answered 500 until the viewer gave up. Both sessions were from a phone on a cellular link, which is what made it look like a connectivity problem — ICE had in fact connected in 0.84 s over reflexive addresses and both data channels were open. A handle that cannot be read from is now replaced rather than returned: the source is added again, using the recipe the thread now keeps for exactly this. Covered by tests.
4
+
1
5
  ## 2.9.109
2
6
 
3
7
  - **Chore**: A session now outlives a vanished browser by thirty minutes instead of ten. The number means something different since server 0.8.103: a browser that holds a session re-asserts it every 30 s, so an open tab never consumes this at all — not while paused, not across a three-hour film. What is left is the case where the browser has genuinely gone, and keeping the session means such a viewer returns to a warm encoder rather than a cold start. While nobody is there the encoder is suspended and burns no CPU; the cost is disk for the produced segments, already bounded by the pool's 10 GB cap with eviction. Thirty minutes covers a meal, a phone call or a lift ride.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.109",
3
+ "version": "2.9.110",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @file Whether a torrent handle can still be read from.
3
+ *
4
+ * Its own module so it can be tested: importing the worker starts a torrent
5
+ * client and a piece pool, which a unit test has no business doing.
6
+ */
7
+
8
+ /**
9
+ * Whether a torrent handle can still be read from.
10
+ *
11
+ * `destroyed` is WebTorrent's own flag and the earliest signal. The empty file
12
+ * list is the symptom that actually reaches a reader — it is what produced
13
+ * `File 0 not found` in the field — and it is also true of a handle whose
14
+ * metadata has not arrived yet, in which case adding the source again is
15
+ * equally right: the add de-duplicates and yields the same torrent once it is
16
+ * ready.
17
+ *
18
+ * @param {{ destroyed?: boolean, files?: unknown[] } | null | undefined} torrent
19
+ * @returns {boolean}
20
+ */
21
+ export function isUsableTorrentHandle(torrent) {
22
+ return Boolean(torrent) && torrent.destroyed !== true && (torrent.files?.length ?? 0) > 0;
23
+ }
@@ -21,6 +21,7 @@
21
21
  // before WebTorrent can reach the native one. Two isolates using
22
22
  // node-datachannel at once abort the process, and the torrent's wss trackers
23
23
  // create peer connections of their own.
24
+ import { isUsableTorrentHandle } from "./handle-state.js";
24
25
  import "./install-webrtc-shim.js";
25
26
  import { parentPort, workerData } from "node:worker_threads";
26
27
  import { createSendStream } from "./channel.js";
@@ -43,6 +44,15 @@ const pool = new TorrentPool({
43
44
 
44
45
  /** Torrents by sourceKey — the main thread names them, this thread owns them. */
45
46
  const torrentsByKey = new Map();
47
+
48
+ /**
49
+ * How each source was named when it was added, so a torrent that has since been
50
+ * destroyed can be added again. Kept separately from {@link torrentsByKey}
51
+ * because that map holds the promise, not the recipe.
52
+ *
53
+ * @type {Map<string, { sourceType: string, source: string }>}
54
+ */
55
+ const sourceRecipes = new Map();
46
56
  /** File claims, each with its own identity — see `file-claims.js`. */
47
57
  const fileClaims = createFileClaims();
48
58
  /** In-flight reads, so a cancel can stop one mid-body. */
@@ -82,9 +92,36 @@ async function requireTorrent(sourceKey) {
82
92
  if (!pending) {
83
93
  throw new Error(`Unknown source ${sourceKey}.`);
84
94
  }
85
- return pending;
95
+ const torrent = await pending;
96
+ if (isUsableTorrentHandle(torrent)) {
97
+ return torrent;
98
+ }
99
+ // The pool destroys a torrent that has gone unread for a quarter of an hour,
100
+ // and under disk pressure. It clears its OWN map when it does; this one it
101
+ // knows nothing about, so the promise here went on resolving to a corpse: a
102
+ // destroyed torrent keeps its object but loses its files. Every later session
103
+ // for that source then failed the same way — the plan and the codec probe
104
+ // answered from cache in milliseconds, nothing waited for metadata because
105
+ // everything believed the torrent was known, and ffmpeg's first read died on
106
+ // `File N not found` 130 ms in, after which the session answered 500 for
107
+ // ever. Measured 2026-08-06 on two sessions in a row, both from a phone,
108
+ // which is what made it look like a mobile problem.
109
+ const recipe = sourceRecipes.get(sourceKey);
110
+ if (!recipe) {
111
+ torrentsByKey.delete(sourceKey);
112
+ throw new Error(`Source ${sourceKey} is gone and cannot be re-added.`);
113
+ }
114
+ const revived = pool.getTorrent(recipe.sourceType, recipe.source);
115
+ torrentsByKey.set(sourceKey, revived);
116
+ revived.catch(() => {
117
+ if (torrentsByKey.get(sourceKey) === revived) {
118
+ torrentsByKey.delete(sourceKey);
119
+ }
120
+ });
121
+ return revived;
86
122
  }
87
123
 
124
+
88
125
  /**
89
126
  * Fragments waiting for the main thread to say it has finished reading them,
90
127
  * keyed by request id. One per read, because only one fragment is in flight.
@@ -237,6 +274,10 @@ async function runCommand(command, params, id) {
237
274
  // is being added waits for it instead of being told it does not exist.
238
275
  // Reusing the same promise for a repeated add also collapses two callers
239
276
  // racing to open the same torrent into one.
277
+ sourceRecipes.set(params.sourceKey, {
278
+ sourceType: params.sourceType,
279
+ source: params.source
280
+ });
240
281
  let pending = torrentsByKey.get(params.sourceKey);
241
282
  if (!pending) {
242
283
  pending = pool.getTorrent(params.sourceType, params.source);
@@ -344,6 +385,7 @@ async function runCommand(command, params, id) {
344
385
  case Command.DESTROY_ALL: {
345
386
  fileClaims.closeAll();
346
387
  torrentsByKey.clear();
388
+ sourceRecipes.clear();
347
389
  await pool.destroyAll();
348
390
  return true;
349
391
  }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @file A torrent that was destroyed must not be handed to a reader.
3
+ *
4
+ * The worker remembers each source as a promise, and only ever forgot one when
5
+ * the ADD failed. But the pool destroys a torrent that has gone unread for a
6
+ * quarter of an hour, and under disk pressure — clearing its own map, not this
7
+ * one. The promise then went on resolving to a corpse: a destroyed torrent
8
+ * keeps its object and loses its files.
9
+ *
10
+ * What that did to a viewer, measured 2026-08-06 on two sessions in a row: the
11
+ * plan answered from cache in 23 ms, the session was created from cache in
12
+ * 2 ms, nothing waited for metadata because everything believed the torrent was
13
+ * known, and ffmpeg's first read died 130 ms in with `File 0 not found`. Every
14
+ * request for the playlist then answered 500 until the viewer gave up.
15
+ *
16
+ * The rule under test is the whole fix: a handle that cannot be read from is
17
+ * not returned, it is replaced.
18
+ */
19
+
20
+ import test from "node:test";
21
+ import assert from "node:assert/strict";
22
+ import { isUsableTorrentHandle } from "../services/torrent-worker/handle-state.js";
23
+
24
+ test("a live torrent is usable", () => {
25
+ assert.equal(isUsableTorrentHandle({ destroyed: false, files: [{ name: "a.mkv" }] }), true);
26
+ });
27
+
28
+ test("a destroyed torrent is not, even while it still lists files", () => {
29
+ assert.equal(
30
+ isUsableTorrentHandle({ destroyed: true, files: [{ name: "a.mkv" }] }),
31
+ false,
32
+ "WebTorrent's own flag is the earliest signal that a handle is finished"
33
+ );
34
+ });
35
+
36
+ test("a torrent with no files is not — that is what the reader actually hits", () => {
37
+ assert.equal(
38
+ isUsableTorrentHandle({ destroyed: false, files: [] }),
39
+ false,
40
+ "an empty file list is what produced `File 0 not found` in the field"
41
+ );
42
+ assert.equal(
43
+ isUsableTorrentHandle({ destroyed: false }),
44
+ false,
45
+ "and a handle with no file list at all is the same case"
46
+ );
47
+ });
48
+
49
+ test("nothing at all is not usable", () => {
50
+ assert.equal(isUsableTorrentHandle(null), false);
51
+ assert.equal(isUsableTorrentHandle(undefined), false);
52
+ });