@torrent-tv/proxy 2.9.70 → 2.9.72
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 +10 -0
- package/package.json +1 -1
- package/server.js +8 -2
- package/services/torrent-worker/channel.js +222 -0
- package/services/torrent-worker/client.js +264 -0
- package/services/torrent-worker/pool-adapter.js +179 -0
- package/services/torrent-worker/protocol.js +103 -0
- package/services/torrent-worker/worker.js +265 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 2.9.72
|
|
2
|
+
|
|
3
|
+
- **Fix**: Playback broke entirely after the torrent moved to its own thread (2.9.71): the torrent was deleted **with its downloaded data** while still being read, after which every read hung and ffmpeg saw an empty input (`Stream ends prematurely at 0, should be 3303133078`), and the container-index read took 73 s to return nothing. Cause: `acquireFile` was dispatched without awaiting while its release was sent normally, so a release could overtake the acquire it belonged to; the reader count then hit zero mid-read and the idle sweep fired (`removed idle torrent ... and its store`). Two fixes, each sufficient alone: the release is now chained onto the acquire so it can never arrive first, and the worker additionally holds the file for the whole duration of the read — a claim that lives inside the read and so cannot be reordered against it. Not reproducible locally, where the test torrent was fully downloaded and never went idle.
|
|
4
|
+
|
|
5
|
+
## 2.9.71
|
|
6
|
+
|
|
7
|
+
- **New**: The torrent client now runs on its own thread (`services/torrent-worker/`). Profiling a live seek (2026-08-02) found the main thread ~85% occupied by WebTorrent — buffer concatenation in `uint8-util` ~15%, `_updateWire` and its wrapper ~9%, garbage collection ~5%, and **no piece hashing at all**, which had been the standing assumption — while three of four cores idled. Serving a segment shared that thread, so reading an already-finished 10 MB file off SSD took **12-23 s** where handing it to the channel took 125 ms. Measured after the split, through the real `/stream` route: **3 MB in 0.05-0.12 s** (~500 Mbps), roughly a hundredfold improvement, with main-thread event-loop delay down from 300-390 ms to **28-38 ms**.
|
|
8
|
+
- **Chore**: The transport was chosen by measurement, not preference. A 10 MB body costs 37 ms structured-cloned, **104 ms through a transferable `ReadableStream`** (the obvious standard answer, and 22x worse), and **4.8-5.3 ms** transferring ownership of 1 MB chunks behind an ordinary `ReadableStream` wrapper — standard interface outside, ownership transfer inside, which is what shipped. Chunk size follows the same arithmetic: at ~100 µs per round trip, 64 KB chunks would spend 13 ms per segment on overhead versus ~1 ms at 1 MB. Backpressure caps chunks in flight so a fast disk cannot rebuild in the message queue the memory the transfers save.
|
|
9
|
+
- **Chore**: `WorkerTorrentPool` presents `TorrentPool`'s existing interface, so the switch is one line in `server.js` and none of the twelve call sites across the stream route, subtitle route, playback planner and health report changed. Torrent objects cannot cross a thread, so the worker keys them by `sourceKey` and hands back a stand-in exposing the `files[i].createReadStream()` shape callers already use.
|
|
10
|
+
|
|
1
11
|
## 2.9.70
|
|
2
12
|
|
|
3
13
|
- **Chore**: Instrumentation to settle where a slow transfer actually loses its time, instead of arguing about it. Every data-channel body transfer now reports the split — `readMs` (reading the body from the local route), `chanMs` (handing chunks to the channel), `drainMs` (waiting for the channel queue) — plus `rate` and, decisively, the **event-loop delay** over the same window (`loopMean`/`loopP99`/`loopMax`, via `perf_hooks.monitorEventLoopDelay`). Synchronous work blocking the loop looks exactly like a slow network from the outside; these figures tell them apart. Prompted by a field seek where a 9.4 MB segment took 16.5 s to deliver with the channel queue **empty the whole time** (`maxBuffered=0`) while the encoder ran at 14x realtime and the file was already on disk — so none of encoder, torrent or channel capacity explained it, and no measurement existed that could. New `utils/perf.js` (`OperationTimer`, `eventLoopDelay`); deeper tools (`--trace-events-enabled`, `--cpu-prof`) remain for when these point somewhere specific.
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -29,7 +29,7 @@ import { handleApiTranscodeSessionSeekPost } from "./routes/api/transcode-sessio
|
|
|
29
29
|
import { handleStreamGet } from "./routes/stream/get.js";
|
|
30
30
|
import { handleTranscodeSessionFileGet } from "./routes/transcode/session-file/get.js";
|
|
31
31
|
import { createSourceRegistry } from "./store/source-registry.js";
|
|
32
|
-
import {
|
|
32
|
+
import { WorkerTorrentPool } from "./services/torrent-worker/pool-adapter.js";
|
|
33
33
|
import { HlsSessionManager } from "./services/hls-session-manager.js";
|
|
34
34
|
import { createPlaybackPlanner } from "./services/playback-planner.js";
|
|
35
35
|
import { detectVideoEncoder, benchmarkSoftwarePresets, detectTonemapSupport } from "./services/hwaccel.js";
|
|
@@ -98,7 +98,13 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
98
98
|
});
|
|
99
99
|
|
|
100
100
|
const sourceRegistry = createSourceRegistry(200);
|
|
101
|
-
|
|
101
|
+
// The torrent runs on its own thread. Profiling a live seek (2026-08-02)
|
|
102
|
+
// found the main thread ~85% occupied by WebTorrent — buffer concatenation
|
|
103
|
+
// ~15%, wire updates ~9%, garbage collection ~5% — while three of four cores
|
|
104
|
+
// idled. Serving a segment shared that thread, so reading an already-finished
|
|
105
|
+
// 10 MB file took 12-23 s against 125 ms to hand it to the channel. The
|
|
106
|
+
// adapter keeps TorrentPool's interface, so nothing downstream changed.
|
|
107
|
+
const torrentPool = new WorkerTorrentPool({ maxDiskBytes });
|
|
102
108
|
const selectedPort = await getPort({
|
|
103
109
|
port: buildPortCandidates(port)
|
|
104
110
|
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The transport both sides of the torrent worker share.
|
|
3
|
+
*
|
|
4
|
+
* One place holds the request/reply bookkeeping and the streaming rules, so the
|
|
5
|
+
* worker and its main-thread client cannot drift apart on the details that
|
|
6
|
+
* matter: which side transfers, which side acknowledges, and when a stream is
|
|
7
|
+
* allowed to keep going.
|
|
8
|
+
*
|
|
9
|
+
* See `protocol.js` for why the design is what it is — every number in it came
|
|
10
|
+
* from a measurement, not a preference.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { Event, STREAM_HIGH_WATER_CHUNKS } from "./protocol.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Issue request ids that stay unique for the life of a thread.
|
|
17
|
+
*
|
|
18
|
+
* @returns {() => number}
|
|
19
|
+
*/
|
|
20
|
+
export function createRequestIds() {
|
|
21
|
+
let next = 0;
|
|
22
|
+
return () => (next += 1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Wrap a port's messages into request/reply calls.
|
|
27
|
+
*
|
|
28
|
+
* Callers get promises; the plumbing of matching replies to requests, and of
|
|
29
|
+
* turning a worker-side failure back into a rejection, lives here. `Error`
|
|
30
|
+
* objects do not survive a thread boundary, so failures cross as messages and
|
|
31
|
+
* are rebuilt into `Error`s on arrival — a caller sees an ordinary rejection.
|
|
32
|
+
*
|
|
33
|
+
* @param {import("node:worker_threads").MessagePort | import("node:worker_threads").Worker} port
|
|
34
|
+
* @returns {{
|
|
35
|
+
* call: (command: string, params?: object) => Promise<unknown>,
|
|
36
|
+
* handleReply: (message: object) => boolean,
|
|
37
|
+
* rejectAll: (reason: Error) => void
|
|
38
|
+
* }}
|
|
39
|
+
*/
|
|
40
|
+
export function createCaller(port) {
|
|
41
|
+
const nextId = createRequestIds();
|
|
42
|
+
const pending = new Map();
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
call(command, params = {}) {
|
|
46
|
+
return new Promise((resolve, reject) => {
|
|
47
|
+
const id = nextId();
|
|
48
|
+
pending.set(id, { resolve, reject });
|
|
49
|
+
port.postMessage({ command, id, params });
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Feed a message in; returns true when it was a reply this caller owned.
|
|
55
|
+
*/
|
|
56
|
+
handleReply(message) {
|
|
57
|
+
const entry = pending.get(message?.id);
|
|
58
|
+
if (!entry) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
if (message.type === Event.RESULT) {
|
|
62
|
+
pending.delete(message.id);
|
|
63
|
+
entry.resolve(message.result);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (message.type === Event.ERROR) {
|
|
67
|
+
pending.delete(message.id);
|
|
68
|
+
entry.reject(new Error(message.error ?? "Torrent worker request failed."));
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fail everything outstanding — the worker died, or is being shut down.
|
|
76
|
+
*/
|
|
77
|
+
rejectAll(reason) {
|
|
78
|
+
for (const [, entry] of pending) {
|
|
79
|
+
entry.reject(reason);
|
|
80
|
+
}
|
|
81
|
+
pending.clear();
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Receive a chunked body as an ordinary `ReadableStream`.
|
|
88
|
+
*
|
|
89
|
+
* This is the half that makes the design pay off: chunks arrive as transferred
|
|
90
|
+
* buffers (no copying), and are handed on through a standard stream, so callers
|
|
91
|
+
* treat it exactly like any other body. Each chunk is acknowledged as it is
|
|
92
|
+
* enqueued, which is what lets the worker keep only
|
|
93
|
+
* {@link STREAM_HIGH_WATER_CHUNKS} in flight.
|
|
94
|
+
*
|
|
95
|
+
* Cancelling the stream — a viewer navigating away, a superseded seek — sends
|
|
96
|
+
* the cancel command, so the worker stops reading rather than filling a queue
|
|
97
|
+
* nobody will drain.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} params
|
|
100
|
+
* @param {import("node:worker_threads").Worker} params.port
|
|
101
|
+
* @param {number} params.requestId
|
|
102
|
+
* @param {() => void} params.onCancel - Sends CANCEL_READ for this request.
|
|
103
|
+
* @returns {{ stream: ReadableStream<Uint8Array>, push: (bytes: Uint8Array) => void, close: () => void, fail: (error: Error) => void }}
|
|
104
|
+
*/
|
|
105
|
+
export function createReceiveStream({ port, requestId, onCancel }) {
|
|
106
|
+
let controller = null;
|
|
107
|
+
let finished = false;
|
|
108
|
+
|
|
109
|
+
const stream = new ReadableStream({
|
|
110
|
+
start(streamController) {
|
|
111
|
+
controller = streamController;
|
|
112
|
+
},
|
|
113
|
+
cancel() {
|
|
114
|
+
if (!finished) {
|
|
115
|
+
finished = true;
|
|
116
|
+
onCancel();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
stream,
|
|
123
|
+
|
|
124
|
+
push(bytes) {
|
|
125
|
+
if (finished || !controller) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
controller.enqueue(bytes);
|
|
129
|
+
// Acknowledge only once the data is in the stream's own queue, so the
|
|
130
|
+
// worker's in-flight count reflects what has actually been taken up.
|
|
131
|
+
port.postMessage({ type: Event.CHUNK_ACK, id: requestId });
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
close() {
|
|
135
|
+
if (finished || !controller) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
finished = true;
|
|
139
|
+
controller.close();
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
fail(error) {
|
|
143
|
+
if (finished || !controller) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
finished = true;
|
|
147
|
+
controller.error(error);
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Send a body as chunks, pausing when too many are unacknowledged.
|
|
154
|
+
*
|
|
155
|
+
* The worker side of the same arrangement. `waitForCapacity` resolves when the
|
|
156
|
+
* main thread has taken up enough of what was sent; without it a fast disk
|
|
157
|
+
* would outrun the channel and rebuild in the message queue exactly the memory
|
|
158
|
+
* the transfers were saving.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} params
|
|
161
|
+
* @param {import("node:worker_threads").MessagePort} params.port
|
|
162
|
+
* @param {number} params.requestId
|
|
163
|
+
* @returns {{ send: (bytes: Buffer) => Promise<void>, end: () => void, ack: () => void, cancel: () => void, isCancelled: () => boolean }}
|
|
164
|
+
*/
|
|
165
|
+
export function createSendStream({ port, requestId }) {
|
|
166
|
+
let inFlight = 0;
|
|
167
|
+
let cancelled = false;
|
|
168
|
+
let wake = null;
|
|
169
|
+
|
|
170
|
+
const waitForCapacity = () => {
|
|
171
|
+
if (cancelled || inFlight < STREAM_HIGH_WATER_CHUNKS) {
|
|
172
|
+
return Promise.resolve();
|
|
173
|
+
}
|
|
174
|
+
return new Promise((resolve) => {
|
|
175
|
+
wake = resolve;
|
|
176
|
+
});
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
async send(bytes) {
|
|
181
|
+
await waitForCapacity();
|
|
182
|
+
if (cancelled) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
inFlight += 1;
|
|
186
|
+
// Transfer the underlying memory rather than copying it — the whole point
|
|
187
|
+
// of the design, and the difference between 4.8 ms and 37 ms per 10 MB.
|
|
188
|
+
port.postMessage(
|
|
189
|
+
{ type: Event.CHUNK, id: requestId, bytes },
|
|
190
|
+
[bytes.buffer]
|
|
191
|
+
);
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
end() {
|
|
195
|
+
if (!cancelled) {
|
|
196
|
+
port.postMessage({ type: Event.READ_END, id: requestId });
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
ack() {
|
|
201
|
+
inFlight = Math.max(0, inFlight - 1);
|
|
202
|
+
if (wake) {
|
|
203
|
+
const resume = wake;
|
|
204
|
+
wake = null;
|
|
205
|
+
resume();
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
cancel() {
|
|
210
|
+
cancelled = true;
|
|
211
|
+
if (wake) {
|
|
212
|
+
const resume = wake;
|
|
213
|
+
wake = null;
|
|
214
|
+
resume();
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
isCancelled() {
|
|
219
|
+
return cancelled;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Main-thread face of the torrent worker.
|
|
3
|
+
*
|
|
4
|
+
* Presents the same operations the routes and session manager already use, so
|
|
5
|
+
* moving the torrent to its own thread does not ripple through calling code.
|
|
6
|
+
* The one unavoidable change is that torrents are named by `sourceKey` instead
|
|
7
|
+
* of passed around as objects — objects cannot cross a thread boundary, and
|
|
8
|
+
* pretending otherwise would mean copying them on every call.
|
|
9
|
+
*
|
|
10
|
+
* Reads come back as an ordinary `ReadableStream`, so `/stream` and the codec
|
|
11
|
+
* probe consume them exactly as they consume WebTorrent's own streams today.
|
|
12
|
+
* What that hides is the part that matters: chunks arrive as transferred
|
|
13
|
+
* buffers, never copied — 5.3 ms per 10 MB against 37 ms if cloned and 104 ms
|
|
14
|
+
* through a transferable stream (measured 2026-08-02, see `protocol.js`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Worker } from "node:worker_threads";
|
|
18
|
+
import { Readable } from "node:stream";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { logger } from "../../utils/logger.js";
|
|
21
|
+
import { createCaller, createReceiveStream } from "./channel.js";
|
|
22
|
+
import { Command, Event } from "./protocol.js";
|
|
23
|
+
|
|
24
|
+
const WORKER_URL = new URL("./worker.js", import.meta.url);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Runs the torrent client on its own thread and exposes it to the main thread.
|
|
28
|
+
*
|
|
29
|
+
* Why this exists at all: profiling during a live seek found the main thread
|
|
30
|
+
* ~85% busy with WebTorrent (buffer concatenation ~15%, wire updates ~9%,
|
|
31
|
+
* garbage collection ~5%), while three of four cores idled. Serving a segment
|
|
32
|
+
* queued behind that work, so reading a finished 10 MB file took 12-23 s where
|
|
33
|
+
* handing it to the channel took 125 ms.
|
|
34
|
+
*/
|
|
35
|
+
export class TorrentWorkerClient {
|
|
36
|
+
#worker;
|
|
37
|
+
#caller;
|
|
38
|
+
/** Receive-side handles for in-flight reads, keyed by request id. */
|
|
39
|
+
#reads = new Map();
|
|
40
|
+
/** Monotonic ids for reads, independent of the caller's own numbering. */
|
|
41
|
+
#nextReadId = 0;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {{ maxDiskBytes?: number }} [options]
|
|
45
|
+
*/
|
|
46
|
+
constructor({ maxDiskBytes } = {}) {
|
|
47
|
+
this.#worker = new Worker(fileURLToPath(WORKER_URL), {
|
|
48
|
+
workerData: { maxDiskBytes }
|
|
49
|
+
});
|
|
50
|
+
this.#caller = createCaller(this.#worker);
|
|
51
|
+
|
|
52
|
+
this.#worker.on("message", (message) => {
|
|
53
|
+
if (this.#caller.handleReply(message)) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
switch (message?.type) {
|
|
57
|
+
case Event.CHUNK: {
|
|
58
|
+
const bytes = message.bytes;
|
|
59
|
+
this.#reads.get(message.id)?.push(
|
|
60
|
+
new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.length)
|
|
61
|
+
);
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case Event.READ_END:
|
|
65
|
+
this.#reads.get(message.id)?.close();
|
|
66
|
+
this.#reads.delete(message.id);
|
|
67
|
+
break;
|
|
68
|
+
case Event.LOG:
|
|
69
|
+
logger.info(`torrent-worker: ${message.message}`);
|
|
70
|
+
break;
|
|
71
|
+
default:
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
this.#worker.on("error", (error) => {
|
|
77
|
+
logger.error(`torrent-worker crashed: ${error?.message ?? error}`);
|
|
78
|
+
// Fail everything outstanding rather than leaving callers hanging: a dead
|
|
79
|
+
// worker will never answer, and a stalled request is worse than an error
|
|
80
|
+
// the loading flow can retry.
|
|
81
|
+
const reason = new Error("Torrent worker stopped unexpectedly.");
|
|
82
|
+
this.#caller.rejectAll(reason);
|
|
83
|
+
for (const [, read] of this.#reads) {
|
|
84
|
+
read.fail(reason);
|
|
85
|
+
}
|
|
86
|
+
this.#reads.clear();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Add (or join) a torrent and register it under `sourceKey`.
|
|
92
|
+
*
|
|
93
|
+
* @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
|
|
94
|
+
* @returns {Promise<{ infoHash: string, name: string, files: { index: number, name: string, path: string, length: number }[] }>}
|
|
95
|
+
*/
|
|
96
|
+
async addSource({ sourceKey, sourceType, source }) {
|
|
97
|
+
return this.#caller.call(Command.ADD_SOURCE, { sourceKey, sourceType, source });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The torrent's files, as plain data.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} sourceKey
|
|
104
|
+
* @returns {Promise<{ index: number, name: string, path: string, length: number }[]>}
|
|
105
|
+
*/
|
|
106
|
+
async listFiles(sourceKey) {
|
|
107
|
+
return this.#caller.call(Command.LIST_FILES, { sourceKey });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Claim a file so it is not evicted while being read.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} sourceKey
|
|
114
|
+
* @param {number} fileIndex
|
|
115
|
+
* @returns {Promise<void>}
|
|
116
|
+
*/
|
|
117
|
+
async acquireFile(sourceKey, fileIndex) {
|
|
118
|
+
await this.#caller.call(Command.ACQUIRE_FILE, { sourceKey, fileIndex });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Drop a claim taken with {@link acquireFile}.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} sourceKey
|
|
125
|
+
* @param {number} fileIndex
|
|
126
|
+
* @returns {Promise<void>}
|
|
127
|
+
*/
|
|
128
|
+
async releaseFile(sourceKey, fileIndex) {
|
|
129
|
+
await this.#caller.call(Command.RELEASE_FILE, { sourceKey, fileIndex });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Live download figures for the progress display.
|
|
134
|
+
*
|
|
135
|
+
* @param {{ sourceKey: string, fileIndex: number, resumeAnchorByteStart?: number | null }} params
|
|
136
|
+
* @returns {Promise<object>}
|
|
137
|
+
*/
|
|
138
|
+
async getFileStats({ sourceKey, fileIndex, resumeAnchorByteStart = null }) {
|
|
139
|
+
return this.#caller.call(Command.FILE_STATS, { sourceKey, fileIndex, resumeAnchorByteStart });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Reorder piece selection around a read position (seek prioritisation).
|
|
144
|
+
*
|
|
145
|
+
* @param {{ sourceKey: string, fileIndex: number, byteStart: number, windowBytes?: number }} params
|
|
146
|
+
* @returns {Promise<void>}
|
|
147
|
+
*/
|
|
148
|
+
async prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes }) {
|
|
149
|
+
await this.#caller.call(Command.PRIORITIZE, { sourceKey, fileIndex, byteStart, windowBytes });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Pre-fetch the head and tail the codec probe needs.
|
|
154
|
+
*
|
|
155
|
+
* @param {{ sourceKey: string, fileIndex: number, headBytes?: number, tailBytes?: number, timeoutMs?: number }} params
|
|
156
|
+
* @returns {Promise<unknown>}
|
|
157
|
+
*/
|
|
158
|
+
async prefetchFileEdges({ sourceKey, fileIndex, headBytes, tailBytes, timeoutMs }) {
|
|
159
|
+
return this.#caller.call(Command.PREFETCH_EDGES, {
|
|
160
|
+
sourceKey,
|
|
161
|
+
fileIndex,
|
|
162
|
+
headBytes,
|
|
163
|
+
tailBytes,
|
|
164
|
+
timeoutMs
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Read a byte range as a stream.
|
|
170
|
+
*
|
|
171
|
+
* Returns immediately with a stream that fills as chunks arrive; cancelling it
|
|
172
|
+
* (viewer gone, seek superseded) stops the worker reading, so pieces are not
|
|
173
|
+
* fetched for a stream nobody will drain.
|
|
174
|
+
*
|
|
175
|
+
* @param {{ sourceKey: string, fileIndex: number, start?: number | null, end?: number | null }} params
|
|
176
|
+
* @returns {ReadableStream<Uint8Array>}
|
|
177
|
+
*/
|
|
178
|
+
createReadStream({ sourceKey, fileIndex, start = null, end = null }) {
|
|
179
|
+
const readId = (this.#nextReadId += 1);
|
|
180
|
+
const receive = createReceiveStream({
|
|
181
|
+
port: this.#worker,
|
|
182
|
+
requestId: readId,
|
|
183
|
+
onCancel: () => {
|
|
184
|
+
void this.#caller.call(Command.CANCEL_READ, { readId }).catch(() => undefined);
|
|
185
|
+
this.#reads.delete(readId);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
this.#reads.set(readId, receive);
|
|
189
|
+
|
|
190
|
+
// The worker replies to READ_RANGE only once the body is fully sent; a
|
|
191
|
+
// failure before that must surface on the stream, not vanish.
|
|
192
|
+
this.#worker.postMessage({
|
|
193
|
+
command: Command.READ_RANGE,
|
|
194
|
+
id: readId,
|
|
195
|
+
params: { sourceKey, fileIndex, start, end }
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
return receive.stream;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A stand-in for the WebTorrent torrent object, backed by the worker.
|
|
203
|
+
*
|
|
204
|
+
* Callers already hold a torrent and reach into `torrent.files[i]` — for the
|
|
205
|
+
* length, the name, or a read stream. Handing back an object of the same
|
|
206
|
+
* shape keeps every one of those call sites working unchanged, which matters:
|
|
207
|
+
* they are spread across the stream route, the subtitle route, the playback
|
|
208
|
+
* planner and the health report, and rewriting all of them to thread a
|
|
209
|
+
* `sourceKey` through would be a large change with nothing to show for it.
|
|
210
|
+
*
|
|
211
|
+
* Only what is actually used is provided. Anything else would be a promise we
|
|
212
|
+
* cannot keep — the real object lives on the other thread and its methods are
|
|
213
|
+
* not reachable from here.
|
|
214
|
+
*
|
|
215
|
+
* @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
|
|
216
|
+
* @returns {Promise<{ infoHash: string, name: string, sourceKey: string, files: object[] }>}
|
|
217
|
+
*/
|
|
218
|
+
async getTorrent({ sourceKey, sourceType, source }) {
|
|
219
|
+
const info = await this.addSource({ sourceKey, sourceType, source });
|
|
220
|
+
const client = this;
|
|
221
|
+
return {
|
|
222
|
+
infoHash: info.infoHash,
|
|
223
|
+
name: info.name,
|
|
224
|
+
// Carried so helpers that receive only the torrent can still name it to
|
|
225
|
+
// the worker.
|
|
226
|
+
sourceKey,
|
|
227
|
+
files: info.files.map((file) => ({
|
|
228
|
+
...file,
|
|
229
|
+
/**
|
|
230
|
+
* @param {{ start?: number, end?: number }} [options]
|
|
231
|
+
* @returns {ReadableStream<Uint8Array>}
|
|
232
|
+
*/
|
|
233
|
+
createReadStream(options = {}) {
|
|
234
|
+
// Node stream, not a web one: Fastify replies and the ffmpeg pipe
|
|
235
|
+
// both expect that shape, and every existing call site passes the
|
|
236
|
+
// result straight to one of them. `Readable.fromWeb` adds no copy —
|
|
237
|
+
// it wraps the same buffers.
|
|
238
|
+
return Readable.fromWeb(
|
|
239
|
+
client.createReadStream({
|
|
240
|
+
sourceKey,
|
|
241
|
+
fileIndex: file.index,
|
|
242
|
+
start: options.start ?? null,
|
|
243
|
+
end: options.end ?? null
|
|
244
|
+
})
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}))
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Shut the torrent client down and stop the thread.
|
|
253
|
+
*
|
|
254
|
+
* @returns {Promise<void>}
|
|
255
|
+
*/
|
|
256
|
+
async destroyAll() {
|
|
257
|
+
try {
|
|
258
|
+
await this.#caller.call(Command.DESTROY_ALL, {});
|
|
259
|
+
} catch {
|
|
260
|
+
// Already gone — termination below is what matters.
|
|
261
|
+
}
|
|
262
|
+
await this.#worker.terminate();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,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 }} [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(() => undefined);
|
|
101
|
+
let released = false;
|
|
102
|
+
return () => {
|
|
103
|
+
if (released) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
released = true;
|
|
107
|
+
void acquired.then(() => this.#client.releaseFile(sourceKey, fileIndex)).catch(() => undefined);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Live download figures for the progress display.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} torrent
|
|
115
|
+
* @param {number | null} [fileIndex]
|
|
116
|
+
* @param {{ resumeAnchorByteStart?: number | null }} [options]
|
|
117
|
+
* @returns {Promise<object | null>}
|
|
118
|
+
*/
|
|
119
|
+
async getFileStats(torrent, fileIndex = null, options = {}) {
|
|
120
|
+
const sourceKey = torrent?.sourceKey;
|
|
121
|
+
if (!sourceKey) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return this.#client.getFileStats({
|
|
125
|
+
sourceKey,
|
|
126
|
+
fileIndex,
|
|
127
|
+
resumeAnchorByteStart: options?.resumeAnchorByteStart ?? null
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Reorder piece selection around a read position.
|
|
133
|
+
*
|
|
134
|
+
* Synchronous by design — see the file header.
|
|
135
|
+
*
|
|
136
|
+
* @param {object} torrent
|
|
137
|
+
* @param {number} fileIndex
|
|
138
|
+
* @param {number} byteStart
|
|
139
|
+
* @param {number} [windowBytes]
|
|
140
|
+
* @returns {void}
|
|
141
|
+
*/
|
|
142
|
+
prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes) {
|
|
143
|
+
const sourceKey = torrent?.sourceKey;
|
|
144
|
+
if (!sourceKey) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
void this.#client
|
|
148
|
+
.prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes })
|
|
149
|
+
.catch(() => undefined);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Pre-fetch the head and tail the codec probe needs.
|
|
154
|
+
*
|
|
155
|
+
* @param {object} torrent
|
|
156
|
+
* @param {number} fileIndex
|
|
157
|
+
* @param {number} [headBytes]
|
|
158
|
+
* @param {number} [tailBytes]
|
|
159
|
+
* @param {number} [timeoutMs]
|
|
160
|
+
* @returns {Promise<unknown>}
|
|
161
|
+
*/
|
|
162
|
+
async prefetchFileEdges(torrent, fileIndex, headBytes, tailBytes, timeoutMs) {
|
|
163
|
+
const sourceKey = torrent?.sourceKey;
|
|
164
|
+
if (!sourceKey) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
return this.#client.prefetchFileEdges({ sourceKey, fileIndex, headBytes, tailBytes, timeoutMs });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Shut the torrent client down and stop the thread.
|
|
172
|
+
*
|
|
173
|
+
* @returns {Promise<void>}
|
|
174
|
+
*/
|
|
175
|
+
async destroyAll() {
|
|
176
|
+
this.#torrents.clear();
|
|
177
|
+
await this.#client.destroyAll();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Message protocol between the main thread and the torrent worker.
|
|
3
|
+
*
|
|
4
|
+
* **Why the torrent gets its own thread.** Profiling the live proxy during a
|
|
5
|
+
* seek (2026-08-02) found the main thread ~85% busy, and busy with WebTorrent:
|
|
6
|
+
* buffer concatenation in `uint8-util` ~15%, `_updateWire` and its wrapper ~9%,
|
|
7
|
+
* garbage collection ~5% — and no piece hashing anywhere, which had been the
|
|
8
|
+
* standing assumption. Serving a segment shares that thread, so reading an
|
|
9
|
+
* already-finished 10 MB file off SSD took 12-23 s while handing it to the
|
|
10
|
+
* channel took 125 ms. Two unrelated jobs — one talking to fifty peers in small
|
|
11
|
+
* bursts, one owing a viewer a prompt answer — were queued behind each other
|
|
12
|
+
* for no reason but sharing a thread. Three of the four cores sat idle.
|
|
13
|
+
*
|
|
14
|
+
* **Why this shape, measured rather than assumed** (see the numbers below):
|
|
15
|
+
*
|
|
16
|
+
* | approach | 10 MB |
|
|
17
|
+
* |--------------------------------------------|---------|
|
|
18
|
+
* | structured clone (copying) | 37 ms |
|
|
19
|
+
* | transferable `ReadableStream` (the standard)| 104 ms |
|
|
20
|
+
* | **this: transfer inside a stream wrapper** | **4.8 ms** |
|
|
21
|
+
* | one whole buffer, no chunking | 0.49 ms |
|
|
22
|
+
*
|
|
23
|
+
* The standard transferable stream is the obvious choice and the wrong one: it
|
|
24
|
+
* negotiates every chunk across the boundary and costs 22x this design. Copying
|
|
25
|
+
* is worse still. So the worker transfers ownership of large buffers, and the
|
|
26
|
+
* main thread wraps the arriving buffers in an ordinary `ReadableStream` —
|
|
27
|
+
* standard interface outside, ownership transfer inside. Callers cannot tell
|
|
28
|
+
* the difference; the cost is a tenth of a percent of a segment's playing time.
|
|
29
|
+
*
|
|
30
|
+
* Chunk size follows from the same measurements: a round trip costs ~100 µs, so
|
|
31
|
+
* 64 KB chunks would spend 13 ms per segment on overhead against 0.5 ms sent
|
|
32
|
+
* whole. {@link STREAM_CHUNK_BYTES} of 1 MB puts a 10 MB segment at ten
|
|
33
|
+
* messages — about 1 ms — while still allowing a read to be cancelled promptly
|
|
34
|
+
* and keeping peak memory bounded.
|
|
35
|
+
*
|
|
36
|
+
* Torrent objects cannot cross a thread boundary, so the main thread names them
|
|
37
|
+
* by `sourceKey` (the identifier the registry already uses) and the worker owns
|
|
38
|
+
* the objects.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Commands sent main thread → worker.
|
|
43
|
+
*
|
|
44
|
+
* @readonly
|
|
45
|
+
* @enum {string}
|
|
46
|
+
*/
|
|
47
|
+
export const Command = {
|
|
48
|
+
/** Add (or join) a torrent; resolves when metadata is ready. */
|
|
49
|
+
ADD_SOURCE: "add-source",
|
|
50
|
+
/** Claim a file for reading, so it is not evicted while in use. */
|
|
51
|
+
ACQUIRE_FILE: "acquire-file",
|
|
52
|
+
/** Drop a claim; the worker applies its own idle-removal policy. */
|
|
53
|
+
RELEASE_FILE: "release-file",
|
|
54
|
+
/** File list and metadata for a source. */
|
|
55
|
+
LIST_FILES: "list-files",
|
|
56
|
+
/** Live download figures for the progress display. */
|
|
57
|
+
FILE_STATS: "file-stats",
|
|
58
|
+
/** Reorder piece selection around a read position (seek prioritisation). */
|
|
59
|
+
PRIORITIZE: "prioritize",
|
|
60
|
+
/** Read a byte range; the body arrives as CHUNK messages. */
|
|
61
|
+
READ_RANGE: "read-range",
|
|
62
|
+
/** Abandon an in-flight READ_RANGE (viewer gone, seek superseded). */
|
|
63
|
+
CANCEL_READ: "cancel-read",
|
|
64
|
+
/** Pre-fetch the head and tail a codec probe needs. */
|
|
65
|
+
PREFETCH_EDGES: "prefetch-edges",
|
|
66
|
+
/** Shut the client down, optionally deleting downloaded data. */
|
|
67
|
+
DESTROY_ALL: "destroy-all"
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Messages sent worker → main thread.
|
|
72
|
+
*
|
|
73
|
+
* @readonly
|
|
74
|
+
* @enum {string}
|
|
75
|
+
*/
|
|
76
|
+
export const Event = {
|
|
77
|
+
/** A command completed; carries its result. */
|
|
78
|
+
RESULT: "result",
|
|
79
|
+
/** A command failed; carries a message (`Error`s do not survive the boundary). */
|
|
80
|
+
ERROR: "error",
|
|
81
|
+
/** One piece of a READ_RANGE body; its bytes are transferred, never copied. */
|
|
82
|
+
CHUNK: "chunk",
|
|
83
|
+
/** A READ_RANGE ended; no further CHUNKs bear that request id. */
|
|
84
|
+
READ_END: "read-end",
|
|
85
|
+
/** The main thread consumed a chunk — see {@link STREAM_HIGH_WATER_CHUNKS}. */
|
|
86
|
+
CHUNK_ACK: "chunk-ack",
|
|
87
|
+
/** A log line, so worker output reaches the same place as everything else. */
|
|
88
|
+
LOG: "log"
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Bytes per CHUNK message. See the file header for why 1 MB.
|
|
93
|
+
*/
|
|
94
|
+
export const STREAM_CHUNK_BYTES = 1024 * 1024;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* How many chunks may be in flight before the worker waits for an acknowledgement.
|
|
98
|
+
*
|
|
99
|
+
* Unbounded sending would let a fast disk outrun the channel and rebuild, in the
|
|
100
|
+
* message queue, exactly the memory the transfers were saving. Two in flight
|
|
101
|
+
* keeps the pipe full without letting it grow.
|
|
102
|
+
*/
|
|
103
|
+
export const STREAM_HIGH_WATER_CHUNKS = 2;
|
|
@@ -0,0 +1,265 @@
|
|
|
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
|
+
import { parentPort, workerData } from "node:worker_threads";
|
|
21
|
+
import { TorrentPool } from "../torrent-pool.js";
|
|
22
|
+
import { createSendStream } from "./channel.js";
|
|
23
|
+
import { Command, Event, STREAM_CHUNK_BYTES } from "./protocol.js";
|
|
24
|
+
|
|
25
|
+
const pool = new TorrentPool({ maxDiskBytes: workerData?.maxDiskBytes });
|
|
26
|
+
|
|
27
|
+
/** Torrents by sourceKey — the main thread names them, this thread owns them. */
|
|
28
|
+
const torrentsByKey = new Map();
|
|
29
|
+
/** File-claim release callbacks, keyed `${sourceKey}:${fileIndex}`. */
|
|
30
|
+
const releaseByClaim = new Map();
|
|
31
|
+
/** In-flight reads, so a cancel can stop one mid-body. */
|
|
32
|
+
const readsById = new Map();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Forward a log line to the main thread, so worker output is not lost or
|
|
36
|
+
* interleaved separately from everything else.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} message
|
|
39
|
+
* @returns {void}
|
|
40
|
+
*/
|
|
41
|
+
function log(message) {
|
|
42
|
+
parentPort.postMessage({ type: Event.LOG, message });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The torrent for a sourceKey, or throw a message the caller can surface.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} sourceKey
|
|
49
|
+
* @returns {import("webtorrent").Torrent}
|
|
50
|
+
*/
|
|
51
|
+
function requireTorrent(sourceKey) {
|
|
52
|
+
const torrent = torrentsByKey.get(sourceKey);
|
|
53
|
+
if (!torrent) {
|
|
54
|
+
throw new Error(`Unknown source ${sourceKey}.`);
|
|
55
|
+
}
|
|
56
|
+
return torrent;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Stream a byte range back as CHUNK messages.
|
|
61
|
+
*
|
|
62
|
+
* Reads through WebTorrent's own read stream — which serves already-downloaded
|
|
63
|
+
* pieces from disk and waits for the rest — and forwards it in
|
|
64
|
+
* {@link STREAM_CHUNK_BYTES} pieces, transferring ownership of each so nothing
|
|
65
|
+
* is copied across the boundary. `createSendStream` applies the backpressure,
|
|
66
|
+
* so a fast disk cannot outrun the main thread and rebuild the queue in memory.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} params
|
|
69
|
+
* @param {number} params.id - Request id; CHUNK/READ_END carry it.
|
|
70
|
+
* @param {string} params.sourceKey
|
|
71
|
+
* @param {number} params.fileIndex
|
|
72
|
+
* @param {number | null} params.start - Inclusive, or null for the whole file.
|
|
73
|
+
* @param {number | null} params.end - Inclusive.
|
|
74
|
+
* @returns {Promise<void>}
|
|
75
|
+
*/
|
|
76
|
+
async function streamRange({ id, sourceKey, fileIndex, start, end }) {
|
|
77
|
+
const torrent = requireTorrent(sourceKey);
|
|
78
|
+
const file = torrent.files?.[fileIndex];
|
|
79
|
+
if (!file) {
|
|
80
|
+
throw new Error(`File ${fileIndex} not found in ${sourceKey}.`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const sender = createSendStream({ port: parentPort, requestId: id });
|
|
84
|
+
readsById.set(id, sender);
|
|
85
|
+
|
|
86
|
+
// Hold the file for as long as this read runs. The caller also acquires it,
|
|
87
|
+
// but that acquire and its release are separate messages from another thread
|
|
88
|
+
// and can be reordered; this one cannot, because it lives entirely inside the
|
|
89
|
+
// read. Without it the idle sweep saw a zero reader count and removed the
|
|
90
|
+
// torrent AND its store mid-read — field 2026-08-02: "removed idle torrent
|
|
91
|
+
// ... and its store", after which every subsequent read hung and ffmpeg got
|
|
92
|
+
// an empty input.
|
|
93
|
+
const releaseRead = pool.acquireFile(torrent, fileIndex);
|
|
94
|
+
|
|
95
|
+
const options = start === null || start === undefined ? {} : { start, end };
|
|
96
|
+
const source = file.createReadStream(options);
|
|
97
|
+
|
|
98
|
+
// Coalesce WebTorrent's own chunking (piece-sized, often much smaller) up to
|
|
99
|
+
// our chunk size: a round trip costs ~100 µs, so sending its native pieces
|
|
100
|
+
// straight through would multiply the crossings for no benefit.
|
|
101
|
+
let pendingParts = [];
|
|
102
|
+
let pendingBytes = 0;
|
|
103
|
+
|
|
104
|
+
const flush = async () => {
|
|
105
|
+
if (pendingBytes === 0) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const merged = pendingParts.length === 1 ? pendingParts[0] : Buffer.concat(pendingParts, pendingBytes);
|
|
109
|
+
pendingParts = [];
|
|
110
|
+
pendingBytes = 0;
|
|
111
|
+
await sender.send(merged);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
for await (const part of source) {
|
|
116
|
+
if (sender.isCancelled()) {
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
pendingParts.push(part);
|
|
120
|
+
pendingBytes += part.length;
|
|
121
|
+
if (pendingBytes >= STREAM_CHUNK_BYTES) {
|
|
122
|
+
await flush();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!sender.isCancelled()) {
|
|
126
|
+
await flush();
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
readsById.delete(id);
|
|
130
|
+
releaseRead();
|
|
131
|
+
sender.end();
|
|
132
|
+
// A cancelled read must stop the underlying torrent stream too, or the
|
|
133
|
+
// pieces keep being fetched for a viewer who has gone.
|
|
134
|
+
if (typeof source.destroy === "function") {
|
|
135
|
+
source.destroy();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Run one command and return its result.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} command
|
|
144
|
+
* @param {object} params
|
|
145
|
+
* @param {number} id
|
|
146
|
+
* @returns {Promise<unknown>}
|
|
147
|
+
*/
|
|
148
|
+
async function runCommand(command, params, id) {
|
|
149
|
+
switch (command) {
|
|
150
|
+
case Command.ADD_SOURCE: {
|
|
151
|
+
const torrent = await pool.getTorrent(params.sourceType, params.source);
|
|
152
|
+
torrentsByKey.set(params.sourceKey, torrent);
|
|
153
|
+
return {
|
|
154
|
+
infoHash: torrent.infoHash,
|
|
155
|
+
name: torrent.name,
|
|
156
|
+
// Files cross as plain data; the objects stay here.
|
|
157
|
+
files: (torrent.files ?? []).map((file, index) => ({
|
|
158
|
+
index,
|
|
159
|
+
name: file.name,
|
|
160
|
+
path: file.path,
|
|
161
|
+
length: file.length
|
|
162
|
+
}))
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case Command.LIST_FILES: {
|
|
167
|
+
const torrent = requireTorrent(params.sourceKey);
|
|
168
|
+
return (torrent.files ?? []).map((file, index) => ({
|
|
169
|
+
index,
|
|
170
|
+
name: file.name,
|
|
171
|
+
path: file.path,
|
|
172
|
+
length: file.length
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
case Command.ACQUIRE_FILE: {
|
|
177
|
+
const torrent = requireTorrent(params.sourceKey);
|
|
178
|
+
const claimKey = `${params.sourceKey}:${params.fileIndex}`;
|
|
179
|
+
// One claim per key; a second acquire without release would leak the
|
|
180
|
+
// first release callback and pin the file forever.
|
|
181
|
+
if (!releaseByClaim.has(claimKey)) {
|
|
182
|
+
releaseByClaim.set(claimKey, pool.acquireFile(torrent, params.fileIndex));
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case Command.RELEASE_FILE: {
|
|
188
|
+
const claimKey = `${params.sourceKey}:${params.fileIndex}`;
|
|
189
|
+
const release = releaseByClaim.get(claimKey);
|
|
190
|
+
if (release) {
|
|
191
|
+
releaseByClaim.delete(claimKey);
|
|
192
|
+
release();
|
|
193
|
+
}
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
case Command.FILE_STATS: {
|
|
198
|
+
const torrent = requireTorrent(params.sourceKey);
|
|
199
|
+
return pool.getFileStats(torrent, params.fileIndex, {
|
|
200
|
+
resumeAnchorByteStart: params.resumeAnchorByteStart ?? null
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
case Command.PRIORITIZE: {
|
|
205
|
+
const torrent = requireTorrent(params.sourceKey);
|
|
206
|
+
pool.prioritizeByteRange(torrent, params.fileIndex, params.byteStart, params.windowBytes);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
case Command.PREFETCH_EDGES: {
|
|
211
|
+
const torrent = requireTorrent(params.sourceKey);
|
|
212
|
+
return pool.prefetchFileEdges(torrent, params.fileIndex, params.headBytes, params.tailBytes, params.timeoutMs);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
case Command.READ_RANGE: {
|
|
216
|
+
// Streams its own reply; the caller's promise resolves once the body has
|
|
217
|
+
// been fully sent, which is what lets the client await completion.
|
|
218
|
+
await streamRange({
|
|
219
|
+
id,
|
|
220
|
+
sourceKey: params.sourceKey,
|
|
221
|
+
fileIndex: params.fileIndex,
|
|
222
|
+
start: params.start ?? null,
|
|
223
|
+
end: params.end ?? null
|
|
224
|
+
});
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
case Command.CANCEL_READ: {
|
|
229
|
+
readsById.get(params.readId)?.cancel();
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
case Command.DESTROY_ALL: {
|
|
234
|
+
for (const [, release] of releaseByClaim) {
|
|
235
|
+
release();
|
|
236
|
+
}
|
|
237
|
+
releaseByClaim.clear();
|
|
238
|
+
torrentsByKey.clear();
|
|
239
|
+
await pool.destroyAll();
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
default:
|
|
244
|
+
throw new Error(`Unknown torrent-worker command: ${command}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
parentPort.on("message", async (message) => {
|
|
249
|
+
// Chunk acknowledgements are not commands — they release backpressure on an
|
|
250
|
+
// in-flight read.
|
|
251
|
+
if (message?.type === Event.CHUNK_ACK) {
|
|
252
|
+
readsById.get(message.id)?.ack();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const { command, id, params } = message ?? {};
|
|
257
|
+
try {
|
|
258
|
+
const result = await runCommand(command, params ?? {}, id);
|
|
259
|
+
parentPort.postMessage({ type: Event.RESULT, id, result });
|
|
260
|
+
} catch (error) {
|
|
261
|
+
parentPort.postMessage({ type: Event.ERROR, id, error: error?.message ?? String(error) });
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
log("torrent worker started");
|