@torrent-tv/proxy 2.9.71 → 2.9.73
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 +9 -0
- package/package.json +1 -1
- package/routes/api/sources/stats/get.js +69 -66
- package/server.js +3 -1
- package/services/torrent-worker/channel.js +234 -222
- package/services/torrent-worker/pool-adapter.js +179 -171
- package/services/torrent-worker/worker.js +265 -255
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 2.9.73
|
|
2
|
+
|
|
3
|
+
- **Fix**: File stats came back as `{}` after the torrent moved to its own thread (2.9.71), which left the loading screen with no peers, no speed and no progress. Two call sites — the stats route and the health report — invoked `getFileStats` **without awaiting**: it used to answer locally and immediately, and now crosses a thread boundary, so the reply was the pending promise itself, serialised to an empty object. Both now await it.
|
|
4
|
+
- **Chore**: Chunk transfer no longer hands over memory the chunk does not own outright. Node allocates small buffers from a shared 8 KB pool — several unrelated buffers occupy one region, each viewing its own slice (verified: a 1 KB buffer reports an 8192-byte region at offset 8) — so transferring that region would detach it from its neighbours. Chunks sourced from the network are small enough to be pooled while local disk reads are not, which is exactly the difference between the field host and the local test. Measured afterwards: pooled chunks cross intact, so this is a correctness guard rather than the cause of the field failure.
|
|
5
|
+
|
|
6
|
+
## 2.9.72
|
|
7
|
+
|
|
8
|
+
- **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.
|
|
9
|
+
|
|
1
10
|
## 2.9.71
|
|
2
11
|
|
|
3
12
|
- **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**.
|
package/package.json
CHANGED
|
@@ -1,66 +1,69 @@
|
|
|
1
|
-
import { logger } from "../../../../utils/logger.js";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Return download statistics for a registered torrent source.
|
|
5
|
-
*
|
|
6
|
-
* Provides peer count, transfer speeds, and per-file download progress so
|
|
7
|
-
* that the browser client can display meaningful feedback while the proxy is
|
|
8
|
-
* pre-fetching file metadata (MOOV atom / EBML headers) before codec probing.
|
|
9
|
-
*
|
|
10
|
-
* GET /api/sources/:sourceKey/stats?fileIndex=N
|
|
11
|
-
*
|
|
12
|
-
* @param {import("fastify").FastifyRequest} req
|
|
13
|
-
* @param {import("fastify").FastifyReply} reply
|
|
14
|
-
* @param {{
|
|
15
|
-
* sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
|
|
16
|
-
* torrentPool: import("../../../../services/torrent-pool.js").TorrentPool
|
|
17
|
-
* }} deps
|
|
18
|
-
* @returns {Promise<void>}
|
|
19
|
-
*/
|
|
20
|
-
export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool }) {
|
|
21
|
-
const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
22
|
-
if (!sourceKey) {
|
|
23
|
-
return reply.code(400).send({ error: "sourceKey is required." });
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const sourceRecord = sourceRegistry.get(sourceKey);
|
|
27
|
-
if (!sourceRecord) {
|
|
28
|
-
return reply.code(404).send({ error: "Source key was not found." });
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
let torrent;
|
|
32
|
-
try {
|
|
33
|
-
// getTorrent resolves immediately when the torrent is already loaded.
|
|
34
|
-
torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
|
|
35
|
-
} catch (error) {
|
|
36
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
-
return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
|
|
41
|
-
const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
|
|
42
|
-
|
|
43
|
-
// Optional: pin the resume window to a FIXED byte offset for the duration of
|
|
44
|
-
// one buffering episode (see getFileStats JSDoc) instead of the live, moving
|
|
45
|
-
// read position — otherwise "bytes needed" can jump up mid-poll as the window
|
|
46
|
-
// slides forward with playback/encoding progress.
|
|
47
|
-
const resumeAnchorRaw = typeof req.query.resumeAnchorByteStart === "string" ? req.query.resumeAnchorByteStart : "";
|
|
48
|
-
const resumeAnchorByteStart = resumeAnchorRaw !== "" && /^\d+$/.test(resumeAnchorRaw) ? Number(resumeAnchorRaw) : null;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
1
|
+
import { logger } from "../../../../utils/logger.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Return download statistics for a registered torrent source.
|
|
5
|
+
*
|
|
6
|
+
* Provides peer count, transfer speeds, and per-file download progress so
|
|
7
|
+
* that the browser client can display meaningful feedback while the proxy is
|
|
8
|
+
* pre-fetching file metadata (MOOV atom / EBML headers) before codec probing.
|
|
9
|
+
*
|
|
10
|
+
* GET /api/sources/:sourceKey/stats?fileIndex=N
|
|
11
|
+
*
|
|
12
|
+
* @param {import("fastify").FastifyRequest} req
|
|
13
|
+
* @param {import("fastify").FastifyReply} reply
|
|
14
|
+
* @param {{
|
|
15
|
+
* sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
|
|
16
|
+
* torrentPool: import("../../../../services/torrent-pool.js").TorrentPool
|
|
17
|
+
* }} deps
|
|
18
|
+
* @returns {Promise<void>}
|
|
19
|
+
*/
|
|
20
|
+
export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool }) {
|
|
21
|
+
const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
|
|
22
|
+
if (!sourceKey) {
|
|
23
|
+
return reply.code(400).send({ error: "sourceKey is required." });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const sourceRecord = sourceRegistry.get(sourceKey);
|
|
27
|
+
if (!sourceRecord) {
|
|
28
|
+
return reply.code(404).send({ error: "Source key was not found." });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let torrent;
|
|
32
|
+
try {
|
|
33
|
+
// getTorrent resolves immediately when the torrent is already loaded.
|
|
34
|
+
torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
+
return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
|
|
41
|
+
const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
|
|
42
|
+
|
|
43
|
+
// Optional: pin the resume window to a FIXED byte offset for the duration of
|
|
44
|
+
// one buffering episode (see getFileStats JSDoc) instead of the live, moving
|
|
45
|
+
// read position — otherwise "bytes needed" can jump up mid-poll as the window
|
|
46
|
+
// slides forward with playback/encoding progress.
|
|
47
|
+
const resumeAnchorRaw = typeof req.query.resumeAnchorByteStart === "string" ? req.query.resumeAnchorByteStart : "";
|
|
48
|
+
const resumeAnchorByteStart = resumeAnchorRaw !== "" && /^\d+$/.test(resumeAnchorRaw) ? Number(resumeAnchorRaw) : null;
|
|
49
|
+
|
|
50
|
+
// Awaited: with the torrent on its own thread this is a round trip, not a
|
|
51
|
+
// local lookup. Without the await the reply was the pending promise itself,
|
|
52
|
+
// which serialises to `{}` — the empty stats seen in the field 2026-08-02.
|
|
53
|
+
const stats = await torrentPool.getFileStats(torrent, fileIndex, { resumeAnchorByteStart });
|
|
54
|
+
|
|
55
|
+
// Diagnostic: surface the real swarm state per poll so a cold-start download
|
|
56
|
+
// stall (0 peers / header not advancing → playback-plan blocks on the codec
|
|
57
|
+
// probe → browser timeout) is visible in the proxy log.
|
|
58
|
+
const downKbps = (stats.downloadSpeed / 1024).toFixed(0);
|
|
59
|
+
const filePct = stats.fileProgress != null ? `${(stats.fileProgress * 100).toFixed(1)}%` : "n/a";
|
|
60
|
+
const header =
|
|
61
|
+
stats.headerBytes != null
|
|
62
|
+
? `${stats.headerDownloadedBytes}/${stats.headerBytes}B`
|
|
63
|
+
: "n/a";
|
|
64
|
+
logger.info(
|
|
65
|
+
`[stats] ${sourceKey.slice(0, 8)} peers=${stats.numPeers} down=${downKbps}KB/s file=${filePct} header=${header}`
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return reply.send(stats);
|
|
69
|
+
}
|
package/server.js
CHANGED
|
@@ -144,7 +144,9 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
144
144
|
}
|
|
145
145
|
try {
|
|
146
146
|
const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
|
|
147
|
-
|
|
147
|
+
// Awaited for the same reason as the stats route: this now crosses a
|
|
148
|
+
// thread boundary and returns a promise.
|
|
149
|
+
return await torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
|
|
148
150
|
} catch {
|
|
149
151
|
return null;
|
|
150
152
|
}
|
|
@@ -1,222 +1,234 @@
|
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
+
//
|
|
189
|
+
// But only memory this chunk owns OUTRIGHT may be transferred. Node hands
|
|
190
|
+
// out small buffers from a shared 8 KB pool: several unrelated buffers sit
|
|
191
|
+
// in one region, each viewing its own slice (verified: a 1 KB buffer
|
|
192
|
+
// reports an 8192-byte region at offset 8). Transferring that region
|
|
193
|
+
// detaches it from every other buffer living there — which is what broke
|
|
194
|
+
// reads in the field 2026-08-02: headers were produced in 325 ms and the
|
|
195
|
+
// body never arrived, because chunks from the network are small enough to
|
|
196
|
+
// be pooled while local disk reads, which is all the local test exercised,
|
|
197
|
+
// are not.
|
|
198
|
+
const ownsRegion = bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength;
|
|
199
|
+
const payload = ownsRegion ? bytes : new Uint8Array(bytes);
|
|
200
|
+
port.postMessage(
|
|
201
|
+
{ type: Event.CHUNK, id: requestId, bytes: payload },
|
|
202
|
+
[payload.buffer]
|
|
203
|
+
);
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
end() {
|
|
207
|
+
if (!cancelled) {
|
|
208
|
+
port.postMessage({ type: Event.READ_END, id: requestId });
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
ack() {
|
|
213
|
+
inFlight = Math.max(0, inFlight - 1);
|
|
214
|
+
if (wake) {
|
|
215
|
+
const resume = wake;
|
|
216
|
+
wake = null;
|
|
217
|
+
resume();
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
cancel() {
|
|
222
|
+
cancelled = true;
|
|
223
|
+
if (wake) {
|
|
224
|
+
const resume = wake;
|
|
225
|
+
wake = null;
|
|
226
|
+
resume();
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
isCancelled() {
|
|
231
|
+
return cancelled;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
}
|