@torrent-tv/proxy 2.9.75 → 2.9.77
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 +12 -0
- package/package.json +1 -1
- package/routes/stream/get.js +141 -116
- package/services/piece-store/shared-piece-store.js +416 -416
- package/services/torrent-worker/channel.js +12 -0
- package/services/torrent-worker/client.js +271 -264
- package/services/torrent-worker/file-claims.js +91 -0
- package/services/torrent-worker/pool-adapter.js +188 -179
- package/services/torrent-worker/worker.js +72 -35
- package/test/file-claims.test.js +64 -0
- package/test/stream-route.test.js +121 -0
- package/test/worker-channel.test.js +56 -1
- package/test/worker-source-race.test.js +76 -0
|
@@ -1,179 +1,188 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file `TorrentPool`'s interface, served from the worker thread.
|
|
3
|
-
*
|
|
4
|
-
* The routes, the planner, the health report and the session manager all reach
|
|
5
|
-
* for a torrent pool and use it the same handful of ways. Rather than rewrite
|
|
6
|
-
* every one of them to thread a `sourceKey` through and await what used to be
|
|
7
|
-
* immediate, this presents the shape they already expect and does the thread
|
|
8
|
-
* hop behind it. Swapping the implementation is then a one-line change at
|
|
9
|
-
* construction, and the call sites are untouched — which is what keeps a change
|
|
10
|
-
* of this size reviewable.
|
|
11
|
-
*
|
|
12
|
-
* Two accommodations are needed, and both are deliberate:
|
|
13
|
-
*
|
|
14
|
-
* - **`acquireFile` and `prioritizeByteRange` stay synchronous.** They return
|
|
15
|
-
* nothing the caller inspects, so the command is dispatched and not awaited.
|
|
16
|
-
* `acquireFile` hands back a release function exactly as before, which sends
|
|
17
|
-
* its own command when called. Awaiting them would mean touching every call
|
|
18
|
-
* site for no observable gain.
|
|
19
|
-
* - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
|
|
20
|
-
* thread, so the worker keys them. Callers that have one pass it; the rest
|
|
21
|
-
* get one derived from the source itself, so the identity stays stable
|
|
22
|
-
* across calls for the same torrent.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import crypto from "node:crypto";
|
|
26
|
-
import { TorrentWorkerClient } from "./client.js";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Stable key for a source, matching how the worker keys its torrents.
|
|
30
|
-
*
|
|
31
|
-
* Derived from the source itself rather than handed out per request, so two
|
|
32
|
-
* routes asking for the same torrent name the same thing on the worker side.
|
|
33
|
-
*
|
|
34
|
-
* @param {"magnet" | "torrent"} sourceType
|
|
35
|
-
* @param {string} source
|
|
36
|
-
* @returns {string}
|
|
37
|
-
*/
|
|
38
|
-
function deriveSourceKey(sourceType, source) {
|
|
39
|
-
return `${sourceType}:${crypto.createHash("sha1").update(source).digest("hex")}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A torrent pool whose work happens on another thread.
|
|
44
|
-
*
|
|
45
|
-
* See `protocol.js` for why: the torrent was taking ~85% of the main thread and
|
|
46
|
-
* everything owed to a viewer queued behind it.
|
|
47
|
-
*/
|
|
48
|
-
export class WorkerTorrentPool {
|
|
49
|
-
#client;
|
|
50
|
-
/** Stand-ins by source key, so repeat calls return the same object. */
|
|
51
|
-
#torrents = new Map();
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
|
|
55
|
-
*/
|
|
56
|
-
constructor(options = {}) {
|
|
57
|
-
this.#client = new TorrentWorkerClient(options);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Load (or join) a torrent and return a stand-in for it.
|
|
62
|
-
*
|
|
63
|
-
* @param {"magnet" | "torrent"} sourceType
|
|
64
|
-
* @param {string} source
|
|
65
|
-
* @returns {Promise<object>}
|
|
66
|
-
*/
|
|
67
|
-
async getTorrent(sourceType, source) {
|
|
68
|
-
const sourceKey = deriveSourceKey(sourceType, source);
|
|
69
|
-
const existing = this.#torrents.get(sourceKey);
|
|
70
|
-
if (existing) {
|
|
71
|
-
return existing;
|
|
72
|
-
}
|
|
73
|
-
const torrent = await this.#client.getTorrent({ sourceKey, sourceType, source });
|
|
74
|
-
this.#torrents.set(sourceKey, torrent);
|
|
75
|
-
return torrent;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Claim a file for reading; the returned function releases it.
|
|
80
|
-
*
|
|
81
|
-
* Synchronous by design — see the file header.
|
|
82
|
-
*
|
|
83
|
-
* @param {object} torrent - A stand-in from {@link getTorrent}.
|
|
84
|
-
* @param {number} fileIndex
|
|
85
|
-
* @returns {() => void}
|
|
86
|
-
*/
|
|
87
|
-
acquireFile(torrent, fileIndex) {
|
|
88
|
-
const sourceKey = torrent?.sourceKey;
|
|
89
|
-
if (!sourceKey) {
|
|
90
|
-
return () => undefined;
|
|
91
|
-
}
|
|
92
|
-
// Dispatched, not awaited — callers use the result immediately and inspect
|
|
93
|
-
// nothing. But the release MUST NOT overtake it: both are ordinary messages
|
|
94
|
-
// to the worker, and if release arrives first the reader count drops to zero
|
|
95
|
-
// while a read is still running. The idle sweep then removes the torrent AND
|
|
96
|
-
// its downloaded data out from under the encoder — field 2026-08-02:
|
|
97
|
-
// "removed idle torrent ... and its store" mid-playback, after which every
|
|
98
|
-
// read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
|
|
99
|
-
// Chaining the release onto the acquire keeps them in order.
|
|
100
|
-
const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() =>
|
|
101
|
-
let released = false;
|
|
102
|
-
return () => {
|
|
103
|
-
if (released) {
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
released = true;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* @
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
this.#
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file `TorrentPool`'s interface, served from the worker thread.
|
|
3
|
+
*
|
|
4
|
+
* The routes, the planner, the health report and the session manager all reach
|
|
5
|
+
* for a torrent pool and use it the same handful of ways. Rather than rewrite
|
|
6
|
+
* every one of them to thread a `sourceKey` through and await what used to be
|
|
7
|
+
* immediate, this presents the shape they already expect and does the thread
|
|
8
|
+
* hop behind it. Swapping the implementation is then a one-line change at
|
|
9
|
+
* construction, and the call sites are untouched — which is what keeps a change
|
|
10
|
+
* of this size reviewable.
|
|
11
|
+
*
|
|
12
|
+
* Two accommodations are needed, and both are deliberate:
|
|
13
|
+
*
|
|
14
|
+
* - **`acquireFile` and `prioritizeByteRange` stay synchronous.** They return
|
|
15
|
+
* nothing the caller inspects, so the command is dispatched and not awaited.
|
|
16
|
+
* `acquireFile` hands back a release function exactly as before, which sends
|
|
17
|
+
* its own command when called. Awaiting them would mean touching every call
|
|
18
|
+
* site for no observable gain.
|
|
19
|
+
* - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
|
|
20
|
+
* thread, so the worker keys them. Callers that have one pass it; the rest
|
|
21
|
+
* get one derived from the source itself, so the identity stays stable
|
|
22
|
+
* across calls for the same torrent.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import crypto from "node:crypto";
|
|
26
|
+
import { TorrentWorkerClient } from "./client.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Stable key for a source, matching how the worker keys its torrents.
|
|
30
|
+
*
|
|
31
|
+
* Derived from the source itself rather than handed out per request, so two
|
|
32
|
+
* routes asking for the same torrent name the same thing on the worker side.
|
|
33
|
+
*
|
|
34
|
+
* @param {"magnet" | "torrent"} sourceType
|
|
35
|
+
* @param {string} source
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function deriveSourceKey(sourceType, source) {
|
|
39
|
+
return `${sourceType}:${crypto.createHash("sha1").update(source).digest("hex")}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A torrent pool whose work happens on another thread.
|
|
44
|
+
*
|
|
45
|
+
* See `protocol.js` for why: the torrent was taking ~85% of the main thread and
|
|
46
|
+
* everything owed to a viewer queued behind it.
|
|
47
|
+
*/
|
|
48
|
+
export class WorkerTorrentPool {
|
|
49
|
+
#client;
|
|
50
|
+
/** Stand-ins by source key, so repeat calls return the same object. */
|
|
51
|
+
#torrents = new Map();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
|
|
55
|
+
*/
|
|
56
|
+
constructor(options = {}) {
|
|
57
|
+
this.#client = new TorrentWorkerClient(options);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Load (or join) a torrent and return a stand-in for it.
|
|
62
|
+
*
|
|
63
|
+
* @param {"magnet" | "torrent"} sourceType
|
|
64
|
+
* @param {string} source
|
|
65
|
+
* @returns {Promise<object>}
|
|
66
|
+
*/
|
|
67
|
+
async getTorrent(sourceType, source) {
|
|
68
|
+
const sourceKey = deriveSourceKey(sourceType, source);
|
|
69
|
+
const existing = this.#torrents.get(sourceKey);
|
|
70
|
+
if (existing) {
|
|
71
|
+
return existing;
|
|
72
|
+
}
|
|
73
|
+
const torrent = await this.#client.getTorrent({ sourceKey, sourceType, source });
|
|
74
|
+
this.#torrents.set(sourceKey, torrent);
|
|
75
|
+
return torrent;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Claim a file for reading; the returned function releases it.
|
|
80
|
+
*
|
|
81
|
+
* Synchronous by design — see the file header.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} torrent - A stand-in from {@link getTorrent}.
|
|
84
|
+
* @param {number} fileIndex
|
|
85
|
+
* @returns {() => void}
|
|
86
|
+
*/
|
|
87
|
+
acquireFile(torrent, fileIndex) {
|
|
88
|
+
const sourceKey = torrent?.sourceKey;
|
|
89
|
+
if (!sourceKey) {
|
|
90
|
+
return () => undefined;
|
|
91
|
+
}
|
|
92
|
+
// Dispatched, not awaited — callers use the result immediately and inspect
|
|
93
|
+
// nothing. But the release MUST NOT overtake it: both are ordinary messages
|
|
94
|
+
// to the worker, and if release arrives first the reader count drops to zero
|
|
95
|
+
// while a read is still running. The idle sweep then removes the torrent AND
|
|
96
|
+
// its downloaded data out from under the encoder — field 2026-08-02:
|
|
97
|
+
// "removed idle torrent ... and its store" mid-playback, after which every
|
|
98
|
+
// read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
|
|
99
|
+
// Chaining the release onto the acquire keeps them in order.
|
|
100
|
+
const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() => null);
|
|
101
|
+
let released = false;
|
|
102
|
+
return () => {
|
|
103
|
+
if (released) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
released = true;
|
|
107
|
+
// Release the claim this call opened, not "the file" — waiting for the
|
|
108
|
+
// acquire is also what tells us which claim that is.
|
|
109
|
+
void acquired
|
|
110
|
+
.then((claimId) => (claimId ? this.#client.releaseFile(claimId) : undefined))
|
|
111
|
+
.catch(() => undefined);
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Live download figures for the progress display.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} torrent
|
|
119
|
+
* @param {number | null} [fileIndex]
|
|
120
|
+
* @param {{ resumeAnchorByteStart?: number | null }} [options]
|
|
121
|
+
* @returns {Promise<object | null>}
|
|
122
|
+
*/
|
|
123
|
+
async getFileStats(torrent, fileIndex = null, options = {}) {
|
|
124
|
+
const sourceKey = torrent?.sourceKey;
|
|
125
|
+
if (!sourceKey) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
return this.#client.getFileStats({
|
|
129
|
+
sourceKey,
|
|
130
|
+
fileIndex,
|
|
131
|
+
resumeAnchorByteStart: options?.resumeAnchorByteStart ?? null
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Reorder piece selection around a read position.
|
|
137
|
+
*
|
|
138
|
+
* Synchronous by design — see the file header.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} torrent
|
|
141
|
+
* @param {number} fileIndex
|
|
142
|
+
* @param {number} byteStart
|
|
143
|
+
* @param {number} [windowBytes]
|
|
144
|
+
* @returns {void}
|
|
145
|
+
*/
|
|
146
|
+
prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes) {
|
|
147
|
+
const sourceKey = torrent?.sourceKey;
|
|
148
|
+
if (!sourceKey) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
void this.#client
|
|
152
|
+
.prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes })
|
|
153
|
+
.catch(() => undefined);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Pre-fetch the head and tail the codec probe needs.
|
|
158
|
+
*
|
|
159
|
+
* Takes an options object, matching `TorrentPool.prefetchFileEdges` — this
|
|
160
|
+
* adapter exists to present that same interface. It previously declared
|
|
161
|
+
* positional parameters instead, so the planner's options object arrived as
|
|
162
|
+
* `headBytes` and only worked because it was passed along far enough to be
|
|
163
|
+
* destructured at the far end. Anyone calling it as documented got the
|
|
164
|
+
* defaults instead of the sizes they asked for.
|
|
165
|
+
*
|
|
166
|
+
* @param {object} torrent
|
|
167
|
+
* @param {number} fileIndex
|
|
168
|
+
* @param {{ headBytes?: number, tailBytes?: number, timeoutMs?: number }} [options]
|
|
169
|
+
* @returns {Promise<unknown>}
|
|
170
|
+
*/
|
|
171
|
+
async prefetchFileEdges(torrent, fileIndex, options = {}) {
|
|
172
|
+
const sourceKey = torrent?.sourceKey;
|
|
173
|
+
if (!sourceKey) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
return this.#client.prefetchFileEdges({ sourceKey, fileIndex, options });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Shut the torrent client down and stop the thread.
|
|
181
|
+
*
|
|
182
|
+
* @returns {Promise<void>}
|
|
183
|
+
*/
|
|
184
|
+
async destroyAll() {
|
|
185
|
+
this.#torrents.clear();
|
|
186
|
+
await this.#client.destroyAll();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import "./install-webrtc-shim.js";
|
|
25
25
|
import { parentPort, workerData } from "node:worker_threads";
|
|
26
26
|
import { createSendStream } from "./channel.js";
|
|
27
|
+
import { createFileClaims } from "./file-claims.js";
|
|
27
28
|
import { Command, Event, STREAM_CHUNK_BYTES } from "./protocol.js";
|
|
28
29
|
|
|
29
30
|
// Imported dynamically, and that is load-bearing: static imports are RESOLVED
|
|
@@ -41,8 +42,8 @@ const pool = new TorrentPool({
|
|
|
41
42
|
|
|
42
43
|
/** Torrents by sourceKey — the main thread names them, this thread owns them. */
|
|
43
44
|
const torrentsByKey = new Map();
|
|
44
|
-
/** File
|
|
45
|
-
const
|
|
45
|
+
/** File claims, each with its own identity — see `file-claims.js`. */
|
|
46
|
+
const fileClaims = createFileClaims();
|
|
46
47
|
/** In-flight reads, so a cancel can stop one mid-body. */
|
|
47
48
|
const readsById = new Map();
|
|
48
49
|
|
|
@@ -58,17 +59,29 @@ function log(message) {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/**
|
|
61
|
-
* The torrent for a sourceKey,
|
|
62
|
+
* The torrent for a sourceKey, waiting for it if it is still being added.
|
|
63
|
+
*
|
|
64
|
+
* The map holds a PROMISE, registered the moment the add begins rather than
|
|
65
|
+
* when it finishes. That distinction is the whole fix: adding a magnet takes as
|
|
66
|
+
* long as its metadata does — seconds to tens of seconds — and until 2.9.77
|
|
67
|
+
* everything naming that source in the meantime was told `Unknown source`,
|
|
68
|
+
* which is false. The source exists; it is not ready. Reproduced with a magnet
|
|
69
|
+
* nobody seeds: stats, the file listing and a read all failed instantly while
|
|
70
|
+
* the add was still in flight, which on the loading screen shows up as no
|
|
71
|
+
* peers, no progress, and a plan request that fails before the torrent has had
|
|
72
|
+
* a chance to start.
|
|
73
|
+
*
|
|
74
|
+
* A source that was never added still throws, which is the honest answer.
|
|
62
75
|
*
|
|
63
76
|
* @param {string} sourceKey
|
|
64
|
-
* @returns {import("webtorrent").Torrent}
|
|
77
|
+
* @returns {Promise<import("webtorrent").Torrent>}
|
|
65
78
|
*/
|
|
66
|
-
function requireTorrent(sourceKey) {
|
|
67
|
-
const
|
|
68
|
-
if (!
|
|
79
|
+
async function requireTorrent(sourceKey) {
|
|
80
|
+
const pending = torrentsByKey.get(sourceKey);
|
|
81
|
+
if (!pending) {
|
|
69
82
|
throw new Error(`Unknown source ${sourceKey}.`);
|
|
70
83
|
}
|
|
71
|
-
return
|
|
84
|
+
return pending;
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
/**
|
|
@@ -89,7 +102,7 @@ function requireTorrent(sourceKey) {
|
|
|
89
102
|
* @returns {Promise<void>}
|
|
90
103
|
*/
|
|
91
104
|
async function streamRange({ id, sourceKey, fileIndex, start, end }) {
|
|
92
|
-
const torrent = requireTorrent(sourceKey);
|
|
105
|
+
const torrent = await requireTorrent(sourceKey);
|
|
93
106
|
const file = torrent.files?.[fileIndex];
|
|
94
107
|
if (!file) {
|
|
95
108
|
throw new Error(`File ${fileIndex} not found in ${sourceKey}.`);
|
|
@@ -126,6 +139,7 @@ async function streamRange({ id, sourceKey, fileIndex, start, end }) {
|
|
|
126
139
|
await sender.send(merged);
|
|
127
140
|
};
|
|
128
141
|
|
|
142
|
+
let failed = false;
|
|
129
143
|
try {
|
|
130
144
|
for await (const part of source) {
|
|
131
145
|
if (sender.isCancelled()) {
|
|
@@ -140,10 +154,20 @@ async function streamRange({ id, sourceKey, fileIndex, start, end }) {
|
|
|
140
154
|
if (!sender.isCancelled()) {
|
|
141
155
|
await flush();
|
|
142
156
|
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
// The end-of-read marker means "the body is complete". Sending it after a
|
|
159
|
+
// failure told the reader the file simply ended — a truncated segment that
|
|
160
|
+
// ffmpeg reported as `Stream ends prematurely`, with the real cause thrown
|
|
161
|
+
// away. Let the error propagate instead; the command handler reports it and
|
|
162
|
+
// the main thread fails the stream.
|
|
163
|
+
failed = true;
|
|
164
|
+
throw error;
|
|
143
165
|
} finally {
|
|
144
166
|
readsById.delete(id);
|
|
145
167
|
releaseRead();
|
|
146
|
-
|
|
168
|
+
if (!failed) {
|
|
169
|
+
sender.end();
|
|
170
|
+
}
|
|
147
171
|
// A cancelled read must stop the underlying torrent stream too, or the
|
|
148
172
|
// pieces keep being fetched for a viewer who has gone.
|
|
149
173
|
if (typeof source.destroy === "function") {
|
|
@@ -163,8 +187,24 @@ async function streamRange({ id, sourceKey, fileIndex, start, end }) {
|
|
|
163
187
|
async function runCommand(command, params, id) {
|
|
164
188
|
switch (command) {
|
|
165
189
|
case Command.ADD_SOURCE: {
|
|
166
|
-
|
|
167
|
-
|
|
190
|
+
// Registered before it resolves, so anything naming this source while it
|
|
191
|
+
// is being added waits for it instead of being told it does not exist.
|
|
192
|
+
// Reusing the same promise for a repeated add also collapses two callers
|
|
193
|
+
// racing to open the same torrent into one.
|
|
194
|
+
let pending = torrentsByKey.get(params.sourceKey);
|
|
195
|
+
if (!pending) {
|
|
196
|
+
pending = pool.getTorrent(params.sourceType, params.source);
|
|
197
|
+
torrentsByKey.set(params.sourceKey, pending);
|
|
198
|
+
// A failed add must not be remembered, or every later attempt at this
|
|
199
|
+
// source replays the same failure. The handler also marks the rejection
|
|
200
|
+
// as observed, so it cannot surface as an unhandled one.
|
|
201
|
+
pending.catch(() => {
|
|
202
|
+
if (torrentsByKey.get(params.sourceKey) === pending) {
|
|
203
|
+
torrentsByKey.delete(params.sourceKey);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const torrent = await pending;
|
|
168
208
|
return {
|
|
169
209
|
infoHash: torrent.infoHash,
|
|
170
210
|
name: torrent.name,
|
|
@@ -179,7 +219,7 @@ async function runCommand(command, params, id) {
|
|
|
179
219
|
}
|
|
180
220
|
|
|
181
221
|
case Command.LIST_FILES: {
|
|
182
|
-
const torrent = requireTorrent(params.sourceKey);
|
|
222
|
+
const torrent = await requireTorrent(params.sourceKey);
|
|
183
223
|
return (torrent.files ?? []).map((file, index) => ({
|
|
184
224
|
index,
|
|
185
225
|
name: file.name,
|
|
@@ -189,42 +229,42 @@ async function runCommand(command, params, id) {
|
|
|
189
229
|
}
|
|
190
230
|
|
|
191
231
|
case Command.ACQUIRE_FILE: {
|
|
192
|
-
const torrent = requireTorrent(params.sourceKey);
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
232
|
+
const torrent = await requireTorrent(params.sourceKey);
|
|
233
|
+
// Every acquire is its own claim. Sharing one per file meant the first
|
|
234
|
+
// reader to finish released the hold while others were still reading.
|
|
235
|
+
return fileClaims.open(
|
|
236
|
+
params.sourceKey,
|
|
237
|
+
params.fileIndex,
|
|
238
|
+
pool.acquireFile(torrent, params.fileIndex)
|
|
239
|
+
);
|
|
200
240
|
}
|
|
201
241
|
|
|
202
242
|
case Command.RELEASE_FILE: {
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
release
|
|
243
|
+
const released = fileClaims.close(params.claimId);
|
|
244
|
+
if (!released) {
|
|
245
|
+
// Not fatal — but it means a release arrived twice or after teardown,
|
|
246
|
+
// and silence here is what let the previous scheme look healthy.
|
|
247
|
+
log(`release for unknown file claim ${params.claimId}`);
|
|
208
248
|
}
|
|
209
|
-
return
|
|
249
|
+
return released;
|
|
210
250
|
}
|
|
211
251
|
|
|
212
252
|
case Command.FILE_STATS: {
|
|
213
|
-
const torrent = requireTorrent(params.sourceKey);
|
|
253
|
+
const torrent = await requireTorrent(params.sourceKey);
|
|
214
254
|
return pool.getFileStats(torrent, params.fileIndex, {
|
|
215
255
|
resumeAnchorByteStart: params.resumeAnchorByteStart ?? null
|
|
216
256
|
});
|
|
217
257
|
}
|
|
218
258
|
|
|
219
259
|
case Command.PRIORITIZE: {
|
|
220
|
-
const torrent = requireTorrent(params.sourceKey);
|
|
260
|
+
const torrent = await requireTorrent(params.sourceKey);
|
|
221
261
|
pool.prioritizeByteRange(torrent, params.fileIndex, params.byteStart, params.windowBytes);
|
|
222
262
|
return true;
|
|
223
263
|
}
|
|
224
264
|
|
|
225
265
|
case Command.PREFETCH_EDGES: {
|
|
226
|
-
const torrent = requireTorrent(params.sourceKey);
|
|
227
|
-
return pool.prefetchFileEdges(torrent, params.fileIndex, params.
|
|
266
|
+
const torrent = await requireTorrent(params.sourceKey);
|
|
267
|
+
return pool.prefetchFileEdges(torrent, params.fileIndex, params.options ?? {});
|
|
228
268
|
}
|
|
229
269
|
|
|
230
270
|
case Command.READ_RANGE: {
|
|
@@ -246,10 +286,7 @@ async function runCommand(command, params, id) {
|
|
|
246
286
|
}
|
|
247
287
|
|
|
248
288
|
case Command.DESTROY_ALL: {
|
|
249
|
-
|
|
250
|
-
release();
|
|
251
|
-
}
|
|
252
|
-
releaseByClaim.clear();
|
|
289
|
+
fileClaims.closeAll();
|
|
253
290
|
torrentsByKey.clear();
|
|
254
291
|
await pool.destroyAll();
|
|
255
292
|
return true;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file File claims must be held per reader, not per file.
|
|
3
|
+
*
|
|
4
|
+
* The proxy reads one file from several places at once — ffmpeg's input, the
|
|
5
|
+
* keyframe index, the codec probe, a second viewer. Keying claims by file made
|
|
6
|
+
* them shared, so the first reader to finish released the hold while the others
|
|
7
|
+
* were still reading, and the file's data could then be removed under them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { createFileClaims } from "../services/torrent-worker/file-claims.js";
|
|
13
|
+
|
|
14
|
+
test("two readers of one file hold two claims", () => {
|
|
15
|
+
const claims = createFileClaims();
|
|
16
|
+
let released = 0;
|
|
17
|
+
|
|
18
|
+
const first = claims.open("source", 0, () => (released += 1));
|
|
19
|
+
const second = claims.open("source", 0, () => (released += 1));
|
|
20
|
+
|
|
21
|
+
assert.notEqual(first, second, "the second reader reused the first one's claim");
|
|
22
|
+
assert.equal(claims.size, 2);
|
|
23
|
+
|
|
24
|
+
claims.close(first);
|
|
25
|
+
assert.equal(released, 1, "closing one claim released more than one hold");
|
|
26
|
+
assert.equal(claims.size, 1, "the second reader's claim went with the first");
|
|
27
|
+
|
|
28
|
+
claims.close(second);
|
|
29
|
+
assert.equal(released, 2);
|
|
30
|
+
assert.equal(claims.size, 0);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a repeated release affects nothing and reports itself", () => {
|
|
34
|
+
const claims = createFileClaims();
|
|
35
|
+
let released = 0;
|
|
36
|
+
const claimId = claims.open("source", 3, () => (released += 1));
|
|
37
|
+
|
|
38
|
+
assert.equal(claims.close(claimId), true);
|
|
39
|
+
assert.equal(claims.close(claimId), false, "a second release was accepted as valid");
|
|
40
|
+
assert.equal(released, 1, "the hold was released twice");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a release naming nothing is rejected rather than guessed at", () => {
|
|
44
|
+
const claims = createFileClaims();
|
|
45
|
+
let released = 0;
|
|
46
|
+
claims.open("source", 0, () => (released += 1));
|
|
47
|
+
|
|
48
|
+
assert.equal(claims.close("source:0:999"), false);
|
|
49
|
+
assert.equal(released, 0, "an unknown claim released a real hold");
|
|
50
|
+
assert.equal(claims.size, 1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("teardown releases every outstanding claim", () => {
|
|
54
|
+
const claims = createFileClaims();
|
|
55
|
+
let released = 0;
|
|
56
|
+
claims.open("a", 0, () => (released += 1));
|
|
57
|
+
claims.open("a", 1, () => (released += 1));
|
|
58
|
+
claims.open("b", 0, () => (released += 1));
|
|
59
|
+
|
|
60
|
+
claims.closeAll();
|
|
61
|
+
|
|
62
|
+
assert.equal(released, 3);
|
|
63
|
+
assert.equal(claims.size, 0);
|
|
64
|
+
});
|