@torrent-tv/proxy 2.9.78 → 2.9.80

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,15 @@
1
+ ## 2.9.80
2
+
3
+ - **Fix**: A seek backward could hang forever. `prioritizeByteRange` demotes the pieces behind the playhead with `deselect`, which removes them from the download set — and `critical`, which runs right after, only flags pieces that are already selected, so it never puts them back. A seek forward followed by a seek backward therefore left the target pieces wanted by nobody: the encoder waited on data the torrent had been told to stop fetching, while the swarm ran at full speed on pieces nobody needed. The read position is now re-selected whenever it moves back behind what an earlier seek deselected, tracked per file because WebTorrent does not report its own selection back.
4
+ - **Fix**: Two pieces could be given the same slot in the shared store. Eviction chose a victim, then **awaited** the spill write before removing it from the books, so a second claim arriving in that window chose the same victim and received the same slot — after which two pieces overwrote each other, both failed their hash, and the torrent downloaded them again indefinitely. From outside this looked exactly like a seek that never completes while the download runs at full speed. The victim is now claimed and unbooked in one uninterrupted step, and a reader that arrives mid-spill waits for the write instead of being told the piece is missing.
5
+ - **Fix**: A burst of concurrent `put`s could fail with "every resident piece is pinned" when nothing was pinned at all. Slots are claimed before the piece is copied into them, and pieces arrive from many peers at once, so the store saw an empty eviction list while its slots were already spoken for. Slots handed out but not yet recorded are now counted, and a claim that finds nothing waits for that work to land rather than declaring the store exhausted.
6
+ - **New**: Two figures the last field failure could not be diagnosed without. The store now reports `pinned=` alongside its other counters, so a leaked pin is visible while it is still harmless instead of only when eviction has nothing left to take; and a read position that jumps — a seek — is logged with its offset and percentage through the file, so it can be seen whether a seek reached the torrent at all.
7
+
8
+ ## 2.9.79
9
+
10
+ - **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.
11
+ - **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.
12
+
1
13
  ## 2.9.78
2
14
 
3
15
  - **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.80",
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
+ }
@@ -79,7 +79,37 @@
79
79
  import { performance } from "node:perf_hooks";
80
80
  import { eventLoopDelay, resetEventLoopDelay } from "../utils/perf.js";
81
81
 
