@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
|
@@ -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
|
-
|
|
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
|
+
});
|