@torrent-tv/proxy 2.9.108 → 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,11 @@
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
+
5
+ ## 2.9.109
6
+
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.
8
+
1
9
  ## 2.9.108
2
10
 
3
11
  - **New**: A send queue that stops draining now says WHY, instead of leaving the cause to be guessed at. Field 2026-08-06: a channel stayed open, kept accepting requests and delivered nothing for eleven minutes — the queue grew from 214 049 to 239 731 bytes in fourteen seconds and never fell, while the route reported answering in 15 ms and the channel reported itself open. `bufferedAmount` alone cannot distinguish the possible causes; it only proves the bytes are still ours. Each channel is now sampled every second, and once its queue has failed to fall for five seconds the transport itself is asked: bytes sent, bytes received, round-trip time, connection and ICE state, and the candidate pair in use — then again every second, so the trend of each counter is in the log rather than one snapshot. The reading is decided in advance and written beside the code: bytes-sent rising with the queue means packets leave and nothing acknowledges them (the return path is broken); bytes-sent flat with the queue rising means SCTP is not transmitting at all (the peer's receive window is shut, or congestion control has collapsed); bytes-received still rising in either case proves the peer is alive and the failure is one-directional.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.108",
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": {
@@ -137,7 +137,17 @@ const MAX_SEEK_FAILURES = 3;
137
137
  // look-ahead cap when idle, so a lingering session costs retained segments on
138
138
  // disk, not sustained CPU. Active playback refreshes the timer on every segment
139
139
  // fetch, so it never expires mid-watch.
140
- const DEFAULT_SESSION_TTL_MS = 10 * 60 * 1000;
140
+ // How long a session outlives the BROWSER, not the viewing. Since server
141
+ // 0.8.103 a browser that holds a session re-asserts it every 30 s, so an open
142
+ // tab never consumes this at all — not while paused, not across a three-hour
143
+ // film. What is left is the case where the browser has genuinely gone: the tab
144
+ // was killed without releasing, the phone slept, the network dropped. Keeping
145
+ // the session means such a viewer comes back to a warm encoder instead of
146
+ // waiting out a cold start; the cost while nobody is there is disk for the
147
+ // produced segments, since the encoder is suspended and burns no CPU, and that
148
+ // disk is already bounded by the pool's 10 GB cap with eviction. Thirty minutes
149
+ // covers a meal, a phone call or a lift ride.
150
+ const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
141
151
  const DEFAULT_STARTUP_WAIT_MS = 5_000;
142
152
  // Realtime budget — runtime downswitch (software encoder only). Periodically
143
153
  // check each active software-transcode session's ffmpeg `speed`; when it stays
@@ -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
+ });