@torrent-tv/proxy 2.9.93 → 2.9.95

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.
@@ -1,410 +1,412 @@
1
- /**
2
- * @file The torrent thread: WebTorrent and nothing else.
3
- *
4
- * Everything that made the main thread unresponsive lives here now — peer
5
- * connections, buffer concatenation, piece bookkeeping, garbage collection from
6
- * all of it. The main thread keeps only what owes a viewer a prompt answer.
7
- *
8
- * This file deliberately holds no HTTP, no session logic and no knowledge of
9
- * HLS: it answers the commands in `protocol.js` and streams bytes back. That
10
- * boundary is what keeps the split honest — anything added here will compete
11
- * with the torrent for this thread, which is exactly the problem being solved.
12
- *
13
- * The existing `TorrentPool` is reused wholesale rather than reimplemented. It
14
- * already carries the parts that took field failures to get right — refcounted
15
- * file claims, idle removal, the global disk cap with LRU eviction, seek-aware
16
- * piece prioritisation, adaptive upload — and none of that changes by moving
17
- * threads.
18
- */
19
-
20
- // MUST stay first: it redirects `webrtc-polyfill` to a JavaScript WebRTC stack
21
- // before WebTorrent can reach the native one. Two isolates using
22
- // node-datachannel at once abort the process, and the torrent's wss trackers
23
- // create peer connections of their own.
24
- import "./install-webrtc-shim.js";
25
- import { parentPort, workerData } from "node:worker_threads";
26
- import { createSendStream } from "./channel.js";
27
- import { createFileClaims } from "./file-claims.js";
28
- import { readFragments } from "./piece-reader.js";
29
- import { Command, Event } from "./protocol.js";
30
-
31
- // Imported dynamically, and that is load-bearing: static imports are RESOLVED
32
- // during linking, before any module body runs, so a statically imported pool
33
- // would drag in WebTorrent — and with it the real `webrtc-polyfill` — before
34
- // the hook above had a chance to register. Verified the hard way: with a static
35
- // import the process still aborted, and the stack named the genuine polyfill.
36
- const { TorrentPool } = await import("../torrent-pool.js");
37
- const { collectStoreStats, findSharedStore } = await import("../piece-store/shared-piece-store.js");
38
-
39
- const pool = new TorrentPool({
40
- maxDiskBytes: workerData?.maxDiskBytes,
41
- memoryBytes: workerData?.memoryBytes
42
- });
43
-
44
- /** Torrents by sourceKey — the main thread names them, this thread owns them. */
45
- const torrentsByKey = new Map();
46
- /** File claims, each with its own identity — see `file-claims.js`. */
47
- const fileClaims = createFileClaims();
48
- /** In-flight reads, so a cancel can stop one mid-body. */
49
- const readsById = new Map();
50
-
51
- /**
52
- * Forward a log line to the main thread, so worker output is not lost or
53
- * interleaved separately from everything else.
54
- *
55
- * @param {string} message
56
- * @returns {void}
57
- */
58
- function log(message) {
59
- parentPort.postMessage({ type: Event.LOG, message });
60
- }
61
-
62
- /**
63
- * The torrent for a sourceKey, waiting for it if it is still being added.
64
- *
65
- * The map holds a PROMISE, registered the moment the add begins rather than
66
- * when it finishes. That distinction is the whole fix: adding a magnet takes as
67
- * long as its metadata does — seconds to tens of seconds — and until 2.9.77
68
- * everything naming that source in the meantime was told `Unknown source`,
69
- * which is false. The source exists; it is not ready. Reproduced with a magnet
70
- * nobody seeds: stats, the file listing and a read all failed instantly while
71
- * the add was still in flight, which on the loading screen shows up as no
72
- * peers, no progress, and a plan request that fails before the torrent has had
73
- * a chance to start.
74
- *
75
- * A source that was never added still throws, which is the honest answer.
76
- *
77
- * @param {string} sourceKey
78
- * @returns {Promise<import("webtorrent").Torrent>}
79
- */
80
- async function requireTorrent(sourceKey) {
81
- const pending = torrentsByKey.get(sourceKey);
82
- if (!pending) {
83
- throw new Error(`Unknown source ${sourceKey}.`);
84
- }
85
- return pending;
86
- }
87
-
88
- /**
89
- * Fragments waiting for the main thread to say it has finished reading them,
90
- * keyed by request id. One per read, because only one fragment is in flight.
91
- *
92
- * @type {Map<number, () => void>}
93
- */
94
- const fragmentWaiters = new Map();
95
-
96
- /**
97
- * Wake a read that is waiting for a fragment to be confirmed.
98
- *
99
- * Used both by the confirmation itself and by cancellation — a cancelled read
100
- * will never be confirmed, and without this it would wait forever holding a pin.
101
- *
102
- * @param {number} id
103
- * @returns {void}
104
- */
105
- function settleFragment(id) {
106
- const done = fragmentWaiters.get(id);
107
- if (done) {
108
- fragmentWaiters.delete(id);
109
- done();
110
- }
111
- }
112
-
113
- /**
114
- * Send one fragment's position and wait until the main thread is done with it.
115
- *
116
- * The pin is dropped only after the confirmation, because until then the other
117
- * thread may still be reading those exact bytes.
118
- *
119
- * @param {number} id
120
- * @param {import("./piece-reader.js").PieceFragment} fragment
121
- * @returns {Promise<void>}
122
- */
123
- function sendFragment(id, fragment) {
124
- return new Promise((resolve) => {
125
- fragmentWaiters.set(id, () => {
126
- fragment.release();
127
- resolve();
128
- });
129
- parentPort.postMessage({
130
- type: Event.FRAGMENT,
131
- id,
132
- pieceIndex: fragment.pieceIndex,
133
- offset: fragment.offset,
134
- length: fragment.length
135
- });
136
- });
137
- }
138
-
139
- /**
140
- * Stream a byte range back as CHUNK messages.
141
- *
142
- * Reads through WebTorrent's own read stream — which serves already-downloaded
143
- * pieces from disk and waits for the rest — and forwards it in
144
- * {@link STREAM_CHUNK_BYTES} pieces, transferring ownership of each so nothing
145
- * is copied across the boundary. `createSendStream` applies the backpressure,
146
- * so a fast disk cannot outrun the main thread and rebuild the queue in memory.
147
- *
148
- * @param {object} params
149
- * @param {number} params.id - Request id; CHUNK/READ_END carry it.
150
- * @param {string} params.sourceKey
151
- * @param {number} params.fileIndex
152
- * @param {number | null} params.start - Inclusive, or null for the whole file.
153
- * @param {number | null} params.end - Inclusive.
154
- * @returns {Promise<void>}
155
- */
156
- async function streamRange({ id, sourceKey, fileIndex, start, end }) {
157
- const torrent = await requireTorrent(sourceKey);
158
- const file = torrent.files?.[fileIndex];
159
- if (!file) {
160
- throw new Error(`File ${fileIndex} not found in ${sourceKey}.`);
161
- }
162
-
163
- const sender = createSendStream({ port: parentPort, requestId: id });
164
- readsById.set(id, sender);
165
-
166
- // Hold the file for as long as this read runs. The caller also acquires it,
167
- // but that acquire and its release are separate messages from another thread
168
- // and can be reordered; this one cannot, because it lives entirely inside the
169
- // read. Without it the idle sweep saw a zero reader count and removed the
170
- // torrent AND its store mid-read — field 2026-08-02: "removed idle torrent
171
- // ... and its store", after which every subsequent read hung and ffmpeg got
172
- // an empty input.
173
- const releaseRead = pool.acquireFile(torrent, fileIndex);
174
-
175
- const rangeStart = start ?? 0;
176
- const rangeEnd = end ?? file.length - 1;
177
-
178
- let failed = false;
179
- try {
180
- // Positions in shared memory, not bytes: the main thread maps the same pool
181
- // and reads each fragment in place, so nothing is copied and nothing is
182
- // transferred. See `piece-reader.js`.
183
- for await (const fragment of readFragments({
184
- torrent,
185
- fileIndex,
186
- start: rangeStart,
187
- end: rangeEnd,
188
- cancellation: sender
189
- })) {
190
- if (sender.isCancelled()) {
191
- fragment.release();
192
- break;
193
- }
194
- // One fragment in flight at a time. Each one holds a piece pinned, and
195
- // the store guarantees only two resident pieces at its smallest budget
196
- // holding two pins while asking for a third would deadlock it against
197
- // itself. The round trip costs ~100 µs against a piece worth megabytes,
198
- // so there is nothing to win by overlapping them.
199
- await sendFragment(id, fragment);
200
- }
201
- } catch (error) {
202
- // The end-of-read marker means "the body is complete". Sending it after a
203
- // failure told the reader the file simply ended a truncated segment that
204
- // ffmpeg reported as `Stream ends prematurely`, with the real cause thrown
205
- // away. Let the error propagate instead; the command handler reports it and
206
- // the main thread fails the stream.
207
- failed = true;
208
- throw error;
209
- } finally {
210
- readsById.delete(id);
211
- // Any fragment still awaiting confirmation will never get one now; settling
212
- // it here releases its pin rather than leaking a held slot.
213
- settleFragment(id);
214
- releaseRead();
215
- if (!failed) {
216
- sender.end();
217
- }
218
- // Nothing else to tear down: the reader owns no stream of its own, and a
219
- // cancelled read stops at its next fragment boundary because it polls the
220
- // same `sender` for cancellation.
221
- }
222
- }
223
-
224
- /**
225
- * Run one command and return its result.
226
- *
227
- * @param {string} command
228
- * @param {object} params
229
- * @param {number} id
230
- * @returns {Promise<unknown>}
231
- */
232
- async function runCommand(command, params, id) {
233
- switch (command) {
234
- case Command.ADD_SOURCE: {
235
- // Registered before it resolves, so anything naming this source while it
236
- // is being added waits for it instead of being told it does not exist.
237
- // Reusing the same promise for a repeated add also collapses two callers
238
- // racing to open the same torrent into one.
239
- let pending = torrentsByKey.get(params.sourceKey);
240
- if (!pending) {
241
- pending = pool.getTorrent(params.sourceType, params.source);
242
- torrentsByKey.set(params.sourceKey, pending);
243
- // A failed add must not be remembered, or every later attempt at this
244
- // source replays the same failure. The handler also marks the rejection
245
- // as observed, so it cannot surface as an unhandled one.
246
- pending.catch(() => {
247
- if (torrentsByKey.get(params.sourceKey) === pending) {
248
- torrentsByKey.delete(params.sourceKey);
249
- }
250
- });
251
- }
252
- const torrent = await pending;
253
- return {
254
- infoHash: torrent.infoHash,
255
- name: torrent.name,
256
- // The piece pool itself. A `SharedArrayBuffer` crosses as a reference to
257
- // the same memory rather than a copy, which is what lets the main thread
258
- // read a piece where it already lies instead of being sent its bytes.
259
- sharedBuffer: findSharedStore(torrent)?.sharedBuffer ?? null,
260
- // Files cross as plain data; the objects stay here.
261
- files: (torrent.files ?? []).map((file, index) => ({
262
- index,
263
- name: file.name,
264
- path: file.path,
265
- length: file.length
266
- }))
267
- };
268
- }
269
-
270
- case Command.LIST_FILES: {
271
- const torrent = await requireTorrent(params.sourceKey);
272
- return (torrent.files ?? []).map((file, index) => ({
273
- index,
274
- name: file.name,
275
- path: file.path,
276
- length: file.length
277
- }));
278
- }
279
-
280
- case Command.ACQUIRE_FILE: {
281
- const torrent = await requireTorrent(params.sourceKey);
282
- // Every acquire is its own claim. Sharing one per file meant the first
283
- // reader to finish released the hold while others were still reading.
284
- return fileClaims.open(
285
- params.sourceKey,
286
- params.fileIndex,
287
- pool.acquireFile(torrent, params.fileIndex)
288
- );
289
- }
290
-
291
- case Command.RELEASE_FILE: {
292
- const released = fileClaims.close(params.claimId);
293
- if (!released) {
294
- // Not fatal — but it means a release arrived twice or after teardown,
295
- // and silence here is what let the previous scheme look healthy.
296
- log(`release for unknown file claim ${params.claimId}`);
297
- }
298
- return released;
299
- }
300
-
301
- case Command.FILE_STATS: {
302
- const torrent = await requireTorrent(params.sourceKey);
303
- return pool.getFileStats(torrent, params.fileIndex, {
304
- resumeAnchorByteStart: params.resumeAnchorByteStart ?? null
305
- });
306
- }
307
-
308
- case Command.PRIORITIZE: {
309
- const torrent = await requireTorrent(params.sourceKey);
310
- pool.prioritizeByteRange(torrent, params.fileIndex, params.byteStart, params.windowBytes, {
311
- wholeFileRead: params.wholeFileRead === true
312
- });
313
- return true;
314
- }
315
-
316
- case Command.PREFETCH_EDGES: {
317
- const torrent = await requireTorrent(params.sourceKey);
318
- return pool.prefetchFileEdges(torrent, params.fileIndex, params.options ?? {});
319
- }
320
-
321
- case Command.READ_RANGE: {
322
- // Streams its own reply; the caller's promise resolves once the body has
323
- // been fully sent, which is what lets the client await completion.
324
- await streamRange({
325
- id,
326
- sourceKey: params.sourceKey,
327
- fileIndex: params.fileIndex,
328
- start: params.start ?? null,
329
- end: params.end ?? null
330
- });
331
- return true;
332
- }
333
-
334
- case Command.CANCEL_READ: {
335
- readsById.get(params.readId)?.cancel();
336
- // A cancelled read will never have its outstanding fragment confirmed, so
337
- // wake it here — otherwise it waits forever with a piece pinned.
338
- settleFragment(params.readId);
339
- return true;
340
- }
341
-
342
- case Command.DESTROY_ALL: {
343
- fileClaims.closeAll();
344
- torrentsByKey.clear();
345
- await pool.destroyAll();
346
- return true;
347
- }
348
-
349
- default:
350
- throw new Error(`Unknown torrent-worker command: ${command}`);
351
- }
352
- }
353
-
354
- parentPort.on("message", async (message) => {
355
- // Chunk acknowledgements are not commands — they release backpressure on an
356
- // in-flight read.
357
- if (message?.type === Event.CHUNK_ACK) {
358
- readsById.get(message.id)?.ack();
359
- return;
360
- }
361
-
362
- // The main thread has finished reading a fragment out of shared memory, so
363
- // its piece may be unpinned and the read may continue.
364
- if (message?.type === Event.FRAGMENT_DONE) {
365
- settleFragment(message.id);
366
- return;
367
- }
368
-
369
- const { command, id, params } = message ?? {};
370
- try {
371
- const result = await runCommand(command, params ?? {}, id);
372
- parentPort.postMessage({ type: Event.RESULT, id, result });
373
- } catch (error) {
374
- parentPort.postMessage({ type: Event.ERROR, id, error: error?.message ?? String(error) });
375
- }
376
- });
377
-
378
- /**
379
- * How often the piece store reports what it has been doing.
380
- *
381
- * The store decides whether a read costs nothing or costs a disk trip, and
382
- * until 2.9.75 nothing about it reached the log — a field oddity would have had
383
- * no evidence to work from. Reported only when something changed, so an idle
384
- * proxy stays quiet.
385
- */
386
- const STORE_REPORT_INTERVAL_MS = 60_000;
387
-
388
- /** Last reported figures per store, so unchanged ones stay silent. */
389
- const lastReported = new Map();
390
-
391
- setInterval(() => {
392
- for (const stats of collectStoreStats()) {
393
- const signature = `${stats.fromMemory}/${stats.fromDisk}/${stats.spills}/${stats.revivals}/${stats.blockedByPins}`;
394
- if (lastReported.get(stats.name) === signature) {
395
- continue;
396
- }
397
- lastReported.set(stats.name, signature);
398
-
399
- const reads = stats.fromMemory + stats.fromDisk;
400
- const fromMemoryShare = reads > 0 ? ((stats.fromMemory / reads) * 100).toFixed(1) : "—";
401
- log(
402
- `piece-store "${stats.name.slice(0, 40)}": resident=${stats.resident}/${stats.capacity} ` +
403
- `pinned=${stats.pinned} spilled=${stats.spilled} reads=${reads} (${fromMemoryShare}% from memory) ` +
404
- `spills=${stats.spills} revivals=${stats.revivals}` +
405
- (stats.blockedByPins > 0 ? ` blocked-by-pins=${stats.blockedByPins}` : "")
406
- );
407
- }
408
- }, STORE_REPORT_INTERVAL_MS).unref();
409
-
410
- log("torrent worker started");
1
+ /**
2
+ * @file The torrent thread: WebTorrent and nothing else.
3
+ *
4
+ * Everything that made the main thread unresponsive lives here now — peer
5
+ * connections, buffer concatenation, piece bookkeeping, garbage collection from
6
+ * all of it. The main thread keeps only what owes a viewer a prompt answer.
7
+ *
8
+ * This file deliberately holds no HTTP, no session logic and no knowledge of
9
+ * HLS: it answers the commands in `protocol.js` and streams bytes back. That
10
+ * boundary is what keeps the split honest — anything added here will compete
11
+ * with the torrent for this thread, which is exactly the problem being solved.
12
+ *
13
+ * The existing `TorrentPool` is reused wholesale rather than reimplemented. It
14
+ * already carries the parts that took field failures to get right — refcounted
15
+ * file claims, idle removal, the global disk cap with LRU eviction, seek-aware
16
+ * piece prioritisation, adaptive upload — and none of that changes by moving
17
+ * threads.
18
+ */
19
+
20
+ // MUST stay first: it redirects `webrtc-polyfill` to a JavaScript WebRTC stack
21
+ // before WebTorrent can reach the native one. Two isolates using
22
+ // node-datachannel at once abort the process, and the torrent's wss trackers
23
+ // create peer connections of their own.
24
+ import "./install-webrtc-shim.js";
25
+ import { parentPort, workerData } from "node:worker_threads";
26
+ import { createSendStream } from "./channel.js";
27
+ import { createFileClaims } from "./file-claims.js";
28
+ import { readFragments } from "./piece-reader.js";
29
+ import { Command, Event } from "./protocol.js";
30
+
31
+ // Imported dynamically, and that is load-bearing: static imports are RESOLVED
32
+ // during linking, before any module body runs, so a statically imported pool
33
+ // would drag in WebTorrent — and with it the real `webrtc-polyfill` — before
34
+ // the hook above had a chance to register. Verified the hard way: with a static
35
+ // import the process still aborted, and the stack named the genuine polyfill.
36
+ const { TorrentPool } = await import("../torrent-pool.js");
37
+ const { collectStoreStats, findSharedStore } = await import("../piece-store/shared-piece-store.js");
38
+
39
+ const pool = new TorrentPool({
40
+ maxDiskBytes: workerData?.maxDiskBytes,
41
+ memoryBytes: workerData?.memoryBytes
42
+ });
43
+
44
+ /** Torrents by sourceKey — the main thread names them, this thread owns them. */
45
+ const torrentsByKey = new Map();
46
+ /** File claims, each with its own identity — see `file-claims.js`. */
47
+ const fileClaims = createFileClaims();
48
+ /** In-flight reads, so a cancel can stop one mid-body. */
49
+ const readsById = new Map();
50
+
51
+ /**
52
+ * Forward a log line to the main thread, so worker output is not lost or
53
+ * interleaved separately from everything else.
54
+ *
55
+ * @param {string} message
56
+ * @returns {void}
57
+ */
58
+ function log(message) {
59
+ parentPort.postMessage({ type: Event.LOG, message });
60
+ }
61
+
62
+ /**
63
+ * The torrent for a sourceKey, waiting for it if it is still being added.
64
+ *
65
+ * The map holds a PROMISE, registered the moment the add begins rather than
66
+ * when it finishes. That distinction is the whole fix: adding a magnet takes as
67
+ * long as its metadata does — seconds to tens of seconds — and until 2.9.77
68
+ * everything naming that source in the meantime was told `Unknown source`,
69
+ * which is false. The source exists; it is not ready. Reproduced with a magnet
70
+ * nobody seeds: stats, the file listing and a read all failed instantly while
71
+ * the add was still in flight, which on the loading screen shows up as no
72
+ * peers, no progress, and a plan request that fails before the torrent has had
73
+ * a chance to start.
74
+ *
75
+ * A source that was never added still throws, which is the honest answer.
76
+ *
77
+ * @param {string} sourceKey
78
+ * @returns {Promise<import("webtorrent").Torrent>}
79
+ */
80
+ async function requireTorrent(sourceKey) {
81
+ const pending = torrentsByKey.get(sourceKey);
82
+ if (!pending) {
83
+ throw new Error(`Unknown source ${sourceKey}.`);
84
+ }
85
+ return pending;
86
+ }
87
+
88
+ /**
89
+ * Fragments waiting for the main thread to say it has finished reading them,
90
+ * keyed by request id. One per read, because only one fragment is in flight.
91
+ *
92
+ * @type {Map<number, () => void>}
93
+ */
94
+ const fragmentWaiters = new Map();
95
+
96
+ /**
97
+ * Wake a read that is waiting for a fragment to be confirmed.
98
+ *
99
+ * Used both by the confirmation itself and by cancellation — a cancelled read
100
+ * will never be confirmed, and without this it would wait forever holding a pin.
101
+ *
102
+ * @param {number} id
103
+ * @returns {void}
104
+ */
105
+ function settleFragment(id) {
106
+ const done = fragmentWaiters.get(id);
107
+ if (done) {
108
+ fragmentWaiters.delete(id);
109
+ done();
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Send one fragment's position and wait until the main thread is done with it.
115
+ *
116
+ * The pin is dropped only after the confirmation, because until then the other
117
+ * thread may still be reading those exact bytes.
118
+ *
119
+ * @param {number} id
120
+ * @param {import("./piece-reader.js").PieceFragment} fragment
121
+ * @returns {Promise<void>}
122
+ */
123
+ function sendFragment(id, fragment) {
124
+ return new Promise((resolve) => {
125
+ fragmentWaiters.set(id, () => {
126
+ fragment.release();
127
+ resolve();
128
+ });
129
+ parentPort.postMessage({
130
+ type: Event.FRAGMENT,
131
+ id,
132
+ pieceIndex: fragment.pieceIndex,
133
+ offset: fragment.offset,
134
+ length: fragment.length
135
+ });
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Stream a byte range back as CHUNK messages.
141
+ *
142
+ * Reads through WebTorrent's own read stream — which serves already-downloaded
143
+ * pieces from disk and waits for the rest — and forwards it in
144
+ * {@link STREAM_CHUNK_BYTES} pieces, transferring ownership of each so nothing
145
+ * is copied across the boundary. `createSendStream` applies the backpressure,
146
+ * so a fast disk cannot outrun the main thread and rebuild the queue in memory.
147
+ *
148
+ * @param {object} params
149
+ * @param {number} params.id - Request id; CHUNK/READ_END carry it.
150
+ * @param {string} params.sourceKey
151
+ * @param {number} params.fileIndex
152
+ * @param {number | null} params.start - Inclusive, or null for the whole file.
153
+ * @param {number | null} params.end - Inclusive.
154
+ * @returns {Promise<void>}
155
+ */
156
+ async function streamRange({ id, sourceKey, fileIndex, start, end, windowBytes }) {
157
+ const torrent = await requireTorrent(sourceKey);
158
+ const file = torrent.files?.[fileIndex];
159
+ if (!file) {
160
+ throw new Error(`File ${fileIndex} not found in ${sourceKey}.`);
161
+ }
162
+
163
+ const sender = createSendStream({ port: parentPort, requestId: id });
164
+ readsById.set(id, sender);
165
+
166
+ // Hold the file for as long as this read runs. The caller also acquires it,
167
+ // but that acquire and its release are separate messages from another thread
168
+ // and can be reordered; this one cannot, because it lives entirely inside the
169
+ // read. Without it the idle sweep saw a zero reader count and removed the
170
+ // torrent AND its store mid-read — field 2026-08-02: "removed idle torrent
171
+ // ... and its store", after which every subsequent read hung and ffmpeg got
172
+ // an empty input.
173
+ const releaseRead = pool.acquireFile(torrent, fileIndex);
174
+
175
+ const rangeStart = start ?? 0;
176
+ const rangeEnd = end ?? file.length - 1;
177
+
178
+ let failed = false;
179
+ try {
180
+ // Positions in shared memory, not bytes: the main thread maps the same pool
181
+ // and reads each fragment in place, so nothing is copied and nothing is
182
+ // transferred. See `piece-reader.js`.
183
+ for await (const fragment of readFragments({
184
+ torrent,
185
+ fileIndex,
186
+ start: rangeStart,
187
+ end: rangeEnd,
188
+ cancellation: sender,
189
+ windowBytes
190
+ })) {
191
+ if (sender.isCancelled()) {
192
+ fragment.release();
193
+ break;
194
+ }
195
+ // One fragment in flight at a time. Each one holds a piece pinned, and
196
+ // the store guarantees only two resident pieces at its smallest budget
197
+ // holding two pins while asking for a third would deadlock it against
198
+ // itself. The round trip costs ~100 µs against a piece worth megabytes,
199
+ // so there is nothing to win by overlapping them.
200
+ await sendFragment(id, fragment);
201
+ }
202
+ } catch (error) {
203
+ // The end-of-read marker means "the body is complete". Sending it after a
204
+ // failure told the reader the file simply ended a truncated segment that
205
+ // ffmpeg reported as `Stream ends prematurely`, with the real cause thrown
206
+ // away. Let the error propagate instead; the command handler reports it and
207
+ // the main thread fails the stream.
208
+ failed = true;
209
+ throw error;
210
+ } finally {
211
+ readsById.delete(id);
212
+ // Any fragment still awaiting confirmation will never get one now; settling
213
+ // it here releases its pin rather than leaking a held slot.
214
+ settleFragment(id);
215
+ releaseRead();
216
+ if (!failed) {
217
+ sender.end();
218
+ }
219
+ // Nothing else to tear down: the reader owns no stream of its own, and a
220
+ // cancelled read stops at its next fragment boundary because it polls the
221
+ // same `sender` for cancellation.
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Run one command and return its result.
227
+ *
228
+ * @param {string} command
229
+ * @param {object} params
230
+ * @param {number} id
231
+ * @returns {Promise<unknown>}
232
+ */
233
+ async function runCommand(command, params, id) {
234
+ switch (command) {
235
+ case Command.ADD_SOURCE: {
236
+ // Registered before it resolves, so anything naming this source while it
237
+ // is being added waits for it instead of being told it does not exist.
238
+ // Reusing the same promise for a repeated add also collapses two callers
239
+ // racing to open the same torrent into one.
240
+ let pending = torrentsByKey.get(params.sourceKey);
241
+ if (!pending) {
242
+ pending = pool.getTorrent(params.sourceType, params.source);
243
+ torrentsByKey.set(params.sourceKey, pending);
244
+ // A failed add must not be remembered, or every later attempt at this
245
+ // source replays the same failure. The handler also marks the rejection
246
+ // as observed, so it cannot surface as an unhandled one.
247
+ pending.catch(() => {
248
+ if (torrentsByKey.get(params.sourceKey) === pending) {
249
+ torrentsByKey.delete(params.sourceKey);
250
+ }
251
+ });
252
+ }
253
+ const torrent = await pending;
254
+ return {
255
+ infoHash: torrent.infoHash,
256
+ name: torrent.name,
257
+ // The piece pool itself. A `SharedArrayBuffer` crosses as a reference to
258
+ // the same memory rather than a copy, which is what lets the main thread
259
+ // read a piece where it already lies instead of being sent its bytes.
260
+ sharedBuffer: findSharedStore(torrent)?.sharedBuffer ?? null,
261
+ // Files cross as plain data; the objects stay here.
262
+ files: (torrent.files ?? []).map((file, index) => ({
263
+ index,
264
+ name: file.name,
265
+ path: file.path,
266
+ length: file.length
267
+ }))
268
+ };
269
+ }
270
+
271
+ case Command.LIST_FILES: {
272
+ const torrent = await requireTorrent(params.sourceKey);
273
+ return (torrent.files ?? []).map((file, index) => ({
274
+ index,
275
+ name: file.name,
276
+ path: file.path,
277
+ length: file.length
278
+ }));
279
+ }
280
+
281
+ case Command.ACQUIRE_FILE: {
282
+ const torrent = await requireTorrent(params.sourceKey);
283
+ // Every acquire is its own claim. Sharing one per file meant the first
284
+ // reader to finish released the hold while others were still reading.
285
+ return fileClaims.open(
286
+ params.sourceKey,
287
+ params.fileIndex,
288
+ pool.acquireFile(torrent, params.fileIndex)
289
+ );
290
+ }
291
+
292
+ case Command.RELEASE_FILE: {
293
+ const released = fileClaims.close(params.claimId);
294
+ if (!released) {
295
+ // Not fatal but it means a release arrived twice or after teardown,
296
+ // and silence here is what let the previous scheme look healthy.
297
+ log(`release for unknown file claim ${params.claimId}`);
298
+ }
299
+ return released;
300
+ }
301
+
302
+ case Command.FILE_STATS: {
303
+ const torrent = await requireTorrent(params.sourceKey);
304
+ return pool.getFileStats(torrent, params.fileIndex, {
305
+ resumeAnchorByteStart: params.resumeAnchorByteStart ?? null
306
+ });
307
+ }
308
+
309
+ case Command.PRIORITIZE: {
310
+ const torrent = await requireTorrent(params.sourceKey);
311
+ pool.prioritizeByteRange(torrent, params.fileIndex, params.byteStart, params.windowBytes, {
312
+ wholeFileRead: params.wholeFileRead === true
313
+ });
314
+ return true;
315
+ }
316
+
317
+ case Command.PREFETCH_EDGES: {
318
+ const torrent = await requireTorrent(params.sourceKey);
319
+ return pool.prefetchFileEdges(torrent, params.fileIndex, params.options ?? {});
320
+ }
321
+
322
+ case Command.READ_RANGE: {
323
+ // Streams its own reply; the caller's promise resolves once the body has
324
+ // been fully sent, which is what lets the client await completion.
325
+ await streamRange({
326
+ id,
327
+ sourceKey: params.sourceKey,
328
+ fileIndex: params.fileIndex,
329
+ start: params.start ?? null,
330
+ end: params.end ?? null,
331
+ windowBytes: params.windowBytes
332
+ });
333
+ return true;
334
+ }
335
+
336
+ case Command.CANCEL_READ: {
337
+ readsById.get(params.readId)?.cancel();
338
+ // A cancelled read will never have its outstanding fragment confirmed, so
339
+ // wake it here — otherwise it waits forever with a piece pinned.
340
+ settleFragment(params.readId);
341
+ return true;
342
+ }
343
+
344
+ case Command.DESTROY_ALL: {
345
+ fileClaims.closeAll();
346
+ torrentsByKey.clear();
347
+ await pool.destroyAll();
348
+ return true;
349
+ }
350
+
351
+ default:
352
+ throw new Error(`Unknown torrent-worker command: ${command}`);
353
+ }
354
+ }
355
+
356
+ parentPort.on("message", async (message) => {
357
+ // Chunk acknowledgements are not commands — they release backpressure on an
358
+ // in-flight read.
359
+ if (message?.type === Event.CHUNK_ACK) {
360
+ readsById.get(message.id)?.ack();
361
+ return;
362
+ }
363
+
364
+ // The main thread has finished reading a fragment out of shared memory, so
365
+ // its piece may be unpinned and the read may continue.
366
+ if (message?.type === Event.FRAGMENT_DONE) {
367
+ settleFragment(message.id);
368
+ return;
369
+ }
370
+
371
+ const { command, id, params } = message ?? {};
372
+ try {
373
+ const result = await runCommand(command, params ?? {}, id);
374
+ parentPort.postMessage({ type: Event.RESULT, id, result });
375
+ } catch (error) {
376
+ parentPort.postMessage({ type: Event.ERROR, id, error: error?.message ?? String(error) });
377
+ }
378
+ });
379
+
380
+ /**
381
+ * How often the piece store reports what it has been doing.
382
+ *
383
+ * The store decides whether a read costs nothing or costs a disk trip, and
384
+ * until 2.9.75 nothing about it reached the log — a field oddity would have had
385
+ * no evidence to work from. Reported only when something changed, so an idle
386
+ * proxy stays quiet.
387
+ */
388
+ const STORE_REPORT_INTERVAL_MS = 60_000;
389
+
390
+ /** Last reported figures per store, so unchanged ones stay silent. */
391
+ const lastReported = new Map();
392
+
393
+ setInterval(() => {
394
+ for (const stats of collectStoreStats()) {
395
+ const signature = `${stats.fromMemory}/${stats.fromDisk}/${stats.spills}/${stats.revivals}/${stats.blockedByPins}`;
396
+ if (lastReported.get(stats.name) === signature) {
397
+ continue;
398
+ }
399
+ lastReported.set(stats.name, signature);
400
+
401
+ const reads = stats.fromMemory + stats.fromDisk;
402
+ const fromMemoryShare = reads > 0 ? ((stats.fromMemory / reads) * 100).toFixed(1) : "—";
403
+ log(
404
+ `piece-store "${stats.name.slice(0, 40)}": resident=${stats.resident}/${stats.capacity} ` +
405
+ `pinned=${stats.pinned} spilled=${stats.spilled} reads=${reads} (${fromMemoryShare}% from memory) ` +
406
+ `spills=${stats.spills} revivals=${stats.revivals}` +
407
+ (stats.blockedByPins > 0 ? ` blocked-by-pins=${stats.blockedByPins}` : "")
408
+ );
409
+ }
410
+ }, STORE_REPORT_INTERVAL_MS).unref();
411
+
412
+ log("torrent worker started");