@torrent-tv/proxy 2.9.77 → 2.9.79
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 +11 -0
- package/package.json +1 -1
- package/routes/stream/get.js +192 -141
- package/services/piece-store/shared-piece-store.js +72 -0
- package/services/torrent-worker/client.js +170 -0
- package/services/torrent-worker/piece-reader.js +175 -0
- package/services/torrent-worker/protocol.js +12 -0
- package/services/torrent-worker/worker.js +93 -34
- package/test/piece-reader.test.js +162 -0
- package/test/worker-channel.test.js +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## 2.9.79
|
|
2
|
+
|
|
3
|
+
- **New**: The last copy is gone from the read path. `/stream` now writes the response straight out of the torrent's shared memory and releases each piece only when the socket write reports completion — which is the one moment that is safe, because a piece released earlier can be evicted and its slot refilled while those exact bytes are still on their way out. Both halves of that were verified before being relied on: a socket accepts a view into a `SharedArrayBuffer`, and overwriting the pool from inside the write callback leaves the client's copy intact while overwriting it before the callback corrupts it silently. Measured on the same host, 24 MB of already-downloaded data read in 2 MB ranges: **298 ms against 1008 ms**, 675 Mbit/s against 200, and far steadier (265-308 ms against 641-1338). Callers that keep what they are given — the subtitle route, anything using the plain stream — still get a copy and are unaffected; a source with no shared pool falls back to the previous path.
|
|
4
|
+
- **Chore**: Reading the response body by hand is what makes the release point observable, so the route writes and ends the response itself rather than handing Fastify a stream. A client that disconnects mid-response cancels the read, so pieces stop being fetched for a viewer who has gone.
|
|
5
|
+
|
|
6
|
+
## 2.9.78
|
|
7
|
+
|
|
8
|
+
- **New**: Reads cross the thread boundary as **positions instead of bytes**. The pieces already live in a `SharedArrayBuffer`, so the torrent thread now sends an offset and a length and the main thread reads those bytes where they lie. What this removes is the copy that used to sit on the critical path — 18.84 ms per 10 MB segment on the field host, spent in the same thread that runs the torrent, at the moment a viewer is waiting for that segment. A piece is **pinned** for as long as a fragment of it is outstanding, and unpinned only once the main thread confirms it has finished reading, so eviction cannot take the memory out from under a reader; one fragment is in flight at a time, because the store guarantees only two resident pieces at its smallest budget and holding two pins while asking for a third would deadlock it. Verified against a partially downloaded 5.5 GB torrent: a range read whole matches the same range read in parts, a read spanning a piece boundary matches its two halves, and ffmpeg parses the file through this path (`matroska h264/ac3 5939 s`). The arithmetic is covered by tests, including a file that does not start on a piece boundary — the case where treating file offsets as torrent offsets returns the right number of wrong bytes.
|
|
9
|
+
- **Chore**: `SharedPieceStore` gained `reside`, which brings a piece into memory and reports where it sits without the copy `get` has to make (WebTorrent keeps what `get` returns), and `findSharedStore`, which walks WebTorrent's store wrappers to reach ours rather than assuming their number or order.
|
|
10
|
+
- **Known**: the copy is not gone from the system, only from the torrent thread — the main thread still copies each fragment out of the pool before handing it on, because nothing tells us when the socket has finished with those bytes, and releasing the piece earlier would risk serving whatever landed in the slot next. Removing that last copy needs the body write to report completion, which is a change to the stream route rather than to this transport.
|
|
11
|
+
|
|
1
12
|
## 2.9.77
|
|
2
13
|
|
|
3
14
|
- **Fix**: Anything naming a source while that source was still being added got `Unknown source` — which is false, because the source exists and is merely not ready. Adding a magnet takes as long as its metadata does, seconds to tens of seconds, and the browser polls stats and asks for a playback plan throughout that window. The worker registered the torrent only once the add had **finished**; it now registers the pending add itself, so callers wait for it. Reproduced with a magnet nobody seeds: stats, the file listing and a read all failed instantly while the add was in flight, and all three now wait. A source that was never added is still an error, and a failed add is forgotten rather than replayed to every later caller.
|
package/package.json
CHANGED
package/routes/stream/get.js
CHANGED
|
@@ -1,141 +1,192 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Byte-range aware torrent file streaming endpoint.
|
|
3
|
-
*
|
|
4
|
-
* Accepts either a `sourceKey` (registered via POST /api/sources) or a raw
|
|
5
|
-
* `sourceType` + `source` pair. Responds with HTTP 206 for range requests
|
|
6
|
-
* and HTTP 200 for full-file requests.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { parseRange } from "../../utils/parse-range.js";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Resolve source parameters from the query string.
|
|
13
|
-
* Prefers a registered `sourceKey`; falls back to inline `sourceType`+`source`.
|
|
14
|
-
*
|
|
15
|
-
* @param {import("fastify").FastifyRequest["query"]} query
|
|
16
|
-
* @param {ReturnType<import("../../store/source-registry.js").createSourceRegistry>} sourceRegistry
|
|
17
|
-
* @returns {{ sourceType: string, source: string }}
|
|
18
|
-
*/
|
|
19
|
-
function getSourceParams(query, sourceRegistry) {
|
|
20
|
-
const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey : "";
|
|
21
|
-
const sourceTypeFromQuery = typeof query.sourceType === "string" ? query.sourceType : "";
|
|
22
|
-
const sourceFromQuery = typeof query.source === "string" ? query.source : "";
|
|
23
|
-
|
|
24
|
-
const sourceRecord = sourceKey ? sourceRegistry.get(sourceKey) : null;
|
|
25
|
-
const sourceType = sourceRecord?.sourceType ?? sourceTypeFromQuery;
|
|
26
|
-
const source = sourceRecord?.source ?? sourceFromQuery;
|
|
27
|
-
return { sourceType, source };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Stream a torrent file over HTTP with byte-range support.
|
|
32
|
-
*
|
|
33
|
-
* GET /stream
|
|
34
|
-
*
|
|
35
|
-
* @param {import("fastify").FastifyRequest} req
|
|
36
|
-
* @param {import("fastify").FastifyReply} reply
|
|
37
|
-
* @param {{ sourceRegistry: ReturnType<import("../../store/source-registry.js").createSourceRegistry>, torrentPool: import("../../services/torrent-pool.js").TorrentPool }} deps
|
|
38
|
-
* @returns {Promise<void>}
|
|
39
|
-
*/
|
|
40
|
-
export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool }) {
|
|
41
|
-
const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
|
|
42
|
-
const fileIndex = Number(fileIndexRaw);
|
|
43
|
-
const { sourceType, source } = getSourceParams(req.query, sourceRegistry);
|
|
44
|
-
|
|
45
|
-
if (!sourceType || !source || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
46
|
-
return reply
|
|
47
|
-
.code(400)
|
|
48
|
-
.send({ error: "sourceKey or sourceType+source with fileIndex are required." });
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
let torrent;
|
|
52
|
-
try {
|
|
53
|
-
torrent = await torrentPool.getTorrent(sourceType, source);
|
|
54
|
-
} catch (error) {
|
|
55
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
56
|
-
return reply.code(500).send({ error: `Failed to load torrent source: ${message}` });
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const file = torrent.files[fileIndex];
|
|
60
|
-
if (!file) {
|
|
61
|
-
return reply.code(404).send({ error: "File index was not found in torrent." });
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// HEAD asks what a GET would return, not for the bytes. Fastify serves HEAD
|
|
65
|
-
// from this same handler, which used to mean a HEAD started a read of the
|
|
66
|
-
// WHOLE file: the body was discarded by Node, but the read ran on, the
|
|
67
|
-
// response never finished, and the next request on that keep-alive connection
|
|
68
|
-
// waited behind it. Measured on the field host: the keyframe-index HEAD
|
|
69
|
-
// returned headers in 23 ms and then held the connection until its 15 s
|
|
70
|
-
// timeout, which is where the 73 s transcode-session create went.
|
|
71
|
-
if (req.method === "HEAD") {
|
|
72
|
-
// Written to the raw response on purpose. Answering through `reply.send()`
|
|
73
|
-
// with no payload makes Fastify set `content-length: 0`, which is worse
|
|
74
|
-
// than useless here: the keyframe index asks for the file size with this
|
|
75
|
-
// very request and treats 0 as "no index available", silently falling back
|
|
76
|
-
// to an invented segment grid. Hijacking leaves the response to us, and
|
|
77
|
-
// Node omits the body for HEAD by itself.
|
|
78
|
-
reply.hijack();
|
|
79
|
-
reply.raw.writeHead(200, {
|
|
80
|
-
"Accept-Ranges": "bytes",
|
|
81
|
-
"Content-Type": "application/octet-stream",
|
|
82
|
-
"Content-Length": String(file.length),
|
|
83
|
-
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`
|
|
84
|
-
});
|
|
85
|
-
reply.raw.end();
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const releaseFile = torrentPool.acquireFile(torrent, fileIndex);
|
|
90
|
-
|
|
91
|
-
const range = parseRange(req.headers.range, file.length);
|
|
92
|
-
// Prioritize the pieces at this read position so a seek (a request at a new
|
|
93
|
-
// byte offset) downloads first instead of waiting behind the sequential
|
|
94
|
-
// backlog — this is what caused ~15-18 s stalls when seeking into an
|
|
95
|
-
// undownloaded region.
|
|
96
|
-
torrentPool.prioritizeByteRange(torrent, fileIndex, range ? range.start : 0);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file Byte-range aware torrent file streaming endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Accepts either a `sourceKey` (registered via POST /api/sources) or a raw
|
|
5
|
+
* `sourceType` + `source` pair. Responds with HTTP 206 for range requests
|
|
6
|
+
* and HTTP 200 for full-file requests.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parseRange } from "../../utils/parse-range.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve source parameters from the query string.
|
|
13
|
+
* Prefers a registered `sourceKey`; falls back to inline `sourceType`+`source`.
|
|
14
|
+
*
|
|
15
|
+
* @param {import("fastify").FastifyRequest["query"]} query
|
|
16
|
+
* @param {ReturnType<import("../../store/source-registry.js").createSourceRegistry>} sourceRegistry
|
|
17
|
+
* @returns {{ sourceType: string, source: string }}
|
|
18
|
+
*/
|
|
19
|
+
function getSourceParams(query, sourceRegistry) {
|
|
20
|
+
const sourceKey = typeof query.sourceKey === "string" ? query.sourceKey : "";
|
|
21
|
+
const sourceTypeFromQuery = typeof query.sourceType === "string" ? query.sourceType : "";
|
|
22
|
+
const sourceFromQuery = typeof query.source === "string" ? query.source : "";
|
|
23
|
+
|
|
24
|
+
const sourceRecord = sourceKey ? sourceRegistry.get(sourceKey) : null;
|
|
25
|
+
const sourceType = sourceRecord?.sourceType ?? sourceTypeFromQuery;
|
|
26
|
+
const source = sourceRecord?.source ?? sourceFromQuery;
|
|
27
|
+
return { sourceType, source };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Stream a torrent file over HTTP with byte-range support.
|
|
32
|
+
*
|
|
33
|
+
* GET /stream
|
|
34
|
+
*
|
|
35
|
+
* @param {import("fastify").FastifyRequest} req
|
|
36
|
+
* @param {import("fastify").FastifyReply} reply
|
|
37
|
+
* @param {{ sourceRegistry: ReturnType<import("../../store/source-registry.js").createSourceRegistry>, torrentPool: import("../../services/torrent-pool.js").TorrentPool }} deps
|
|
38
|
+
* @returns {Promise<void>}
|
|
39
|
+
*/
|
|
40
|
+
export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool }) {
|
|
41
|
+
const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
|
|
42
|
+
const fileIndex = Number(fileIndexRaw);
|
|
43
|
+
const { sourceType, source } = getSourceParams(req.query, sourceRegistry);
|
|
44
|
+
|
|
45
|
+
if (!sourceType || !source || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
46
|
+
return reply
|
|
47
|
+
.code(400)
|
|
48
|
+
.send({ error: "sourceKey or sourceType+source with fileIndex are required." });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let torrent;
|
|
52
|
+
try {
|
|
53
|
+
torrent = await torrentPool.getTorrent(sourceType, source);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
56
|
+
return reply.code(500).send({ error: `Failed to load torrent source: ${message}` });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const file = torrent.files[fileIndex];
|
|
60
|
+
if (!file) {
|
|
61
|
+
return reply.code(404).send({ error: "File index was not found in torrent." });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// HEAD asks what a GET would return, not for the bytes. Fastify serves HEAD
|
|
65
|
+
// from this same handler, which used to mean a HEAD started a read of the
|
|
66
|
+
// WHOLE file: the body was discarded by Node, but the read ran on, the
|
|
67
|
+
// response never finished, and the next request on that keep-alive connection
|
|
68
|
+
// waited behind it. Measured on the field host: the keyframe-index HEAD
|
|
69
|
+
// returned headers in 23 ms and then held the connection until its 15 s
|
|
70
|
+
// timeout, which is where the 73 s transcode-session create went.
|
|
71
|
+
if (req.method === "HEAD") {
|
|
72
|
+
// Written to the raw response on purpose. Answering through `reply.send()`
|
|
73
|
+
// with no payload makes Fastify set `content-length: 0`, which is worse
|
|
74
|
+
// than useless here: the keyframe index asks for the file size with this
|
|
75
|
+
// very request and treats 0 as "no index available", silently falling back
|
|
76
|
+
// to an invented segment grid. Hijacking leaves the response to us, and
|
|
77
|
+
// Node omits the body for HEAD by itself.
|
|
78
|
+
reply.hijack();
|
|
79
|
+
reply.raw.writeHead(200, {
|
|
80
|
+
"Accept-Ranges": "bytes",
|
|
81
|
+
"Content-Type": "application/octet-stream",
|
|
82
|
+
"Content-Length": String(file.length),
|
|
83
|
+
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`
|
|
84
|
+
});
|
|
85
|
+
reply.raw.end();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const releaseFile = torrentPool.acquireFile(torrent, fileIndex);
|
|
90
|
+
|
|
91
|
+
const range = parseRange(req.headers.range, file.length);
|
|
92
|
+
// Prioritize the pieces at this read position so a seek (a request at a new
|
|
93
|
+
// byte offset) downloads first instead of waiting behind the sequential
|
|
94
|
+
// backlog — this is what caused ~15-18 s stalls when seeking into an
|
|
95
|
+
// undownloaded region.
|
|
96
|
+
torrentPool.prioritizeByteRange(torrent, fileIndex, range ? range.start : 0);
|
|
97
|
+
|
|
98
|
+
const start = range ? range.start : 0;
|
|
99
|
+
const end = range ? range.end : file.length - 1;
|
|
100
|
+
const contentLength = end - start + 1;
|
|
101
|
+
|
|
102
|
+
// Written straight out of the torrent's shared memory when that is available:
|
|
103
|
+
// no copy on either thread, at the cost of doing the writing by hand, because
|
|
104
|
+
// only the write callback tells us when a piece may be released. Falls back to
|
|
105
|
+
// the ordinary stream for sources without a shared pool.
|
|
106
|
+
const fragments = typeof file.createFragmentReader === "function"
|
|
107
|
+
? file.createFragmentReader({ start, end })
|
|
108
|
+
: null;
|
|
109
|
+
|
|
110
|
+
if (fragments) {
|
|
111
|
+
reply.hijack();
|
|
112
|
+
reply.raw.writeHead(range ? 206 : 200, {
|
|
113
|
+
"Accept-Ranges": "bytes",
|
|
114
|
+
"Content-Type": "application/octet-stream",
|
|
115
|
+
"Content-Length": String(contentLength),
|
|
116
|
+
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`,
|
|
117
|
+
...(range ? { "Content-Range": `bytes ${start}-${end}/${file.length}` } : {})
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// A client that goes away mid-response must stop the read, or pieces keep
|
|
121
|
+
// being fetched for nobody.
|
|
122
|
+
reply.raw.once("close", () => fragments.cancel());
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
for await (const fragment of fragments) {
|
|
126
|
+
if (reply.raw.writableEnded || reply.raw.destroyed) {
|
|
127
|
+
fragment.release();
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
await new Promise((resolve, reject) => {
|
|
131
|
+
reply.raw.write(fragment.bytes, (error) => (error ? reject(error) : resolve()));
|
|
132
|
+
});
|
|
133
|
+
// Only now are these bytes gone: the piece can be unpinned, and the
|
|
134
|
+
// slot it occupies reused. Releasing before this point corrupts the
|
|
135
|
+
// response silently.
|
|
136
|
+
fragment.release();
|
|
137
|
+
}
|
|
138
|
+
reply.raw.end();
|
|
139
|
+
} catch {
|
|
140
|
+
// The body is already committed by its headers, so there is nothing
|
|
141
|
+
// useful to send instead — drop the connection and let the client retry.
|
|
142
|
+
reply.raw.destroy();
|
|
143
|
+
} finally {
|
|
144
|
+
releaseFile();
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
reply.header("Accept-Ranges", "bytes");
|
|
150
|
+
reply.header("Content-Type", "application/octet-stream");
|
|
151
|
+
reply.header("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`);
|
|
152
|
+
|
|
153
|
+
if (!range) {
|
|
154
|
+
reply.header("Content-Length", String(file.length));
|
|
155
|
+
const stream = file.createReadStream();
|
|
156
|
+
bindRelease(stream, reply, releaseFile);
|
|
157
|
+
return reply.send(stream);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
reply.code(206);
|
|
161
|
+
reply.header("Content-Length", String(contentLength));
|
|
162
|
+
reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
|
|
163
|
+
const stream = file.createReadStream({ start, end });
|
|
164
|
+
bindRelease(stream, reply, releaseFile);
|
|
165
|
+
return reply.send(stream);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Attach event listeners that release the file reference exactly once when
|
|
170
|
+
* the stream or the underlying HTTP connection closes.
|
|
171
|
+
*
|
|
172
|
+
* @param {import("node:stream").Readable} stream
|
|
173
|
+
* @param {import("fastify").FastifyReply} reply
|
|
174
|
+
* @param {() => void} release
|
|
175
|
+
* @returns {void}
|
|
176
|
+
*/
|
|
177
|
+
function bindRelease(stream, reply, release) {
|
|
178
|
+
let released = false;
|
|
179
|
+
const releaseOnce = () => {
|
|
180
|
+
if (released) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
released = true;
|
|
184
|
+
release();
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
stream.on("close", releaseOnce);
|
|
188
|
+
stream.on("end", releaseOnce);
|
|
189
|
+
stream.on("error", releaseOnce);
|
|
190
|
+
reply.raw.once("close", releaseOnce);
|
|
191
|
+
reply.raw.once("finish", releaseOnce);
|
|
192
|
+
}
|
|
@@ -51,6 +51,31 @@ export function collectStoreStats() {
|
|
|
51
51
|
return [...liveStores].map((store) => store.stats());
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* The shared store behind a torrent, or `null` if it is not one of ours.
|
|
56
|
+
*
|
|
57
|
+
* WebTorrent wraps whatever store it is given — today in `ImmediateChunkStore`,
|
|
58
|
+
* and historically in a piece cache as well — and offers no way to ask for the
|
|
59
|
+
* innermost one. Walking the `store` chain finds it regardless of how many
|
|
60
|
+
* wrappers there are or what order they sit in, which is sturdier than reaching
|
|
61
|
+
* for a fixed `torrent.store.store`.
|
|
62
|
+
*
|
|
63
|
+
* @param {{ store?: object } | null | undefined} torrent
|
|
64
|
+
* @returns {SharedPieceStore | null}
|
|
65
|
+
*/
|
|
66
|
+
export function findSharedStore(torrent) {
|
|
67
|
+
let candidate = torrent?.store;
|
|
68
|
+
// Bounded rather than `while (candidate)`: a store that referenced itself
|
|
69
|
+
// would otherwise hang the thread instead of failing.
|
|
70
|
+
for (let depth = 0; candidate && depth < 8; depth += 1) {
|
|
71
|
+
if (candidate instanceof SharedPieceStore) {
|
|
72
|
+
return candidate;
|
|
73
|
+
}
|
|
74
|
+
candidate = candidate.store;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
54
79
|
/**
|
|
55
80
|
* Ceiling for the automatic budget, and the share of free memory it will take.
|
|
56
81
|
*
|
|
@@ -389,6 +414,53 @@ export class SharedPieceStore {
|
|
|
389
414
|
fetch().then((bytes) => done(null, bytes), (error) => done(error));
|
|
390
415
|
}
|
|
391
416
|
|
|
417
|
+
/**
|
|
418
|
+
* Ensure a piece is in memory and say where it sits — without copying it.
|
|
419
|
+
*
|
|
420
|
+
* This is {@link get} minus its final copy, and it exists for exactly one
|
|
421
|
+
* caller: the reader that hands pieces to the other thread. That thread maps
|
|
422
|
+
* the same {@link sharedBuffer}, so an offset and a length are all it needs,
|
|
423
|
+
* and the bytes never move. `get` cannot serve that purpose because
|
|
424
|
+
* WebTorrent keeps what `get` returns while the slot underneath may be
|
|
425
|
+
* reused.
|
|
426
|
+
*
|
|
427
|
+
* The caller MUST hold a pin across the whole read — the returned offset
|
|
428
|
+
* stays valid only while the piece is pinned.
|
|
429
|
+
*
|
|
430
|
+
* @param {number} index
|
|
431
|
+
* @returns {Promise<{ offset: number, length: number } | null>} `null` when
|
|
432
|
+
* the store holds no such piece, in memory or on disk.
|
|
433
|
+
*/
|
|
434
|
+
async reside(index) {
|
|
435
|
+
if (this.#closed) {
|
|
436
|
+
throw new Error("Piece store is closed.");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const slot = this.#slotOf.get(index);
|
|
440
|
+
if (slot !== undefined) {
|
|
441
|
+
this.#lru.touch(index);
|
|
442
|
+
this.#counters.fromMemory += 1;
|
|
443
|
+
return this.locate(index);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (!this.#disk.has(index)) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const pieceLength = this.#lengthOf(index);
|
|
451
|
+
const revived = await this.#claimSlot();
|
|
452
|
+
const target = this.#pool.subarray(
|
|
453
|
+
revived * this.#chunkLength,
|
|
454
|
+
revived * this.#chunkLength + pieceLength
|
|
455
|
+
);
|
|
456
|
+
await this.#disk.read(index, target);
|
|
457
|
+
this.#slotOf.set(index, revived);
|
|
458
|
+
this.#lru.touch(index);
|
|
459
|
+
this.#counters.fromDisk += 1;
|
|
460
|
+
this.#counters.revivals += 1;
|
|
461
|
+
return this.locate(index);
|
|
462
|
+
}
|
|
463
|
+
|
|
392
464
|
/**
|
|
393
465
|
* Close the store, keeping the spill file.
|
|
394
466
|
*
|
|
@@ -37,6 +37,12 @@ export class TorrentWorkerClient {
|
|
|
37
37
|
#caller;
|
|
38
38
|
/** Receive-side handles for in-flight reads, keyed by request id. */
|
|
39
39
|
#reads = new Map();
|
|
40
|
+
/** Each torrent's piece pool, so a fragment can be read where it lies. */
|
|
41
|
+
#poolBySource = new Map();
|
|
42
|
+
/** Which pool an in-flight read belongs to, keyed by request id. */
|
|
43
|
+
#poolByRead = new Map();
|
|
44
|
+
/** Reads consuming fragments in place, keyed by request id. */
|
|
45
|
+
#fragmentReaders = new Map();
|
|
40
46
|
|
|
41
47
|
/**
|
|
42
48
|
* @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
|
|
@@ -60,10 +66,45 @@ export class TorrentWorkerClient {
|
|
|
60
66
|
read.fail(new Error(message.error ?? "Torrent worker read failed."));
|
|
61
67
|
return;
|
|
62
68
|
}
|
|
69
|
+
if (message?.type === Event.ERROR && this.#fragmentReaders.has(message.id)) {
|
|
70
|
+
const reader = this.#fragmentReaders.get(message.id);
|
|
71
|
+
this.#fragmentReaders.delete(message.id);
|
|
72
|
+
this.#poolByRead.delete(message.id);
|
|
73
|
+
reader.fail(new Error(message.error ?? "Torrent worker read failed."));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
63
76
|
if (this.#caller.handleReply(message)) {
|
|
64
77
|
return;
|
|
65
78
|
}
|
|
66
79
|
switch (message?.type) {
|
|
80
|
+
case Event.FRAGMENT: {
|
|
81
|
+
const pool = this.#poolByRead.get(message.id);
|
|
82
|
+
if (!pool) {
|
|
83
|
+
// No pool means no way to read the fragment; confirm it so the
|
|
84
|
+
// worker is not left waiting, and let the read end short.
|
|
85
|
+
this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
const view = new Uint8Array(pool, message.offset, message.length);
|
|
89
|
+
|
|
90
|
+
const reader = this.#fragmentReaders.get(message.id);
|
|
91
|
+
if (reader) {
|
|
92
|
+
// Handed on as a view into the pool — no copy anywhere. The piece
|
|
93
|
+
// stays pinned until the consumer says it is done with these exact
|
|
94
|
+
// bytes, which for a response body means the socket write has
|
|
95
|
+
// completed.
|
|
96
|
+
reader.push(view, () => {
|
|
97
|
+
this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
|
|
98
|
+
});
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Plain-stream consumers keep what they are given while the slot may
|
|
103
|
+
// be reused, so they get a copy and the piece is released at once.
|
|
104
|
+
this.#reads.get(message.id)?.push(Uint8Array.prototype.slice.call(view));
|
|
105
|
+
this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
67
108
|
case Event.CHUNK: {
|
|
68
109
|
const bytes = message.bytes;
|
|
69
110
|
this.#reads.get(message.id)?.push(
|
|
@@ -74,6 +115,9 @@ export class TorrentWorkerClient {
|
|
|
74
115
|
case Event.READ_END:
|
|
75
116
|
this.#reads.get(message.id)?.close();
|
|
76
117
|
this.#reads.delete(message.id);
|
|
118
|
+
this.#fragmentReaders.get(message.id)?.close();
|
|
119
|
+
this.#fragmentReaders.delete(message.id);
|
|
120
|
+
this.#poolByRead.delete(message.id);
|
|
77
121
|
break;
|
|
78
122
|
case Event.LOG:
|
|
79
123
|
logger.info(`torrent-worker: ${message.message}`);
|
|
@@ -190,9 +234,16 @@ export class TorrentWorkerClient {
|
|
|
190
234
|
onCancel: () => {
|
|
191
235
|
void this.#caller.call(Command.CANCEL_READ, { readId }).catch(() => undefined);
|
|
192
236
|
this.#reads.delete(readId);
|
|
237
|
+
this.#poolByRead.delete(readId);
|
|
193
238
|
}
|
|
194
239
|
});
|
|
195
240
|
this.#reads.set(readId, receive);
|
|
241
|
+
// Which pool this read's fragments will point into. Recorded before the
|
|
242
|
+
// command is sent, because the first fragment can arrive immediately.
|
|
243
|
+
const pool = this.#poolBySource.get(sourceKey);
|
|
244
|
+
if (pool) {
|
|
245
|
+
this.#poolByRead.set(readId, pool);
|
|
246
|
+
}
|
|
196
247
|
|
|
197
248
|
// The worker replies to READ_RANGE only once the body is fully sent; a
|
|
198
249
|
// failure before that must surface on the stream, not vanish.
|
|
@@ -205,6 +256,104 @@ export class TorrentWorkerClient {
|
|
|
205
256
|
return receive.stream;
|
|
206
257
|
}
|
|
207
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Read a byte range as fragments of shared memory, without copying.
|
|
261
|
+
*
|
|
262
|
+
* Each fragment is a view straight into the torrent's piece pool, and the
|
|
263
|
+
* piece behind it stays pinned until `release()` is called — so the consumer
|
|
264
|
+
* must call it once it is genuinely finished with those bytes. For a response
|
|
265
|
+
* body that means after the socket write has completed, not when it was
|
|
266
|
+
* queued: verified that writing a shared-memory view and then overwriting the
|
|
267
|
+
* pool from the write callback leaves the client's copy intact, and that
|
|
268
|
+
* overwriting it earlier corrupts it silently.
|
|
269
|
+
*
|
|
270
|
+
* Returns `null` when this source has no shared pool, so the caller can fall
|
|
271
|
+
* back to {@link createReadStream}.
|
|
272
|
+
*
|
|
273
|
+
* @param {{ sourceKey: string, fileIndex: number, start?: number | null, end?: number | null }} params
|
|
274
|
+
* @returns {{ [Symbol.asyncIterator]: () => AsyncGenerator<{ bytes: Uint8Array, release: () => void }>, cancel: () => void } | null}
|
|
275
|
+
*/
|
|
276
|
+
createFragmentReader({ sourceKey, fileIndex, start = null, end = null }) {
|
|
277
|
+
const pool = this.#poolBySource.get(sourceKey);
|
|
278
|
+
if (!pool) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const readId = this.#caller.nextId();
|
|
283
|
+
/** @type {{ bytes: Uint8Array, release: () => void }[]} */
|
|
284
|
+
const queue = [];
|
|
285
|
+
let wake = null;
|
|
286
|
+
let finished = false;
|
|
287
|
+
let failure = null;
|
|
288
|
+
|
|
289
|
+
const notify = () => {
|
|
290
|
+
const resume = wake;
|
|
291
|
+
wake = null;
|
|
292
|
+
resume?.();
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
this.#fragmentReaders.set(readId, {
|
|
296
|
+
push(bytes, confirm) {
|
|
297
|
+
queue.push({ bytes, release: confirm });
|
|
298
|
+
notify();
|
|
299
|
+
},
|
|
300
|
+
close() {
|
|
301
|
+
finished = true;
|
|
302
|
+
notify();
|
|
303
|
+
},
|
|
304
|
+
fail(error) {
|
|
305
|
+
failure = error;
|
|
306
|
+
finished = true;
|
|
307
|
+
notify();
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
this.#poolByRead.set(readId, pool);
|
|
311
|
+
|
|
312
|
+
const cancel = () => {
|
|
313
|
+
if (this.#fragmentReaders.delete(readId)) {
|
|
314
|
+
this.#poolByRead.delete(readId);
|
|
315
|
+
void this.#caller.call(Command.CANCEL_READ, { readId }).catch(() => undefined);
|
|
316
|
+
}
|
|
317
|
+
finished = true;
|
|
318
|
+
notify();
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
this.#worker.postMessage({
|
|
322
|
+
command: Command.READ_RANGE,
|
|
323
|
+
id: readId,
|
|
324
|
+
params: { sourceKey, fileIndex, start, end }
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
cancel,
|
|
329
|
+
async *[Symbol.asyncIterator]() {
|
|
330
|
+
try {
|
|
331
|
+
for (;;) {
|
|
332
|
+
while (queue.length > 0) {
|
|
333
|
+
yield queue.shift();
|
|
334
|
+
}
|
|
335
|
+
if (failure) {
|
|
336
|
+
throw failure;
|
|
337
|
+
}
|
|
338
|
+
if (finished) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
await new Promise((resolve) => {
|
|
342
|
+
wake = resolve;
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
} finally {
|
|
346
|
+
// Covers the consumer breaking out early — a client that hung up, a
|
|
347
|
+
// superseded seek — which must stop the read rather than leave it
|
|
348
|
+
// fetching pieces nobody will take.
|
|
349
|
+
if (!finished) {
|
|
350
|
+
cancel();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
208
357
|
/**
|
|
209
358
|
* A stand-in for the WebTorrent torrent object, backed by the worker.
|
|
210
359
|
*
|
|
@@ -224,6 +373,11 @@ export class TorrentWorkerClient {
|
|
|
224
373
|
*/
|
|
225
374
|
async getTorrent({ sourceKey, sourceType, source }) {
|
|
226
375
|
const info = await this.addSource({ sourceKey, sourceType, source });
|
|
376
|
+
// The torrent's piece pool. Both threads now hold the same memory, so a
|
|
377
|
+
// read can be answered with an offset instead of with bytes.
|
|
378
|
+
if (info.sharedBuffer) {
|
|
379
|
+
this.#poolBySource.set(sourceKey, info.sharedBuffer);
|
|
380
|
+
}
|
|
227
381
|
const client = this;
|
|
228
382
|
return {
|
|
229
383
|
infoHash: info.infoHash,
|
|
@@ -250,6 +404,22 @@ export class TorrentWorkerClient {
|
|
|
250
404
|
end: options.end ?? null
|
|
251
405
|
})
|
|
252
406
|
);
|
|
407
|
+
},
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Fragments of shared memory, for a caller that can say when it has
|
|
411
|
+
* finished with each one. `null` when this source has no shared pool.
|
|
412
|
+
*
|
|
413
|
+
* @param {{ start?: number, end?: number }} [options]
|
|
414
|
+
* @returns {ReturnType<TorrentWorkerClient["createFragmentReader"]>}
|
|
415
|
+
*/
|
|
416
|
+
createFragmentReader(options = {}) {
|
|
417
|
+
return client.createFragmentReader({
|
|
418
|
+
sourceKey,
|
|
419
|
+
fileIndex: file.index,
|
|
420
|
+
start: options.start ?? null,
|
|
421
|
+
end: options.end ?? null
|
|
422
|
+
});
|
|
253
423
|
}
|
|
254
424
|
}))
|
|
255
425
|
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Reading a byte range as positions in shared memory, not as bytes.
|
|
3
|
+
*
|
|
4
|
+
* The pieces already live in a `SharedArrayBuffer` the main thread can map. So
|
|
5
|
+
* the torrent thread does not need to hand over any bytes at all: it can say
|
|
6
|
+
* *where* a piece sits and let the other side read it there. What crosses the
|
|
7
|
+
* boundary is two numbers per piece.
|
|
8
|
+
*
|
|
9
|
+
* That is the whole point of the exercise. The alternative — copying each piece
|
|
10
|
+
* into memory we own and transferring it — costs 18.84 ms per 10 MB segment on
|
|
11
|
+
* the field host, and costs it **on the critical path**, in the thread that is
|
|
12
|
+
* also running the torrent, at the moment a viewer is waiting for that segment.
|
|
13
|
+
* Here the copy is gone entirely rather than moved.
|
|
14
|
+
*
|
|
15
|
+
* Two obligations come with it, and both are enforced rather than assumed:
|
|
16
|
+
*
|
|
17
|
+
* - a piece being read is **pinned**, so eviction cannot take the memory out
|
|
18
|
+
* from under the reader mid-read;
|
|
19
|
+
* - the pin is released only once the other thread reports it has finished
|
|
20
|
+
* with those bytes — not when they were sent, because nothing was sent.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { findSharedStore } from "../piece-store/shared-piece-store.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Wait until a piece has been downloaded and verified.
|
|
27
|
+
*
|
|
28
|
+
* WebTorrent announces this as `verified`. The bitfield is re-checked after the
|
|
29
|
+
* listener is attached because the piece can complete in between, and a missed
|
|
30
|
+
* event here would wait forever.
|
|
31
|
+
*
|
|
32
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
33
|
+
* @param {number} index
|
|
34
|
+
* @param {{ isCancelled: () => boolean }} cancellation
|
|
35
|
+
* @returns {Promise<void>}
|
|
36
|
+
*/
|
|
37
|
+
function whenPieceReady(torrent, index, cancellation) {
|
|
38
|
+
if (torrent.bitfield?.get(index)) {
|
|
39
|
+
return Promise.resolve();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
/** @param {number} verifiedIndex */
|
|
44
|
+
const onVerified = (verifiedIndex) => {
|
|
45
|
+
if (verifiedIndex === index) {
|
|
46
|
+
cleanup();
|
|
47
|
+
resolve();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const onDestroyed = () => {
|
|
51
|
+
cleanup();
|
|
52
|
+
reject(new Error(`Torrent went away while waiting for piece ${index}.`));
|
|
53
|
+
};
|
|
54
|
+
// Cancellation is polled rather than pushed: a superseded seek destroys the
|
|
55
|
+
// read, and without this the wait would outlive it and hold a pin.
|
|
56
|
+
const poll = setInterval(() => {
|
|
57
|
+
if (cancellation.isCancelled()) {
|
|
58
|
+
cleanup();
|
|
59
|
+
reject(new Error(`Read cancelled while waiting for piece ${index}.`));
|
|
60
|
+
}
|
|
61
|
+
}, 250);
|
|
62
|
+
|
|
63
|
+
function cleanup() {
|
|
64
|
+
clearInterval(poll);
|
|
65
|
+
torrent.removeListener("verified", onVerified);
|
|
66
|
+
torrent.removeListener("close", onDestroyed);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
torrent.on("verified", onVerified);
|
|
70
|
+
torrent.once("close", onDestroyed);
|
|
71
|
+
|
|
72
|
+
// The piece may have arrived between the check above and this listener.
|
|
73
|
+
if (torrent.bitfield?.get(index)) {
|
|
74
|
+
cleanup();
|
|
75
|
+
resolve();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A fragment of a read: where to find it, and how to let it go.
|
|
82
|
+
*
|
|
83
|
+
* @typedef {object} PieceFragment
|
|
84
|
+
* @property {number} pieceIndex
|
|
85
|
+
* @property {number} offset - Byte offset into the shared pool.
|
|
86
|
+
* @property {number} length
|
|
87
|
+
* @property {() => void} release - Drops this fragment's pin. Call exactly once.
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Walk a byte range of a file, yielding each piece's position in shared memory.
|
|
92
|
+
*
|
|
93
|
+
* Yields at most one fragment per piece; the first and last are usually partial.
|
|
94
|
+
* The caller must `release()` every fragment it receives, including on failure —
|
|
95
|
+
* an unreleased pin permanently costs a slot.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} params
|
|
98
|
+
* @param {import("webtorrent").Torrent} params.torrent
|
|
99
|
+
* @param {number} params.fileIndex
|
|
100
|
+
* @param {number} params.start - Inclusive, relative to the file.
|
|
101
|
+
* @param {number} params.end - Inclusive, relative to the file.
|
|
102
|
+
* @param {{ isCancelled: () => boolean }} params.cancellation
|
|
103
|
+
* @returns {AsyncGenerator<PieceFragment>}
|
|
104
|
+
*/
|
|
105
|
+
export async function* readFragments({ torrent, fileIndex, start, end, cancellation }) {
|
|
106
|
+
const store = findSharedStore(torrent);
|
|
107
|
+
if (!store) {
|
|
108
|
+
throw new Error("This torrent is not backed by a shared piece store.");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const file = torrent.files?.[fileIndex];
|
|
112
|
+
if (!file) {
|
|
113
|
+
throw new Error(`File ${fileIndex} not found.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const pieceLength = torrent.pieceLength;
|
|
117
|
+
// Piece numbers are torrent-wide, so a file's own offsets have to be lifted
|
|
118
|
+
// into the torrent's address space first.
|
|
119
|
+
const absoluteStart = file.offset + start;
|
|
120
|
+
const absoluteEnd = file.offset + end;
|
|
121
|
+
const firstPiece = Math.floor(absoluteStart / pieceLength);
|
|
122
|
+
const lastPiece = Math.floor(absoluteEnd / pieceLength);
|
|
123
|
+
|
|
124
|
+
// Ask for these pieces first. `select` puts them in the download set at all;
|
|
125
|
+
// `critical` marks them as wanted now, which is what allows a piece to be
|
|
126
|
+
// fetched out of sequential order for a reader that is waiting on it.
|
|
127
|
+
if (typeof torrent.select === "function") {
|
|
128
|
+
torrent.select(firstPiece, lastPiece, 1);
|
|
129
|
+
}
|
|
130
|
+
if (typeof torrent.critical === "function") {
|
|
131
|
+
torrent.critical(firstPiece, lastPiece);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (let pieceIndex = firstPiece; pieceIndex <= lastPiece; pieceIndex += 1) {
|
|
135
|
+
if (cancellation.isCancelled()) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const pieceStart = pieceIndex * pieceLength;
|
|
140
|
+
const fromWithinPiece = Math.max(absoluteStart, pieceStart) - pieceStart;
|
|
141
|
+
const toWithinPiece = Math.min(absoluteEnd, pieceStart + pieceLength - 1) - pieceStart;
|
|
142
|
+
|
|
143
|
+
await whenPieceReady(torrent, pieceIndex, cancellation);
|
|
144
|
+
|
|
145
|
+
// Pinned BEFORE it is located, and before any await that could let an
|
|
146
|
+
// eviction run: the offset is only meaningful while the piece is held.
|
|
147
|
+
store.pin(pieceIndex);
|
|
148
|
+
let located = null;
|
|
149
|
+
try {
|
|
150
|
+
located = await store.reside(pieceIndex);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
store.unpin(pieceIndex);
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!located) {
|
|
157
|
+
store.unpin(pieceIndex);
|
|
158
|
+
throw new Error(`Piece ${pieceIndex} is verified but absent from the store.`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let releasedThisPiece = false;
|
|
162
|
+
yield {
|
|
163
|
+
pieceIndex,
|
|
164
|
+
offset: located.offset + fromWithinPiece,
|
|
165
|
+
length: toWithinPiece - fromWithinPiece + 1,
|
|
166
|
+
release() {
|
|
167
|
+
if (releasedThisPiece) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
releasedThisPiece = true;
|
|
171
|
+
store.unpin(pieceIndex);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -80,6 +80,18 @@ export const Event = {
|
|
|
80
80
|
ERROR: "error",
|
|
81
81
|
/** One piece of a READ_RANGE body; its bytes are transferred, never copied. */
|
|
82
82
|
CHUNK: "chunk",
|
|
83
|
+
/**
|
|
84
|
+
* Where a piece of the body sits in the torrent's shared pool — an offset and
|
|
85
|
+
* a length, no bytes at all. The main thread maps the same memory and reads it
|
|
86
|
+
* in place; see `piece-reader.js` for why this replaces sending the bytes.
|
|
87
|
+
*/
|
|
88
|
+
FRAGMENT: "fragment",
|
|
89
|
+
/**
|
|
90
|
+
* The main thread has finished with a FRAGMENT and its pin may be dropped.
|
|
91
|
+
* Distinct from {@link CHUNK_ACK}, which only reports queue capacity: this one
|
|
92
|
+
* is a promise that nothing is reading those bytes any more.
|
|
93
|
+
*/
|
|
94
|
+
FRAGMENT_DONE: "fragment-done",
|
|
83
95
|
/** A READ_RANGE ended; no further CHUNKs bear that request id. */
|
|
84
96
|
READ_END: "read-end",
|
|
85
97
|
/** The main thread consumed a chunk — see {@link STREAM_HIGH_WATER_CHUNKS}. */
|
|
@@ -25,7 +25,8 @@ import "./install-webrtc-shim.js";
|
|
|
25
25
|
import { parentPort, workerData } from "node:worker_threads";
|
|
26
26
|
import { createSendStream } from "./channel.js";
|
|
27
27
|
import { createFileClaims } from "./file-claims.js";
|
|
28
|
-
import {
|
|
28
|
+
import { readFragments } from "./piece-reader.js";
|
|
29
|
+
import { Command, Event } from "./protocol.js";
|
|
29
30
|
|
|
30
31
|
// Imported dynamically, and that is load-bearing: static imports are RESOLVED
|
|
31
32
|
// during linking, before any module body runs, so a statically imported pool
|
|
@@ -33,7 +34,7 @@ import { Command, Event, STREAM_CHUNK_BYTES } from "./protocol.js";
|
|
|
33
34
|
// the hook above had a chance to register. Verified the hard way: with a static
|
|
34
35
|
// import the process still aborted, and the stack named the genuine polyfill.
|
|
35
36
|
const { TorrentPool } = await import("../torrent-pool.js");
|
|
36
|
-
const { collectStoreStats } = await import("../piece-store/shared-piece-store.js");
|
|
37
|
+
const { collectStoreStats, findSharedStore } = await import("../piece-store/shared-piece-store.js");
|
|
37
38
|
|
|
38
39
|
const pool = new TorrentPool({
|
|
39
40
|
maxDiskBytes: workerData?.maxDiskBytes,
|
|
@@ -84,6 +85,57 @@ async function requireTorrent(sourceKey) {
|
|
|
84
85
|
return pending;
|
|
85
86
|
}
|
|
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
|
+
|
|
87
139
|
/**
|
|
88
140
|
* Stream a byte range back as CHUNK messages.
|
|
89
141
|
*
|
|
@@ -120,39 +172,31 @@ async function streamRange({ id, sourceKey, fileIndex, start, end }) {
|
|
|
120
172
|
// an empty input.
|
|
121
173
|
const releaseRead = pool.acquireFile(torrent, fileIndex);
|
|
122
174
|
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
// Coalesce WebTorrent's own chunking (piece-sized, often much smaller) up to
|
|
127
|
-
// our chunk size: a round trip costs ~100 µs, so sending its native pieces
|
|
128
|
-
// straight through would multiply the crossings for no benefit.
|
|
129
|
-
let pendingParts = [];
|
|
130
|
-
let pendingBytes = 0;
|
|
131
|
-
|
|
132
|
-
const flush = async () => {
|
|
133
|
-
if (pendingBytes === 0) {
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
const merged = pendingParts.length === 1 ? pendingParts[0] : Buffer.concat(pendingParts, pendingBytes);
|
|
137
|
-
pendingParts = [];
|
|
138
|
-
pendingBytes = 0;
|
|
139
|
-
await sender.send(merged);
|
|
140
|
-
};
|
|
175
|
+
const rangeStart = start ?? 0;
|
|
176
|
+
const rangeEnd = end ?? file.length - 1;
|
|
141
177
|
|
|
142
178
|
let failed = false;
|
|
143
179
|
try {
|
|
144
|
-
|
|
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
|
+
})) {
|
|
145
190
|
if (sender.isCancelled()) {
|
|
191
|
+
fragment.release();
|
|
146
192
|
break;
|
|
147
193
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (!sender.isCancelled()) {
|
|
155
|
-
await flush();
|
|
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);
|
|
156
200
|
}
|
|
157
201
|
} catch (error) {
|
|
158
202
|
// The end-of-read marker means "the body is complete". Sending it after a
|
|
@@ -164,15 +208,16 @@ async function streamRange({ id, sourceKey, fileIndex, start, end }) {
|
|
|
164
208
|
throw error;
|
|
165
209
|
} finally {
|
|
166
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);
|
|
167
214
|
releaseRead();
|
|
168
215
|
if (!failed) {
|
|
169
216
|
sender.end();
|
|
170
217
|
}
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
source.destroy();
|
|
175
|
-
}
|
|
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.
|
|
176
221
|
}
|
|
177
222
|
}
|
|
178
223
|
|
|
@@ -208,6 +253,10 @@ async function runCommand(command, params, id) {
|
|
|
208
253
|
return {
|
|
209
254
|
infoHash: torrent.infoHash,
|
|
210
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,
|
|
211
260
|
// Files cross as plain data; the objects stay here.
|
|
212
261
|
files: (torrent.files ?? []).map((file, index) => ({
|
|
213
262
|
index,
|
|
@@ -282,6 +331,9 @@ async function runCommand(command, params, id) {
|
|
|
282
331
|
|
|
283
332
|
case Command.CANCEL_READ: {
|
|
284
333
|
readsById.get(params.readId)?.cancel();
|
|
334
|
+
// A cancelled read will never have its outstanding fragment confirmed, so
|
|
335
|
+
// wake it here — otherwise it waits forever with a piece pinned.
|
|
336
|
+
settleFragment(params.readId);
|
|
285
337
|
return true;
|
|
286
338
|
}
|
|
287
339
|
|
|
@@ -305,6 +357,13 @@ parentPort.on("message", async (message) => {
|
|
|
305
357
|
return;
|
|
306
358
|
}
|
|
307
359
|
|
|
360
|
+
// The main thread has finished reading a fragment out of shared memory, so
|
|
361
|
+
// its piece may be unpinned and the read may continue.
|
|
362
|
+
if (message?.type === Event.FRAGMENT_DONE) {
|
|
363
|
+
settleFragment(message.id);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
308
367
|
const { command, id, params } = message ?? {};
|
|
309
368
|
try {
|
|
310
369
|
const result = await runCommand(command, params ?? {}, id);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Turning a byte range into positions in shared memory.
|
|
3
|
+
*
|
|
4
|
+
* This arithmetic fails silently when it is wrong: the response comes back the
|
|
5
|
+
* right length and full of the wrong bytes. Piece numbers are torrent-wide
|
|
6
|
+
* while a read is expressed in file coordinates, and the first and last pieces
|
|
7
|
+
* of a range are almost always partial — so every case here is a boundary.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
import { readFragments } from "../services/torrent-worker/piece-reader.js";
|
|
14
|
+
import { SharedPieceStore } from "../services/piece-store/shared-piece-store.js";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
|
|
19
|
+
const PIECE = 1024;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A torrent whose pieces are all present, backed by a real store so that
|
|
23
|
+
* `locate`/`reside`/`pin` behave as they do in production.
|
|
24
|
+
*
|
|
25
|
+
* @param {{ fileOffset: number, fileLength: number, totalLength: number }} shape
|
|
26
|
+
*/
|
|
27
|
+
async function fakeTorrent({ fileOffset, fileLength, totalLength }) {
|
|
28
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "piece-reader-test-"));
|
|
29
|
+
const store = new SharedPieceStore(PIECE, {
|
|
30
|
+
length: totalLength,
|
|
31
|
+
memoryBytes: 64 * PIECE,
|
|
32
|
+
path: directory,
|
|
33
|
+
name: "test"
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const pieceCount = Math.ceil(totalLength / PIECE);
|
|
37
|
+
for (let index = 0; index < pieceCount; index += 1) {
|
|
38
|
+
const length = index === pieceCount - 1 ? totalLength - index * PIECE : PIECE;
|
|
39
|
+
const piece = Buffer.alloc(length);
|
|
40
|
+
// Each byte encodes its own absolute position, so a misplaced offset is
|
|
41
|
+
// visible in the value itself rather than only in the length.
|
|
42
|
+
for (let at = 0; at < length; at += 1) {
|
|
43
|
+
piece[at] = (index * PIECE + at) % 251;
|
|
44
|
+
}
|
|
45
|
+
await new Promise((resolve, reject) => {
|
|
46
|
+
store.put(index, piece, (error) => (error ? reject(error) : resolve()));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const torrent = Object.assign(new EventEmitter(), {
|
|
51
|
+
pieceLength: PIECE,
|
|
52
|
+
store,
|
|
53
|
+
bitfield: { get: () => true },
|
|
54
|
+
files: [{ offset: fileOffset, length: fileLength, name: "file.bin" }],
|
|
55
|
+
select() {},
|
|
56
|
+
critical() {}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return { torrent, store, directory };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Collect a range through the reader, as the worker does. */
|
|
63
|
+
async function readRange(torrent, start, end) {
|
|
64
|
+
const collected = [];
|
|
65
|
+
const positions = [];
|
|
66
|
+
const pool = Buffer.from(torrent.store.sharedBuffer);
|
|
67
|
+
for await (const fragment of readFragments({
|
|
68
|
+
torrent,
|
|
69
|
+
fileIndex: 0,
|
|
70
|
+
start,
|
|
71
|
+
end,
|
|
72
|
+
cancellation: { isCancelled: () => false }
|
|
73
|
+
})) {
|
|
74
|
+
collected.push(Buffer.from(pool.subarray(fragment.offset, fragment.offset + fragment.length)));
|
|
75
|
+
positions.push({ piece: fragment.pieceIndex, length: fragment.length });
|
|
76
|
+
fragment.release();
|
|
77
|
+
}
|
|
78
|
+
return { bytes: Buffer.concat(collected), positions };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What the bytes at an absolute torrent offset should be. */
|
|
82
|
+
function expectedBytes(absoluteStart, length) {
|
|
83
|
+
const expected = Buffer.alloc(length);
|
|
84
|
+
for (let at = 0; at < length; at += 1) {
|
|
85
|
+
expected[at] = (absoluteStart + at) % 251;
|
|
86
|
+
}
|
|
87
|
+
return expected;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test("a range inside one piece is read from that piece only", async () => {
|
|
91
|
+
const { torrent, store, directory } = await fakeTorrent({
|
|
92
|
+
fileOffset: 0,
|
|
93
|
+
fileLength: 4 * PIECE,
|
|
94
|
+
totalLength: 4 * PIECE
|
|
95
|
+
});
|
|
96
|
+
try {
|
|
97
|
+
const { bytes, positions } = await readRange(torrent, 100, 199);
|
|
98
|
+
assert.equal(positions.length, 1);
|
|
99
|
+
assert.deepEqual(bytes, expectedBytes(100, 100));
|
|
100
|
+
} finally {
|
|
101
|
+
store.destroy(() => undefined);
|
|
102
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a range spanning pieces reassembles in order", async () => {
|
|
107
|
+
const { torrent, store, directory } = await fakeTorrent({
|
|
108
|
+
fileOffset: 0,
|
|
109
|
+
fileLength: 4 * PIECE,
|
|
110
|
+
totalLength: 4 * PIECE
|
|
111
|
+
});
|
|
112
|
+
try {
|
|
113
|
+
const start = PIECE - 10;
|
|
114
|
+
const end = 2 * PIECE + 9;
|
|
115
|
+
const { bytes, positions } = await readRange(torrent, start, end);
|
|
116
|
+
assert.deepEqual(positions.map((entry) => entry.piece), [0, 1, 2]);
|
|
117
|
+
assert.deepEqual(bytes, expectedBytes(start, end - start + 1));
|
|
118
|
+
} finally {
|
|
119
|
+
store.destroy(() => undefined);
|
|
120
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("a file that does not start at a piece boundary is still read correctly", async () => {
|
|
125
|
+
// The usual case in a multi-file torrent, and the one where using file
|
|
126
|
+
// offsets as if they were torrent offsets returns the wrong bytes at the
|
|
127
|
+
// right length.
|
|
128
|
+
const fileOffset = PIECE + 300;
|
|
129
|
+
const { torrent, store, directory } = await fakeTorrent({
|
|
130
|
+
fileOffset,
|
|
131
|
+
fileLength: 2 * PIECE,
|
|
132
|
+
totalLength: 5 * PIECE
|
|
133
|
+
});
|
|
134
|
+
try {
|
|
135
|
+
const { bytes } = await readRange(torrent, 0, 1499);
|
|
136
|
+
assert.deepEqual(bytes, expectedBytes(fileOffset, 1500));
|
|
137
|
+
} finally {
|
|
138
|
+
store.destroy(() => undefined);
|
|
139
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("every fragment releases its pin, so nothing stays held", async () => {
|
|
144
|
+
const { torrent, store, directory } = await fakeTorrent({
|
|
145
|
+
fileOffset: 0,
|
|
146
|
+
fileLength: 4 * PIECE,
|
|
147
|
+
totalLength: 4 * PIECE
|
|
148
|
+
});
|
|
149
|
+
try {
|
|
150
|
+
await readRange(torrent, 0, 4 * PIECE - 1);
|
|
151
|
+
// With every piece unpinned the store can still make room; if a pin leaked
|
|
152
|
+
// it would eventually refuse.
|
|
153
|
+
const before = store.stats().blockedByPins;
|
|
154
|
+
for (let index = 0; index < 200; index += 1) {
|
|
155
|
+
await store.reside(index % 4);
|
|
156
|
+
}
|
|
157
|
+
assert.equal(store.stats().blockedByPins, before, "a pin was left behind");
|
|
158
|
+
} finally {
|
|
159
|
+
store.destroy(() => undefined);
|
|
160
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
});
|
|
@@ -156,6 +156,11 @@ test("a read of an unknown source fails the stream rather than hanging", async (
|
|
|
156
156
|
// the reader waited forever. A unit test on either half alone passes happily.
|
|
157
157
|
const client = new TorrentWorkerClient({ memoryBytes: 8 * 1024 * 1024 });
|
|
158
158
|
try {
|
|
159
|
+
// Starting the worker takes several seconds — it builds a torrent client
|
|
160
|
+
// and a DHT — so the first request would measure startup, not the failure
|
|
161
|
+
// path under test.
|
|
162
|
+
await client.listFiles("warm-up").catch(() => undefined);
|
|
163
|
+
|
|
159
164
|
const stream = client.createReadStream({ sourceKey: "no-such-source", fileIndex: 0 });
|
|
160
165
|
const reader = stream.getReader();
|
|
161
166
|
|