@torrent-tv/proxy 2.9.71 → 2.9.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 2.9.73
2
+
3
+ - **Fix**: File stats came back as `{}` after the torrent moved to its own thread (2.9.71), which left the loading screen with no peers, no speed and no progress. Two call sites — the stats route and the health report — invoked `getFileStats` **without awaiting**: it used to answer locally and immediately, and now crosses a thread boundary, so the reply was the pending promise itself, serialised to an empty object. Both now await it.
4
+ - **Chore**: Chunk transfer no longer hands over memory the chunk does not own outright. Node allocates small buffers from a shared 8 KB pool — several unrelated buffers occupy one region, each viewing its own slice (verified: a 1 KB buffer reports an 8192-byte region at offset 8) — so transferring that region would detach it from its neighbours. Chunks sourced from the network are small enough to be pooled while local disk reads are not, which is exactly the difference between the field host and the local test. Measured afterwards: pooled chunks cross intact, so this is a correctness guard rather than the cause of the field failure.
5
+
6
+ ## 2.9.72
7
+
8
+ - **Fix**: Playback broke entirely after the torrent moved to its own thread (2.9.71): the torrent was deleted **with its downloaded data** while still being read, after which every read hung and ffmpeg saw an empty input (`Stream ends prematurely at 0, should be 3303133078`), and the container-index read took 73 s to return nothing. Cause: `acquireFile` was dispatched without awaiting while its release was sent normally, so a release could overtake the acquire it belonged to; the reader count then hit zero mid-read and the idle sweep fired (`removed idle torrent ... and its store`). Two fixes, each sufficient alone: the release is now chained onto the acquire so it can never arrive first, and the worker additionally holds the file for the whole duration of the read — a claim that lives inside the read and so cannot be reordered against it. Not reproducible locally, where the test torrent was fully downloaded and never went idle.
9
+
1
10
  ## 2.9.71
2
11
 
3
12
  - **New**: The torrent client now runs on its own thread (`services/torrent-worker/`). Profiling a live seek (2026-08-02) found the main thread ~85% occupied by WebTorrent — buffer concatenation in `uint8-util` ~15%, `_updateWire` and its wrapper ~9%, garbage collection ~5%, and **no piece hashing at all**, which had been the standing assumption — while three of four cores idled. Serving a segment shared that thread, so reading an already-finished 10 MB file off SSD took **12-23 s** where handing it to the channel took 125 ms. Measured after the split, through the real `/stream` route: **3 MB in 0.05-0.12 s** (~500 Mbps), roughly a hundredfold improvement, with main-thread event-loop delay down from 300-390 ms to **28-38 ms**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.71",