82
+ /**
83
+ * Build one body frame: `[flags(1)][idLen(1)][requestId][payload]`.
84
+ *
85
+ * One allocation and one copy. The previous version made two of each — a copy
86
+ * of the chunk into a `Buffer`, then a `concat` that copied it again into the
87
+ * frame — which measured 75.9 ms per 13 MB segment on the field host against
88
+ * 40.0 ms this way, and allocated ~600 extra buffers over a segment's 208
89
+ * chunks. One copy is the floor: chunks arrive from a web stream that allocates
90
+ * them itself, so there is no buffer of ours to read them into.
91
+ *
92
+ * @param {Buffer} idBytes - The request id, already encoded.
93
+ * @param {Uint8Array | null} bytes - Payload, or nothing for the done frame.
94
+ * @param {boolean} done
95
+ * @returns {Buffer}
96
+ */
97
+ export function encodeFrame(idBytes, bytes, done) {
98
+ const payloadLength = bytes?.length ?? 0;
99
+ const frame = Buffer.allocUnsafe(2 + idBytes.length + payloadLength);
100
+ frame[0] = done ? 1 : 0;
101
+ frame[1] = idBytes.length;
102
+ idBytes.copy(frame, 2);
103
+ if (payloadLength > 0) {
104
+ frame.set(bytes, 2 + idBytes.length);
105
+ }
106
+ return frame;
107
+ }
108
+
82
109
  export function createDataChannelHandler({ proxyPort, onLog }) {
110
+ /** Request id → its ASCII bytes; see {@link requestIdBytes}. */
111
+ const requestIdCache = new Map();
112
+
83
113
  /**
84
114
  * @param {string} message
85
115
  * @returns {void}
@@ -389,16 +419,32 @@ export function createDataChannelHandler({ proxyPort, onLog }) {
389
419
  * @param {boolean} done
390
420
  * @returns {void}
391
421
  */
422
+ /**
423
+ * The request id as bytes, prepared once per request rather than per chunk.
424
+ *
425
+ * A segment is a couple of hundred chunks, and each one was re-encoding the
426
+ * same 32-character string. The map is bounded because request ids are
427
+ * short-lived and unbounded in number — dropping the whole cache when it
428
+ * grows costs one re-encode per live request and cannot leak.
429
+ *
430
+ * @param {string} requestId
431
+ * @returns {Buffer}
432
+ */
433
+ function requestIdBytes(requestId) {
434
+ let bytes = requestIdCache.get(requestId);
435
+ if (!bytes) {
436
+ if (requestIdCache.size > 64) {
437
+ requestIdCache.clear();
438
+ }
439
+ bytes = Buffer.from(requestId, "ascii");
440
+ requestIdCache.set(requestId, bytes);
441
+ }
442
+ return bytes;
443
+ }
444
+
392
445
  function sendChunk(channel, requestId, bytes, done) {
393
446
  try {
394
- const idBuf = Buffer.from(requestId, "ascii");
395
- const header = Buffer.allocUnsafe(2 + idBuf.length);
396
- header[0] = done ? 1 : 0;
397
- header[1] = idBuf.length;
398
- idBuf.copy(header, 2);
399
- const frame =
400
- bytes && bytes.length > 0 ? Buffer.concat([header, Buffer.from(bytes)]) : header;
401
- channel.sendMessageBinary(frame);
447
+ channel.sendMessageBinary(encodeFrame(requestIdBytes(requestId), bytes, done));
402
448
  } catch {
403
449
  // Channel closed between check and send — safe to ignore.
404
450
  }
@@ -49,6 +49,18 @@ export class PieceLru {
49
49
  return this.#capacity;
50
50
  }
51
51
 
52
+ /**
53
+ * How many pieces are currently held by a reader.
54
+ *
55
+ * Reported rather than merely tracked: a pin that is never released is
56
+ * invisible until eviction has nothing left to take, and by then the store is
57
+ * already failing. A count that keeps climbing between reports names the leak
58
+ * long before that.
59
+ */
60
+ get pinnedCount() {
61
+ return this.#pins.size;
62
+ }
63
+
52
64
  /**
53
65
  * Mark a piece as resident, or as used again if it already was.
54
66
  *
@@ -131,6 +131,28 @@ export class SharedPieceStore {
131
131
  #slotOf = new Map();
132
132
  /** Slot numbers not currently holding a piece. */
133
133
  #freeSlots = [];
134
+ /**
135
+ * Pieces being written out to disk right now: index → that write.
136
+ *
137
+ * Such a piece is in neither place — its slot has already been given away,
138
+ * and the disk copy is not finished. A reader arriving in that window must
139
+ * wait for the write instead of concluding the piece is gone.
140
+ *
141
+ * @type {Map<number, Promise<void>>}
142
+ */
143
+ #evicting = new Map();
144
+ /**
145
+ * Slots handed out but not yet recorded against a piece.
146
+ *
147
+ * A slot is claimed before the piece is copied into it, so between those two
148
+ * moments the slot belongs to nobody the books know about. Without counting
149
+ * them, a burst of concurrent puts — which is the normal case, pieces arrive
150
+ * from many peers at once — sees an empty eviction list and concludes the
151
+ * store is exhausted, when in fact it is merely mid-flight.
152
+ */
153
+ #outstandingSlots = 0;
154
+ /** Resolvers waiting for a slot to become claimable. @type {(() => void)[]} */
155
+ #waiters = [];
134
156
  #lru;
135
157
  #disk;
136
158
  /** Slots backed by memory right now; grows towards {@link capacity}. */
@@ -206,6 +228,7 @@ export class SharedPieceStore {
206
228
  name: this.#name,
207
229
  resident: this.#slotOf.size,
208
230
  capacity: this.#capacity,
231
+ pinned: this.#lru.pinnedCount,
209
232
  spilled: this.#disk.size,
210
233
  ...this.#counters
211
234
  };
@@ -293,8 +316,65 @@ export class SharedPieceStore {
293
316
  * @returns {Promise<number>} Slot number.
294
317
  */
295
318
  async #claimSlot() {
319
+ for (;;) {
320
+ const slot = await this.#claimSlotOnce();
321
+ if (slot !== null) {
322
+ return slot;
323
+ }
324
+ // Nothing claimable this instant, but work is in flight that will make a
325
+ // slot claimable: a spill finishing, or a piece being written into a slot
326
+ // already handed out. Wait for either and look again, rather than failing
327
+ // while the store is in the middle of making room.
328
+ await new Promise((resolve) => {
329
+ this.#waiters.push(resolve);
330
+ for (const spill of this.#evicting.values()) {
331
+ void spill.then(() => this.#wake(), () => this.#wake());
332
+ }
333
+ });
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Record a piece against the slot it now occupies, and let waiters retry.
339
+ *
340
+ * @param {number} index
341
+ * @param {number} slot
342
+ * @returns {void}
343
+ */
344
+ #registerSlot(index, slot) {
345
+ this.#slotOf.set(index, slot);
346
+ this.#lru.touch(index);
347
+ this.#outstandingSlots -= 1;
348
+ this.#wake();
349
+ }
350
+
351
+ /**
352
+ * Release everyone waiting for a slot; each rechecks for itself.
353
+ *
354
+ * @returns {void}
355
+ */
356
+ #wake() {
357
+ const waiting = this.#waiters;
358
+ this.#waiters = [];
359
+ for (const resolve of waiting) {
360
+ resolve();
361
+ }
362
+ }
363
+
364
+ /**
365
+ * One attempt at a slot: a number, or `null` when the caller should wait for
366
+ * an in-flight spill and try again.
367
+ *
368
+ * @returns {Promise<number | null>}
369
+ */
370
+ async #claimSlotOnce() {
371
+ // Every slot handed out below is counted BEFORE this function can suspend.
372
+ // Counting it after an `await` would leave concurrent callers — which is
373
+ // how pieces actually arrive — seeing an idle store and declaring it
374
+ // exhausted while its slots are already spoken for.
296
375
  const free = this.#freeSlots.pop();
297
376
  if (free !== undefined) {
377
+ this.#outstandingSlots += 1;
298
378
  return free;
299
379
  }
300
380
 
@@ -305,11 +385,15 @@ export class SharedPieceStore {
305
385
  this.#allocatedSlots += 1;
306
386
  this.#shared.grow(this.#allocatedSlots * this.#chunkLength);
307
387
  this.#pool = Buffer.from(this.#shared);
388
+ this.#outstandingSlots += 1;
308
389
  return this.#allocatedSlots - 1;
309
390
  }
310
391
 
311
392
  const victim = this.#lru.evictionCandidate();
312
393
  if (victim === null) {
394
+ if (this.#evicting.size > 0 || this.#outstandingSlots > 0) {
395
+ return null;
396
+ }
313
397
  // Every resident piece is being read. Taking one anyway is precisely the
314
398
  // failure this store exists to make impossible.
315
399
  this.#counters.blockedByPins += 1;
@@ -317,12 +401,30 @@ export class SharedPieceStore {
317
401
  }
318
402
 
319
403
  const slot = this.#slotOf.get(victim);
320
- const bytes = this.#pool.subarray(slot * this.#chunkLength, slot * this.#chunkLength + this.#lengthOf(victim));
321
- await this.#disk.write(victim, bytes);
322
- this.#counters.spills += 1;
323
404
 
405
+ // Claim the victim NOW, before the write can suspend us. Picking it and
406
+ // releasing it either side of an `await` lets a second claim, arriving in
407
+ // that gap, pick the same victim and be handed the same slot — after which
408
+ // two pieces write over each other, both fail their hash, and the torrent
409
+ // downloads them again, forever. Removing it from the books first makes the
410
+ // choice atomic; `#evicting` keeps readers correct in the meantime.
324
411
  this.#slotOf.delete(victim);
325
412
  this.#lru.remove(victim);
413
+ this.#outstandingSlots += 1;
414
+
415
+ const bytes = this.#pool.subarray(slot * this.#chunkLength, slot * this.#chunkLength + this.#lengthOf(victim));
416
+ const spill = this.#disk.write(victim, bytes).then(
417
+ () => {
418
+ this.#counters.spills += 1;
419
+ this.#evicting.delete(victim);
420
+ },
421
+ (error) => {
422
+ this.#evicting.delete(victim);
423
+ throw error;
424
+ }
425
+ );
426
+ this.#evicting.set(victim, spill);
427
+ await spill;
326
428
  return slot;
327
429
  }
328
430
 
@@ -346,8 +448,11 @@ export class SharedPieceStore {
346
448
  bytes.copy
347
449
  ? bytes.copy(this.#pool, slot * this.#chunkLength)
348
450
  : this.#pool.set(bytes, slot * this.#chunkLength);
349
- this.#slotOf.set(index, slot);
350
- this.#lru.touch(index);
451
+ if (existing === undefined) {
452
+ this.#registerSlot(index, slot);
453
+ } else {
454
+ this.#lru.touch(index);
455
+ }
351
456
  // A newer copy is in memory; whatever is on disk is stale.
352
457
  this.#disk.forget(index);
353
458
  };
@@ -391,6 +496,13 @@ export class SharedPieceStore {
391
496
  return Buffer.from(this.#pool.subarray(start, start + length));
392
497
  }
393
498
 
499
+ // Mid-spill: neither in memory nor yet on disk. See `reside` — reporting
500
+ // it missing here would tell WebTorrent to fetch a piece we already have.
501
+ const spill = this.#evicting.get(index);
502
+ if (spill) {
503
+ await spill.catch(() => undefined);
504
+ }
505
+
394
506
  if (!this.#disk.has(index)) {
395
507
  throw new Error(`Piece ${index} is not in the store.`);
396
508
  }
@@ -403,8 +515,7 @@ export class SharedPieceStore {
403
515
  revived * this.#chunkLength + pieceLength
404
516
  );
405
517
  await this.#disk.read(index, target);
406
- this.#slotOf.set(index, revived);
407
- this.#lru.touch(index);
518
+ this.#registerSlot(index, revived);
408
519
  this.#counters.fromDisk += 1;
409
520
  this.#counters.revivals += 1;
410
521
  const start = revived * this.#chunkLength + offset;
@@ -443,6 +554,14 @@ export class SharedPieceStore {
443
554
  return this.locate(index);
444
555
  }
445
556
 
557
+ // Caught mid-spill: the slot is already gone, the disk copy is not there
558
+ // yet. Waiting is the only correct answer — reporting it missing would make
559
+ // the caller re-download a piece we are in the middle of keeping.
560
+ const spill = this.#evicting.get(index);
561
+ if (spill) {
562
+ await spill.catch(() => undefined);
563
+ }
564
+
446
565
  if (!this.#disk.has(index)) {
447
566
  return null;
448
567
  }
@@ -454,8 +573,7 @@ export class SharedPieceStore {
454
573
  revived * this.#chunkLength + pieceLength
455
574
  );
456
575
  await this.#disk.read(index, target);
457
- this.#slotOf.set(index, revived);
458
- this.#lru.touch(index);
576
+ this.#registerSlot(index, revived);
459
577
  this.#counters.fromDisk += 1;
460
578
  this.#counters.revivals += 1;
461
579
  return this.locate(index);
@@ -306,6 +306,17 @@ export class TorrentPool {
306
306
  */
307
307
  #readPositionByTorrent = new Map();
308
308
 
309
+ /**
310
+ * Lowest piece currently selected for download, per torrent and fileIndex.
311
+ *
312
+ * Needed because selection is not readable back from WebTorrent, and a seek
313
+ * backward has to know whether the pieces it wants were deselected by an
314
+ * earlier seek forward.
315
+ *
316
+ * @type {Map<import("webtorrent").Torrent, Map<number, number>>}
317
+ */
318
+ #selectedFromPiece = new Map();
319
+
309
320
  /** Global disk cap in bytes (0 = disabled). */
310
321
  #maxDiskBytes = 0;
311
322
 
@@ -1068,8 +1079,24 @@ export class TorrentPool {
1068
1079
  readPositions = new Map();
1069
1080
  this.#readPositionByTorrent.set(torrent, readPositions);
1070
1081
  }
1082
+ const previousStart = readPositions.get(fileIndex);
1071
1083
  readPositions.set(fileIndex, safeStart);
1072
1084
 
1085
+ // Log jumps only. Sequential reading calls this on every range request and
1086
+ // would drown the log; a jump is a seek, and a seek that never reaches the
1087
+ // torrent is exactly the failure this line exists to make visible — after a
1088
+ // seek the encoder waits on pieces nobody has been told to fetch.
1089
+ const isJump =
1090
+ previousStart === undefined || Math.abs(safeStart - previousStart) > PRIORITY_WINDOW_BYTES;
1091
+ if (isJump) {
1092
+ const percent = ((safeStart / fileLength) * 100).toFixed(1);
1093
+ logger.info(
1094
+ `torrent-pool: [${String(torrent.infoHash).slice(0, 8)}] read position -> ` +
1095
+ `${(safeStart / 1024 / 1024).toFixed(0)}MB (${percent}% of file ${fileIndex})` +
1096
+ (previousStart === undefined ? " (first)" : ` (was ${(previousStart / 1024 / 1024).toFixed(0)}MB)`)
1097
+ );
1098
+ }
1099
+
1073
1100
  const absStart = fileOffset + safeStart;
1074
1101
  const playheadPiece = Math.floor(absStart / pieceLength);
1075
1102
  const absWindowEnd = Math.min(
@@ -1078,17 +1105,41 @@ export class TorrentPool {
1078
1105
  );
1079
1106
  const windowEndPiece = Math.floor(absWindowEnd / pieceLength);
1080
1107
 
1081
- // (1) Demote the gap behind the playhead so the picker scans forward from
1108
+ // (1) Re-select from the playhead when it moved BACK behind what an earlier
1109
+ // seek deselected. `deselect` removes pieces from the download set, and
1110
+ // `critical` does NOT put them back — it only flags pieces already
1111
+ // selected. So without this, a seek forward followed by a seek backward
1112
+ // leaves the target pieces wanted by nobody: the encoder waits on data
1113
+ // the torrent was told to stop fetching, and waits forever.
1114
+ // Only on a backward move, so repeat calls do not pile up selections.
1115
+ let selectedFrom = this.#selectedFromPiece.get(torrent)?.get(fileIndex);
1116
+ if (selectedFrom === undefined || playheadPiece < selectedFrom) {
1117
+ try {
1118
+ torrent.select(playheadPiece, fileEndPiece, 1);
1119
+ } catch {
1120
+ // Best effort — never break streaming because selection failed.
1121
+ }
1122
+ let perFile = this.#selectedFromPiece.get(torrent);
1123
+ if (!perFile) {
1124
+ perFile = new Map();
1125
+ this.#selectedFromPiece.set(torrent, perFile);
1126
+ }
1127
+ perFile.set(fileIndex, playheadPiece);
1128
+ selectedFrom = playheadPiece;
1129
+ }
1130
+
1131
+ // (2) Demote the gap behind the playhead so the picker scans forward from
1082
1132
  // the read position. Only when there IS a gap (not at the file start).
1083
1133
  if (playheadPiece > fileStartPiece && typeof torrent.deselect === "function") {
1084
1134
  try {
1085
1135
  torrent.deselect(fileStartPiece, playheadPiece - 1);
1136
+ this.#selectedFromPiece.get(torrent)?.set(fileIndex, playheadPiece);
1086
1137
  } catch {
1087
1138
  // Best effort — never break streaming because demotion failed.
1088
1139
  }
1089
1140
  }
1090
1141
 
1091
- // (2) Reset criticality to a moving read-ahead window (hotswap over the near
1142
+ // (3) Reset criticality to a moving read-ahead window (hotswap over the near
1092
1143
  // pieces), so it does not accumulate over the whole file across seeks.
1093
1144
  if (Array.isArray(torrent._critical)) {
1094
1145
  torrent._critical.length = 0;
@@ -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
  };
@@ -398,7 +398,7 @@ setInterval(() => {
398
398
  const fromMemoryShare = reads > 0 ? ((stats.fromMemory / reads) * 100).toFixed(1) : "—";
399
399
  log(
400
400
  `piece-store "${stats.name.slice(0, 40)}": resident=${stats.resident}/${stats.capacity} ` +
401
- `spilled=${stats.spilled} reads=${reads} (${fromMemoryShare}% from memory) ` +
401
+ `pinned=${stats.pinned} spilled=${stats.spilled} reads=${reads} (${fromMemoryShare}% from memory) ` +
402
402
  `spills=${stats.spills} revivals=${stats.revivals}` +
403
403
  (stats.blockedByPins > 0 ? ` blocked-by-pins=${stats.blockedByPins}` : "")
404
404
  );
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @file The wire format of a body frame.
3
+ *
4
+ * The browser parses these bytes, so the layout is a contract:
5
+ * `[flags(1)][idLen(1)][requestId][payload]`. The framing was rewritten to stop
6
+ * copying every chunk twice (75.9 ms per 13 MB segment against 40.0 on the
7
+ * field host), and a rewrite of something a client parses needs the format
8
+ * pinned down, not just the timing.
9
+ */
10
+
11
+ import test from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { encodeFrame } from "../services/data-channel-handler.js";
14
+
15
+ const requestId = Buffer.from("abc123", "ascii");
16
+
17
+ test("a body frame carries the id and the payload unchanged", () => {
18
+ const payload = Buffer.from([1, 2, 3, 4, 250, 255]);
19
+ const frame = encodeFrame(requestId, payload, false);
20
+
21
+ assert.equal(frame[0], 0, "flagged as done");
22
+ assert.equal(frame[1], requestId.length);
23
+ assert.deepEqual(frame.subarray(2, 2 + requestId.length), requestId);
24
+ assert.deepEqual(frame.subarray(2 + requestId.length), payload);
25
+ assert.equal(frame.length, 2 + requestId.length + payload.length);
26
+ });
27
+
28
+ test("the done frame carries no payload", () => {
29
+ const frame = encodeFrame(requestId, null, true);
30
+
31
+ assert.equal(frame[0], 1);
32
+ assert.equal(frame.length, 2 + requestId.length);
33
+ });
34
+
35
+ test("an empty payload is treated as no payload", () => {
36
+ const frame = encodeFrame(requestId, new Uint8Array(0), false);
37
+ assert.equal(frame.length, 2 + requestId.length);
38
+ });
39
+
40
+ test("a payload that is a view into a larger buffer is copied correctly", () => {
41
+ // Chunks arrive as views into a bigger allocation, so copying the whole
42
+ // underlying buffer instead of the view would send the wrong bytes at the
43
+ // wrong length — silently.
44
+ const backing = Buffer.alloc(64, 9);
45
+ backing.fill(42, 16, 32);
46
+ const view = new Uint8Array(backing.buffer, backing.byteOffset + 16, 16);
47
+
48
+ const frame = encodeFrame(requestId, view, false);
49
+
50
+ assert.equal(frame.length, 2 + requestId.length + 16);
51
+ assert.deepEqual(frame.subarray(2 + requestId.length), Buffer.alloc(16, 42));
52
+ });
53
+
54
+ test("a large payload survives framing byte for byte", () => {
55
+ const payload = Buffer.allocUnsafeSlow(64 * 1024);
56
+ for (let at = 0; at < payload.length; at += 1) {
57
+ payload[at] = at % 251;
58
+ }
59
+
60
+ const frame = encodeFrame(requestId, payload, false);
61
+
62
+ assert.deepEqual(frame.subarray(2 + requestId.length), payload);
63
+ });
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Eviction under concurrency.
3
+ *
4
+ * Pieces arrive from several peers at once, so `put` runs concurrently by
5
+ * nature. Choosing a victim and releasing it either side of the spill write
6
+ * therefore let two claims pick the SAME victim and receive the SAME slot, at
7
+ * which point two pieces overwrite each other, both fail their hash, and the
8
+ * torrent re-downloads them without end — which looks from outside exactly like
9
+ * a seek that never completes while the download runs at full speed.
10
+ */
11
+
12
+ import test from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import fs from "node:fs/promises";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { SharedPieceStore } from "../services/piece-store/shared-piece-store.js";
18
+
19
+ const PIECE = 1024;
20
+
21
+ /**
22
+ * @param {number} capacityPieces
23
+ * @returns {Promise<{ store: SharedPieceStore, directory: string }>}
24
+ */
25
+ async function makeStore(capacityPieces) {
26
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), "piece-store-test-"));
27
+ const store = new SharedPieceStore(PIECE, {
28
+ length: PIECE * 64,
29
+ memoryBytes: PIECE * capacityPieces,
30
+ spillDirectory: directory,
31
+ name: "eviction-test"
32
+ });
33
+ return { store, directory };
34
+ }
35
+
36
+ /**
37
+ * @param {number} index
38
+ * @returns {Buffer} A piece whose every byte identifies it.
39
+ */
40
+ const pieceOf = (index) => Buffer.alloc(PIECE, index % 251);
41
+
42
+ /**
43
+ * @param {SharedPieceStore} store
44
+ * @param {number} index
45
+ * @param {Buffer} bytes
46
+ * @returns {Promise<void>}
47
+ */
48
+ const put = (store, index, bytes) =>
49
+ new Promise((resolve, reject) => {
50
+ store.put(index, bytes, (error) => (error ? reject(error) : resolve()));
51
+ });
52
+
53
+ /**
54
+ * @param {SharedPieceStore} store
55
+ * @param {number} index
56
+ * @returns {Promise<Buffer>}
57
+ */
58
+ const get = (store, index) =>
59
+ new Promise((resolve, reject) => {
60
+ store.get(index, (error, bytes) => (error ? reject(error) : resolve(bytes)));
61
+ });
62
+
63
+ test("concurrent puts past capacity never hand two pieces the same slot", async () => {
64
+ const capacity = 4;
65
+ const { store, directory } = await makeStore(capacity);
66
+ try {
67
+ const total = 24;
68
+
69
+ // All at once — the interleaving that a sequential test never produces.
70
+ await Promise.all(
71
+ Array.from({ length: total }, (unused, index) => put(store, index, pieceOf(index)))
72
+ );
73
+
74
+ // Every piece must read back as itself, from memory or from disk. A shared
75
+ // slot shows up here as one piece carrying another's bytes.
76
+ for (let index = 0; index < total; index += 1) {
77
+ const bytes = await get(store, index);
78
+ assert.equal(bytes.length, PIECE, `piece ${index} came back the wrong length`);
79
+ assert.ok(
80
+ bytes.equals(pieceOf(index)),
81
+ `piece ${index} came back as piece ${bytes[0]} — two pieces shared a slot`
82
+ );
83
+ }
84
+
85
+ const stats = store.stats();
86
+ assert.ok(
87
+ stats.resident <= stats.capacity,
88
+ `resident ${stats.resident} exceeds capacity ${stats.capacity}: slots were handed out twice`
89
+ );
90
+ } finally {
91
+ await store.destroy();
92
+ await fs.rm(directory, { recursive: true, force: true });
93
+ }
94
+ });
95
+
96
+ test("a piece caught mid-spill is waited for, not reported missing", async () => {
97
+ const { store, directory } = await makeStore(2);
98
+ try {
99
+ await put(store, 0, pieceOf(0));
100
+ await put(store, 1, pieceOf(1));
101
+
102
+ // Forces piece 0 out while piece 0 is asked for in the same tick.
103
+ const evicting = put(store, 2, pieceOf(2));
104
+ const reading = get(store, 0);
105
+
106
+ await evicting;
107
+ const bytes = await reading;
108
+ assert.ok(bytes.equals(pieceOf(0)), "piece 0 came back wrong while being spilled");
109
+ } finally {
110
+ await store.destroy();
111
+ await fs.rm(directory, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ test("pinned pieces are never evicted, and the pin count is reported", async () => {
116
+ const { store, directory } = await makeStore(2);
117
+ try {
118
+ await put(store, 0, pieceOf(0));
119
+ store.pin(0);
120
+ assert.equal(store.stats().pinned, 1, "pin is not reflected in the stats");
121
+
122
+ await put(store, 1, pieceOf(1));
123
+ await put(store, 2, pieceOf(2));
124
+
125
+ // Piece 0 is pinned, so it must still be the one in memory, not on disk.
126
+ assert.ok(store.locate(0), "a pinned piece was evicted");
127
+
128
+ store.unpin(0);
129
+ assert.equal(store.stats().pinned, 0, "unpin is not reflected in the stats");
130
+ } finally {
131
+ await store.destroy();
132
+ await fs.rm(directory, { recursive: true, force: true });
133
+ }
134
+ });
@@ -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