@torrent-tv/proxy 2.9.78 → 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 CHANGED
@@ -1,3 +1,8 @@
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
+
1
6
  ## 2.9.78
2
7
 
3
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.78",
3
+ "version": "2.9.79",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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
- reply.header("Accept-Ranges", "bytes");
98
- reply.header("Content-Type", "application/octet-stream");
99
- reply.header("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`);
100
-
101
- if (!range) {
102
- reply.header("Content-Length", String(file.length));
103
- const stream = file.createReadStream();
104
- bindRelease(stream, reply, releaseFile);
105
- return reply.send(stream);
106
- }
107
-
108
- const contentLength = range.end - range.start + 1;
109
- reply.code(206);
110
- reply.header("Content-Length", String(contentLength));
111
- reply.header("Content-Range", `bytes ${range.start}-${range.end}/${file.length}`);
112
- const stream = file.createReadStream({ start: range.start, end: range.end });
113
- bindRelease(stream, reply, releaseFile);
114
- return reply.send(stream);
115
- }
116
-
117
- /**
118
- * Attach event listeners that release the file reference exactly once when
119
- * the stream or the underlying HTTP connection closes.
120
- *
121
- * @param {import("node:stream").Readable} stream
122
- * @param {import("fastify").FastifyReply} reply
123
- * @param {() => void} release
124
- * @returns {void}
125
- */
126
- function bindRelease(stream, reply, release) {
127
- let released = false;
128
- const releaseOnce = () => {
129
- if (released) {
130
- return;
131
- }
132
- released = true;
133
- release();
134
- };
135
-
136
- stream.on("close", releaseOnce);
137
- stream.on("end", releaseOnce);
138
- stream.on("error", releaseOnce);
139
- reply.raw.once("close", releaseOnce);
140
- reply.raw.once("finish", releaseOnce);
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
+ }
@@ -41,6 +41,8 @@ export class TorrentWorkerClient {
41
41
  #poolBySource = new Map();
42
42
  /** Which pool an in-flight read belongs to, keyed by request id. */
43
43
  #poolByRead = new Map();
44
+ /** Reads consuming fragments in place, keyed by request id. */
45
+ #fragmentReaders = new Map();
44
46
 
45
47
  /**
46
48
  * @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
@@ -64,25 +66,42 @@ export class TorrentWorkerClient {
64
66
  read.fail(new Error(message.error ?? "Torrent worker read failed."));
65
67
  return;
66
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
+ }
67
76
  if (this.#caller.handleReply(message)) {
68
77
  return;
69
78
  }
70
79
  switch (message?.type) {
71
80
  case Event.FRAGMENT: {
72
- // The bytes are already here — this thread maps the same pool. Read
73
- // them where they lie, then say so, which is what lets the worker
74
- // unpin the piece and move on. The copy exists only because the
75
- // consumer keeps what it is given while the slot may be reused; it is
76
- // one copy on this thread rather than one on the torrent's.
77
81
  const pool = this.#poolByRead.get(message.id);
78
- const bytes = pool
79
- ? Uint8Array.prototype.slice.call(
80
- new Uint8Array(pool, message.offset, message.length)
81
- )
82
- : null;
83
- if (bytes) {
84
- this.#reads.get(message.id)?.push(bytes);
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;
85
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));
86
105
  this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
87
106
  break;
88
107
  }
@@ -96,6 +115,8 @@ export class TorrentWorkerClient {
96
115
  case Event.READ_END:
97
116
  this.#reads.get(message.id)?.close();
98
117
  this.#reads.delete(message.id);
118
+ this.#fragmentReaders.get(message.id)?.close();
119
+ this.#fragmentReaders.delete(message.id);
99
120
  this.#poolByRead.delete(message.id);
100
121
  break;
101
122
  case Event.LOG:
@@ -235,6 +256,104 @@ export class TorrentWorkerClient {
235
256
  return receive.stream;
236
257
  }
237
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
+
238
357
  /**
239
358
  * A stand-in for the WebTorrent torrent object, backed by the worker.
240
359
  *
@@ -285,6 +404,22 @@ export class TorrentWorkerClient {
285
404
  end: options.end ?? null
286
405
  })
287
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
+ });
288
423
  }
289
424
  }))
290
425
  };
@@ -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