3
+ "version": "2.9.73",
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,66 +1,69 @@
1
- import { logger } from "../../../../utils/logger.js";
2
-
3
- /**
4
- * Return download statistics for a registered torrent source.
5
- *
6
- * Provides peer count, transfer speeds, and per-file download progress so
7
- * that the browser client can display meaningful feedback while the proxy is
8
- * pre-fetching file metadata (MOOV atom / EBML headers) before codec probing.
9
- *
10
- * GET /api/sources/:sourceKey/stats?fileIndex=N
11
- *
12
- * @param {import("fastify").FastifyRequest} req
13
- * @param {import("fastify").FastifyReply} reply
14
- * @param {{
15
- * sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
16
- * torrentPool: import("../../../../services/torrent-pool.js").TorrentPool
17
- * }} deps
18
- * @returns {Promise<void>}
19
- */
20
- export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool }) {
21
- const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
22
- if (!sourceKey) {
23
- return reply.code(400).send({ error: "sourceKey is required." });
24
- }
25
-
26
- const sourceRecord = sourceRegistry.get(sourceKey);
27
- if (!sourceRecord) {
28
- return reply.code(404).send({ error: "Source key was not found." });
29
- }
30
-
31
- let torrent;
32
- try {
33
- // getTorrent resolves immediately when the torrent is already loaded.
34
- torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
35
- } catch (error) {
36
- const message = error instanceof Error ? error.message : String(error);
37
- return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
38
- }
39
-
40
- const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
41
- const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
42
-
43
- // Optional: pin the resume window to a FIXED byte offset for the duration of
44
- // one buffering episode (see getFileStats JSDoc) instead of the live, moving
45
- // read position — otherwise "bytes needed" can jump up mid-poll as the window
46
- // slides forward with playback/encoding progress.
47
- const resumeAnchorRaw = typeof req.query.resumeAnchorByteStart === "string" ? req.query.resumeAnchorByteStart : "";
48
- const resumeAnchorByteStart = resumeAnchorRaw !== "" && /^\d+$/.test(resumeAnchorRaw) ? Number(resumeAnchorRaw) : null;
49
-
50
- const stats = torrentPool.getFileStats(torrent, fileIndex, { resumeAnchorByteStart });
51
-
52
- // Diagnostic: surface the real swarm state per poll so a cold-start download
53
- // stall (0 peers / header not advancing → playback-plan blocks on the codec
54
- // probe → browser timeout) is visible in the proxy log.
55
- const downKbps = (stats.downloadSpeed / 1024).toFixed(0);
56
- const filePct = stats.fileProgress != null ? `${(stats.fileProgress * 100).toFixed(1)}%` : "n/a";
57
- const header =
58
- stats.headerBytes != null
59
- ? `${stats.headerDownloadedBytes}/${stats.headerBytes}B`
60
- : "n/a";
61
- logger.info(
62
- `[stats] ${sourceKey.slice(0, 8)} peers=${stats.numPeers} down=${downKbps}KB/s file=${filePct} header=${header}`
63
- );
64
-
65
- return reply.send(stats);
66
- }
1
+ import { logger } from "../../../../utils/logger.js";
2
+
3
+ /**
4
+ * Return download statistics for a registered torrent source.
5
+ *
6
+ * Provides peer count, transfer speeds, and per-file download progress so
7
+ * that the browser client can display meaningful feedback while the proxy is
8
+ * pre-fetching file metadata (MOOV atom / EBML headers) before codec probing.
9
+ *
10
+ * GET /api/sources/:sourceKey/stats?fileIndex=N
11
+ *
12
+ * @param {import("fastify").FastifyRequest} req
13
+ * @param {import("fastify").FastifyReply} reply
14
+ * @param {{
15
+ * sourceRegistry: ReturnType<import("../../../../store/source-registry.js").createSourceRegistry>,
16
+ * torrentPool: import("../../../../services/torrent-pool.js").TorrentPool
17
+ * }} deps
18
+ * @returns {Promise<void>}
19
+ */
20
+ export async function handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool }) {
21
+ const sourceKey = typeof req.params.sourceKey === "string" ? req.params.sourceKey.trim() : "";
22
+ if (!sourceKey) {
23
+ return reply.code(400).send({ error: "sourceKey is required." });
24
+ }
25
+
26
+ const sourceRecord = sourceRegistry.get(sourceKey);
27
+ if (!sourceRecord) {
28
+ return reply.code(404).send({ error: "Source key was not found." });
29
+ }
30
+
31
+ let torrent;
32
+ try {
33
+ // getTorrent resolves immediately when the torrent is already loaded.
34
+ torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
35
+ } catch (error) {
36
+ const message = error instanceof Error ? error.message : String(error);
37
+ return reply.code(500).send({ error: `Failed to load torrent: ${message}` });
38
+ }
39
+
40
+ const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
41
+ const fileIndex = fileIndexRaw !== "" && /^\d+$/.test(fileIndexRaw) ? Number(fileIndexRaw) : null;
42
+
43
+ // Optional: pin the resume window to a FIXED byte offset for the duration of
44
+ // one buffering episode (see getFileStats JSDoc) instead of the live, moving
45
+ // read position — otherwise "bytes needed" can jump up mid-poll as the window
46
+ // slides forward with playback/encoding progress.
47
+ const resumeAnchorRaw = typeof req.query.resumeAnchorByteStart === "string" ? req.query.resumeAnchorByteStart : "";
48
+ const resumeAnchorByteStart = resumeAnchorRaw !== "" && /^\d+$/.test(resumeAnchorRaw) ? Number(resumeAnchorRaw) : null;
49
+
50
+ // Awaited: with the torrent on its own thread this is a round trip, not a
51
+ // local lookup. Without the await the reply was the pending promise itself,
52
+ // which serialises to `{}` — the empty stats seen in the field 2026-08-02.
53
+ const stats = await torrentPool.getFileStats(torrent, fileIndex, { resumeAnchorByteStart });
54
+
55
+ // Diagnostic: surface the real swarm state per poll so a cold-start download
56
+ // stall (0 peers / header not advancing playback-plan blocks on the codec
57
+ // probe → browser timeout) is visible in the proxy log.
58
+ const downKbps = (stats.downloadSpeed / 1024).toFixed(0);
59
+ const filePct = stats.fileProgress != null ? `${(stats.fileProgress * 100).toFixed(1)}%` : "n/a";
60
+ const header =
61
+ stats.headerBytes != null
62
+ ? `${stats.headerDownloadedBytes}/${stats.headerBytes}B`
63
+ : "n/a";
64
+ logger.info(
65
+ `[stats] ${sourceKey.slice(0, 8)} peers=${stats.numPeers} down=${downKbps}KB/s file=${filePct} header=${header}`
66
+ );
67
+
68
+ return reply.send(stats);
69
+ }
package/server.js CHANGED
@@ -144,7 +144,9 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
144
144
  }
145
145
  try {
146
146
  const torrent = await torrentPool.getTorrent(record.sourceType, record.source);
147
- return torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
147
+ // Awaited for the same reason as the stats route: this now crosses a
148
+ // thread boundary and returns a promise.
149
+ return await torrentPool.getFileStats(torrent, Number.isInteger(fileIndex) ? fileIndex : null);
148
150
  } catch {
149
151
  return null;
150
152
  }
@@ -1,222 +1,234 @@
1
- /**
2
- * @file The transport both sides of the torrent worker share.
3
- *
4
- * One place holds the request/reply bookkeeping and the streaming rules, so the
5
- * worker and its main-thread client cannot drift apart on the details that
6
- * matter: which side transfers, which side acknowledges, and when a stream is
7
- * allowed to keep going.
8
- *
9
- * See `protocol.js` for why the design is what it is — every number in it came
10
- * from a measurement, not a preference.
11
- */
12
-
13
- import { Event, STREAM_HIGH_WATER_CHUNKS } from "./protocol.js";
14
-
15
- /**
16
- * Issue request ids that stay unique for the life of a thread.
17
- *
18
- * @returns {() => number}
19
- */
20
- export function createRequestIds() {
21
- let next = 0;
22
- return () => (next += 1);
23
- }
24
-
25
- /**
26
- * Wrap a port's messages into request/reply calls.
27
- *
28
- * Callers get promises; the plumbing of matching replies to requests, and of
29
- * turning a worker-side failure back into a rejection, lives here. `Error`
30
- * objects do not survive a thread boundary, so failures cross as messages and
31
- * are rebuilt into `Error`s on arrival — a caller sees an ordinary rejection.
32
- *
33
- * @param {import("node:worker_threads").MessagePort | import("node:worker_threads").Worker} port
34
- * @returns {{
35
- * call: (command: string, params?: object) => Promise<unknown>,
36
- * handleReply: (message: object) => boolean,
37
- * rejectAll: (reason: Error) => void
38
- * }}
39
- */
40
- export function createCaller(port) {
41
- const nextId = createRequestIds();
42
- const pending = new Map();
43
-
44
- return {
45
- call(command, params = {}) {
46
- return new Promise((resolve, reject) => {
47
- const id = nextId();
48
- pending.set(id, { resolve, reject });
49
- port.postMessage({ command, id, params });
50
- });
51
- },
52
-
53
- /**
54
- * Feed a message in; returns true when it was a reply this caller owned.
55
- */
56
- handleReply(message) {
57
- const entry = pending.get(message?.id);
58
- if (!entry) {
59
- return false;
60
- }
61
- if (message.type === Event.RESULT) {
62
- pending.delete(message.id);
63
- entry.resolve(message.result);
64
- return true;
65
- }
66
- if (message.type === Event.ERROR) {
67
- pending.delete(message.id);
68
- entry.reject(new Error(message.error ?? "Torrent worker request failed."));
69
- return true;
70
- }
71
- return false;
72
- },
73
-
74
- /**
75
- * Fail everything outstanding — the worker died, or is being shut down.
76
- */
77
- rejectAll(reason) {
78
- for (const [, entry] of pending) {
79
- entry.reject(reason);
80
- }
81
- pending.clear();
82
- }
83
- };
84
- }
85
-
86
- /**
87
- * Receive a chunked body as an ordinary `ReadableStream`.
88
- *
89
- * This is the half that makes the design pay off: chunks arrive as transferred
90
- * buffers (no copying), and are handed on through a standard stream, so callers
91
- * treat it exactly like any other body. Each chunk is acknowledged as it is
92
- * enqueued, which is what lets the worker keep only
93
- * {@link STREAM_HIGH_WATER_CHUNKS} in flight.
94
- *
95
- * Cancelling the stream — a viewer navigating away, a superseded seek — sends
96
- * the cancel command, so the worker stops reading rather than filling a queue
97
- * nobody will drain.
98
- *
99
- * @param {object} params
100
- * @param {import("node:worker_threads").Worker} params.port
101
- * @param {number} params.requestId
102
- * @param {() => void} params.onCancel - Sends CANCEL_READ for this request.
103
- * @returns {{ stream: ReadableStream<Uint8Array>, push: (bytes: Uint8Array) => void, close: () => void, fail: (error: Error) => void }}
104
- */
105
- export function createReceiveStream({ port, requestId, onCancel }) {
106
- let controller = null;
107
- let finished = false;
108
-
109
- const stream = new ReadableStream({
110
- start(streamController) {
111
- controller = streamController;
112
- },
113
- cancel() {
114
- if (!finished) {
115
- finished = true;
116
- onCancel();
117
- }
118
- }
119
- });
120
-
121
- return {
122
- stream,
123
-
124
- push(bytes) {
125
- if (finished || !controller) {
126
- return;
127
- }
128
- controller.enqueue(bytes);
129
- // Acknowledge only once the data is in the stream's own queue, so the
130
- // worker's in-flight count reflects what has actually been taken up.
131
- port.postMessage({ type: Event.CHUNK_ACK, id: requestId });
132
- },
133
-
134
- close() {
135
- if (finished || !controller) {
136
- return;
137
- }
138
- finished = true;
139
- controller.close();
140
- },
141
-
142
- fail(error) {
143
- if (finished || !controller) {
144
- return;
145
- }
146
- finished = true;
147
- controller.error(error);
148
- }
149
- };
150
- }
151
-
152
- /**
153
- * Send a body as chunks, pausing when too many are unacknowledged.
154
- *
155
- * The worker side of the same arrangement. `waitForCapacity` resolves when the
156
- * main thread has taken up enough of what was sent; without it a fast disk
157
- * would outrun the channel and rebuild in the message queue exactly the memory
158
- * the transfers were saving.
159
- *
160
- * @param {object} params
161
- * @param {import("node:worker_threads").MessagePort} params.port
162
- * @param {number} params.requestId
163
- * @returns {{ send: (bytes: Buffer) => Promise<void>, end: () => void, ack: () => void, cancel: () => void, isCancelled: () => boolean }}
164
- */
165
- export function createSendStream({ port, requestId }) {
166
- let inFlight = 0;
167
- let cancelled = false;
168
- let wake = null;
169
-
170
- const waitForCapacity = () => {
171
- if (cancelled || inFlight < STREAM_HIGH_WATER_CHUNKS) {
172
- return Promise.resolve();
173
- }
174
- return new Promise((resolve) => {
175
- wake = resolve;
176
- });
177
- };
178
-
179
- return {
180
- async send(bytes) {
181
- await waitForCapacity();
182
- if (cancelled) {
183
- return;
184
- }
185
- inFlight += 1;
186
- // Transfer the underlying memory rather than copying it — the whole point
187
- // of the design, and the difference between 4.8 ms and 37 ms per 10 MB.
188
- port.postMessage(
189
- { type: Event.CHUNK, id: requestId, bytes },
190
- [bytes.buffer]
191
- );
192
- },
193
-
194
- end() {
195
- if (!cancelled) {
196
- port.postMessage({ type: Event.READ_END, id: requestId });
197
- }
198
- },
199
-
200
- ack() {
201
- inFlight = Math.max(0, inFlight - 1);
202
- if (wake) {
203
- const resume = wake;
204
- wake = null;
205
- resume();
206
- }
207
- },
208
-
209
- cancel() {
210
- cancelled = true;
211
- if (wake) {
212
- const resume = wake;
213
- wake = null;
214
- resume();
215
- }
216
- },
217
-
218
- isCancelled() {
219
- return cancelled;
220
- }
221
- };
222
- }
1
+ /**
2
+ * @file The transport both sides of the torrent worker share.
3
+ *
4
+ * One place holds the request/reply bookkeeping and the streaming rules, so the
5
+ * worker and its main-thread client cannot drift apart on the details that
6
+ * matter: which side transfers, which side acknowledges, and when a stream is
7
+ * allowed to keep going.
8
+ *
9
+ * See `protocol.js` for why the design is what it is — every number in it came
10
+ * from a measurement, not a preference.
11
+ */
12
+
13
+ import { Event, STREAM_HIGH_WATER_CHUNKS } from "./protocol.js";
14
+
15
+ /**
16
+ * Issue request ids that stay unique for the life of a thread.
17
+ *
18
+ * @returns {() => number}
19
+ */
20
+ export function createRequestIds() {
21
+ let next = 0;
22
+ return () => (next += 1);
23
+ }
24
+
25
+ /**
26
+ * Wrap a port's messages into request/reply calls.
27
+ *
28
+ * Callers get promises; the plumbing of matching replies to requests, and of
29
+ * turning a worker-side failure back into a rejection, lives here. `Error`
30
+ * objects do not survive a thread boundary, so failures cross as messages and
31
+ * are rebuilt into `Error`s on arrival — a caller sees an ordinary rejection.
32
+ *
33
+ * @param {import("node:worker_threads").MessagePort | import("node:worker_threads").Worker} port
34
+ * @returns {{
35
+ * call: (command: string, params?: object) => Promise<unknown>,
36
+ * handleReply: (message: object) => boolean,
37
+ * rejectAll: (reason: Error) => void
38
+ * }}
39
+ */
40
+ export function createCaller(port) {
41
+ const nextId = createRequestIds();
42
+ const pending = new Map();
43
+
44
+ return {
45
+ call(command, params = {}) {
46
+ return new Promise((resolve, reject) => {
47
+ const id = nextId();
48
+ pending.set(id, { resolve, reject });
49
+ port.postMessage({ command, id, params });
50
+ });
51
+ },
52
+
53
+ /**
54
+ * Feed a message in; returns true when it was a reply this caller owned.
55
+ */
56
+ handleReply(message) {
57
+ const entry = pending.get(message?.id);
58
+ if (!entry) {
59
+ return false;
60
+ }
61
+ if (message.type === Event.RESULT) {
62
+ pending.delete(message.id);
63
+ entry.resolve(message.result);
64
+ return true;
65
+ }
66
+ if (message.type === Event.ERROR) {
67
+ pending.delete(message.id);
68
+ entry.reject(new Error(message.error ?? "Torrent worker request failed."));
69
+ return true;
70
+ }
71
+ return false;
72
+ },
73
+
74
+ /**
75
+ * Fail everything outstanding — the worker died, or is being shut down.
76
+ */
77
+ rejectAll(reason) {
78
+ for (const [, entry] of pending) {
79
+ entry.reject(reason);
80
+ }
81
+ pending.clear();
82
+ }
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Receive a chunked body as an ordinary `ReadableStream`.
88
+ *
89
+ * This is the half that makes the design pay off: chunks arrive as transferred
90
+ * buffers (no copying), and are handed on through a standard stream, so callers
91
+ * treat it exactly like any other body. Each chunk is acknowledged as it is
92
+ * enqueued, which is what lets the worker keep only
93
+ * {@link STREAM_HIGH_WATER_CHUNKS} in flight.
94
+ *
95
+ * Cancelling the stream — a viewer navigating away, a superseded seek — sends
96
+ * the cancel command, so the worker stops reading rather than filling a queue
97
+ * nobody will drain.
98
+ *
99
+ * @param {object} params
100
+ * @param {import("node:worker_threads").Worker} params.port
101
+ * @param {number} params.requestId
102
+ * @param {() => void} params.onCancel - Sends CANCEL_READ for this request.
103
+ * @returns {{ stream: ReadableStream<Uint8Array>, push: (bytes: Uint8Array) => void, close: () => void, fail: (error: Error) => void }}
104
+ */
105
+ export function createReceiveStream({ port, requestId, onCancel }) {
106
+ let controller = null;
107
+ let finished = false;
108
+
109
+ const stream = new ReadableStream({
110
+ start(streamController) {
111
+ controller = streamController;
112
+ },
113
+ cancel() {
114
+ if (!finished) {
115
+ finished = true;
116
+ onCancel();
117
+ }
118
+ }
119
+ });
120
+
121
+ return {
122
+ stream,
123
+
124
+ push(bytes) {
125
+ if (finished || !controller) {
126
+ return;
127
+ }
128
+ controller.enqueue(bytes);
129
+ // Acknowledge only once the data is in the stream's own queue, so the
130
+ // worker's in-flight count reflects what has actually been taken up.
131
+ port.postMessage({ type: Event.CHUNK_ACK, id: requestId });
132
+ },
133
+
134
+ close() {
135
+ if (finished || !controller) {
136
+ return;
137
+ }
138
+ finished = true;
139
+ controller.close();
140
+ },
141
+
142
+ fail(error) {
143
+ if (finished || !controller) {
144
+ return;
145
+ }
146
+ finished = true;
147
+ controller.error(error);
148
+ }
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Send a body as chunks, pausing when too many are unacknowledged.
154
+ *
155
+ * The worker side of the same arrangement. `waitForCapacity` resolves when the
156
+ * main thread has taken up enough of what was sent; without it a fast disk
157
+ * would outrun the channel and rebuild in the message queue exactly the memory
158
+ * the transfers were saving.
159
+ *
160
+ * @param {object} params
161
+ * @param {import("node:worker_threads").MessagePort} params.port
162
+ * @param {number} params.requestId
163
+ * @returns {{ send: (bytes: Buffer) => Promise<void>, end: () => void, ack: () => void, cancel: () => void, isCancelled: () => boolean }}
164
+ */
165
+ export function createSendStream({ port, requestId }) {
166
+ let inFlight = 0;
167
+ let cancelled = false;
168
+ let wake = null;
169
+
170
+ const waitForCapacity = () => {
171
+ if (cancelled || inFlight < STREAM_HIGH_WATER_CHUNKS) {
172
+ return Promise.resolve();
173
+ }
174
+ return new Promise((resolve) => {
175
+ wake = resolve;
176
+ });
177
+ };
178
+
179
+ return {
180
+ async send(bytes) {
181
+ await waitForCapacity();
182
+ if (cancelled) {
183
+ return;
184
+ }
185
+ inFlight += 1;
186
+ // Transfer the underlying memory rather than copying it — the whole point
187
+ // of the design, and the difference between 4.8 ms and 37 ms per 10 MB.
188
+ //
189
+ // But only memory this chunk owns OUTRIGHT may be transferred. Node hands
190
+ // out small buffers from a shared 8 KB pool: several unrelated buffers sit
191
+ // in one region, each viewing its own slice (verified: a 1 KB buffer
192
+ // reports an 8192-byte region at offset 8). Transferring that region
193
+ // detaches it from every other buffer living there — which is what broke
194
+ // reads in the field 2026-08-02: headers were produced in 325 ms and the
195
+ // body never arrived, because chunks from the network are small enough to
196
+ // be pooled while local disk reads, which is all the local test exercised,
197
+ // are not.
198
+ const ownsRegion = bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength;
199
+ const payload = ownsRegion ? bytes : new Uint8Array(bytes);
200
+ port.postMessage(
201
+ { type: Event.CHUNK, id: requestId, bytes: payload },
202
+ [payload.buffer]
203
+ );
204
+ },
205
+
206
+ end() {
207
+ if (!cancelled) {
208
+ port.postMessage({ type: Event.READ_END, id: requestId });
209
+ }
210
+ },
211
+
212
+ ack() {
213
+ inFlight = Math.max(0, inFlight - 1);
214
+ if (wake) {
215
+ const resume = wake;
216
+ wake = null;
217
+ resume();
218
+ }
219
+ },
220
+
221
+ cancel() {
222
+ cancelled = true;
223
+ if (wake) {
224
+ const resume = wake;
225
+ wake = null;
226
+ resume();
227
+ }
228
+ },
229
+
230
+ isCancelled() {
231
+ return cancelled;
232
+ }
233
+ };
234
+ }