@torrent-tv/proxy 2.9.76 → 2.9.78

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.
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @file Who is holding which file open.
3
+ *
4
+ * A claim keeps a file's data from being removed while something is reading it.
5
+ * Until 2.9.77 claims were keyed by `sourceKey:fileIndex`, which quietly made
6
+ * them **shared**: a second reader of the same file found the key taken and
7
+ * added nothing, so the first reader to finish released the claim out from
8
+ * under the second. The proxy reads one file from several places at once —
9
+ * ffmpeg's input, the keyframe index, the codec probe, a second viewer — so
10
+ * this was the normal case, not an edge one.
11
+ *
12
+ * The fix is not a counter. A counter restores the arithmetic but keeps the
13
+ * ambiguity: a release names a file, not a claim, so a duplicate or late
14
+ * release still decrements someone else's hold and nothing can detect it. Here
15
+ * every claim gets its own identity and a release names exactly that claim —
16
+ * so a stray release matches nothing, is reported, and harms no one.
17
+ */
18
+
19
+ /**
20
+ * @typedef {object} FileClaims
21
+ * @property {(sourceKey: string, fileIndex: number, release: () => void) => string} open
22
+ * @property {(claimId: string) => boolean} close
23
+ * @property {() => void} closeAll
24
+ * @property {number} size
25
+ */
26
+
27
+ /**
28
+ * Track file claims by identity.
29
+ *
30
+ * @returns {FileClaims}
31
+ */
32
+ export function createFileClaims() {
33
+ /** Claim id → the function that releases that one claim. */
34
+ const claims = new Map();
35
+ let counter = 0;
36
+
37
+ return {
38
+ /**
39
+ * Record a new claim and return its identity.
40
+ *
41
+ * Each call is a distinct claim even for the same file — that is the whole
42
+ * point.
43
+ *
44
+ * @param {string} sourceKey
45
+ * @param {number} fileIndex
46
+ * @param {() => void} release
47
+ * @returns {string}
48
+ */
49
+ open(sourceKey, fileIndex, release) {
50
+ counter += 1;
51
+ // The file is in the id purely so a log line reads usefully; matching is
52
+ // on the whole string.
53
+ const claimId = `${sourceKey}:${fileIndex}:${counter}`;
54
+ claims.set(claimId, release);
55
+ return claimId;
56
+ },
57
+
58
+ /**
59
+ * Release one claim. Returns false when there was no such claim, which is
60
+ * worth logging: it means a release arrived twice, or after teardown.
61
+ *
62
+ * @param {string} claimId
63
+ * @returns {boolean}
64
+ */
65
+ close(claimId) {
66
+ const release = claims.get(claimId);
67
+ if (!release) {
68
+ return false;
69
+ }
70
+ claims.delete(claimId);
71
+ release();
72
+ return true;
73
+ },
74
+
75
+ /**
76
+ * Release everything — the worker is shutting down.
77
+ *
78
+ * @returns {void}
79
+ */
80
+ closeAll() {
81
+ for (const [, release] of claims) {
82
+ release();
83
+ }
84
+ claims.clear();
85
+ },
86
+
87
+ get size() {
88
+ return claims.size;
89
+ }
90
+ };
91
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * @file Reading a byte range as positions in shared memory, not as bytes.
3
+ *
4
+ * The pieces already live in a `SharedArrayBuffer` the main thread can map. So
5
+ * the torrent thread does not need to hand over any bytes at all: it can say
6
+ * *where* a piece sits and let the other side read it there. What crosses the
7
+ * boundary is two numbers per piece.
8
+ *
9
+ * That is the whole point of the exercise. The alternative — copying each piece
10
+ * into memory we own and transferring it — costs 18.84 ms per 10 MB segment on
11
+ * the field host, and costs it **on the critical path**, in the thread that is
12
+ * also running the torrent, at the moment a viewer is waiting for that segment.
13
+ * Here the copy is gone entirely rather than moved.
14
+ *
15
+ * Two obligations come with it, and both are enforced rather than assumed:
16
+ *
17
+ * - a piece being read is **pinned**, so eviction cannot take the memory out
18
+ * from under the reader mid-read;
19
+ * - the pin is released only once the other thread reports it has finished
20
+ * with those bytes — not when they were sent, because nothing was sent.
21
+ */
22
+
23
+ import { findSharedStore } from "../piece-store/shared-piece-store.js";
24
+
25
+ /**
26
+ * Wait until a piece has been downloaded and verified.
27
+ *
28
+ * WebTorrent announces this as `verified`. The bitfield is re-checked after the
29
+ * listener is attached because the piece can complete in between, and a missed
30
+ * event here would wait forever.
31
+ *
32
+ * @param {import("webtorrent").Torrent} torrent
33
+ * @param {number} index
34
+ * @param {{ isCancelled: () => boolean }} cancellation
35
+ * @returns {Promise<void>}
36
+ */
37
+ function whenPieceReady(torrent, index, cancellation) {
38
+ if (torrent.bitfield?.get(index)) {
39
+ return Promise.resolve();
40
+ }
41
+
42
+ return new Promise((resolve, reject) => {
43
+ /** @param {number} verifiedIndex */
44
+ const onVerified = (verifiedIndex) => {
45
+ if (verifiedIndex === index) {
46
+ cleanup();
47
+ resolve();
48
+ }
49
+ };
50
+ const onDestroyed = () => {
51
+ cleanup();
52
+ reject(new Error(`Torrent went away while waiting for piece ${index}.`));
53
+ };
54
+ // Cancellation is polled rather than pushed: a superseded seek destroys the
55
+ // read, and without this the wait would outlive it and hold a pin.
56
+ const poll = setInterval(() => {
57
+ if (cancellation.isCancelled()) {
58
+ cleanup();
59
+ reject(new Error(`Read cancelled while waiting for piece ${index}.`));
60
+ }
61
+ }, 250);
62
+
63
+ function cleanup() {
64
+ clearInterval(poll);
65
+ torrent.removeListener("verified", onVerified);
66
+ torrent.removeListener("close", onDestroyed);
67
+ }
68
+
69
+ torrent.on("verified", onVerified);
70
+ torrent.once("close", onDestroyed);
71
+
72
+ // The piece may have arrived between the check above and this listener.
73
+ if (torrent.bitfield?.get(index)) {
74
+ cleanup();
75
+ resolve();
76
+ }
77
+ });
78
+ }
79
+
80
+ /**
81
+ * A fragment of a read: where to find it, and how to let it go.
82
+ *
83
+ * @typedef {object} PieceFragment
84
+ * @property {number} pieceIndex
85
+ * @property {number} offset - Byte offset into the shared pool.
86
+ * @property {number} length
87
+ * @property {() => void} release - Drops this fragment's pin. Call exactly once.
88
+ */
89
+
90
+ /**
91
+ * Walk a byte range of a file, yielding each piece's position in shared memory.
92
+ *
93
+ * Yields at most one fragment per piece; the first and last are usually partial.
94
+ * The caller must `release()` every fragment it receives, including on failure —
95
+ * an unreleased pin permanently costs a slot.
96
+ *
97
+ * @param {object} params
98
+ * @param {import("webtorrent").Torrent} params.torrent
99
+ * @param {number} params.fileIndex
100
+ * @param {number} params.start - Inclusive, relative to the file.
101
+ * @param {number} params.end - Inclusive, relative to the file.
102
+ * @param {{ isCancelled: () => boolean }} params.cancellation
103
+ * @returns {AsyncGenerator<PieceFragment>}
104
+ */
105
+ export async function* readFragments({ torrent, fileIndex, start, end, cancellation }) {
106
+ const store = findSharedStore(torrent);
107
+ if (!store) {
108
+ throw new Error("This torrent is not backed by a shared piece store.");
109
+ }
110
+
111
+ const file = torrent.files?.[fileIndex];
112
+ if (!file) {
113
+ throw new Error(`File ${fileIndex} not found.`);
114
+ }
115
+
116
+ const pieceLength = torrent.pieceLength;
117
+ // Piece numbers are torrent-wide, so a file's own offsets have to be lifted
118
+ // into the torrent's address space first.
119
+ const absoluteStart = file.offset + start;
120
+ const absoluteEnd = file.offset + end;
121
+ const firstPiece = Math.floor(absoluteStart / pieceLength);
122
+ const lastPiece = Math.floor(absoluteEnd / pieceLength);
123
+
124
+ // Ask for these pieces first. `select` puts them in the download set at all;
125
+ // `critical` marks them as wanted now, which is what allows a piece to be
126
+ // fetched out of sequential order for a reader that is waiting on it.
127
+ if (typeof torrent.select === "function") {
128
+ torrent.select(firstPiece, lastPiece, 1);
129
+ }
130
+ if (typeof torrent.critical === "function") {
131
+ torrent.critical(firstPiece, lastPiece);
132
+ }
133
+
134
+ for (let pieceIndex = firstPiece; pieceIndex <= lastPiece; pieceIndex += 1) {
135
+ if (cancellation.isCancelled()) {
136
+ return;
137
+ }
138
+
139
+ const pieceStart = pieceIndex * pieceLength;
140
+ const fromWithinPiece = Math.max(absoluteStart, pieceStart) - pieceStart;
141
+ const toWithinPiece = Math.min(absoluteEnd, pieceStart + pieceLength - 1) - pieceStart;
142
+
143
+ await whenPieceReady(torrent, pieceIndex, cancellation);
144
+
145
+ // Pinned BEFORE it is located, and before any await that could let an
146
+ // eviction run: the offset is only meaningful while the piece is held.
147
+ store.pin(pieceIndex);
148
+ let located = null;
149
+ try {
150
+ located = await store.reside(pieceIndex);
151
+ } catch (error) {
152
+ store.unpin(pieceIndex);
153
+ throw error;
154
+ }
155
+
156
+ if (!located) {
157
+ store.unpin(pieceIndex);
158
+ throw new Error(`Piece ${pieceIndex} is verified but absent from the store.`);
159
+ }
160
+
161
+ let releasedThisPiece = false;
162
+ yield {
163
+ pieceIndex,
164
+ offset: located.offset + fromWithinPiece,
165
+ length: toWithinPiece - fromWithinPiece + 1,
166
+ release() {
167
+ if (releasedThisPiece) {
168
+ return;
169
+ }
170
+ releasedThisPiece = true;
171
+ store.unpin(pieceIndex);
172
+ }
173
+ };
174
+ }
175
+ }
@@ -1,179 +1,188 @@
1
- /**
2
- * @file `TorrentPool`'s interface, served from the worker thread.
3
- *
4
- * The routes, the planner, the health report and the session manager all reach
5
- * for a torrent pool and use it the same handful of ways. Rather than rewrite
6
- * every one of them to thread a `sourceKey` through and await what used to be
7
- * immediate, this presents the shape they already expect and does the thread
8
- * hop behind it. Swapping the implementation is then a one-line change at
9
- * construction, and the call sites are untouched — which is what keeps a change
10
- * of this size reviewable.
11
- *
12
- * Two accommodations are needed, and both are deliberate:
13
- *
14
- * - **`acquireFile` and `prioritizeByteRange` stay synchronous.** They return
15
- * nothing the caller inspects, so the command is dispatched and not awaited.
16
- * `acquireFile` hands back a release function exactly as before, which sends
17
- * its own command when called. Awaiting them would mean touching every call
18
- * site for no observable gain.
19
- * - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
20
- * thread, so the worker keys them. Callers that have one pass it; the rest
21
- * get one derived from the source itself, so the identity stays stable
22
- * across calls for the same torrent.
23
- */
24
-
25
- import crypto from "node:crypto";
26
- import { TorrentWorkerClient } from "./client.js";
27
-
28
- /**
29
- * Stable key for a source, matching how the worker keys its torrents.
30
- *
31
- * Derived from the source itself rather than handed out per request, so two
32
- * routes asking for the same torrent name the same thing on the worker side.
33
- *
34
- * @param {"magnet" | "torrent"} sourceType
35
- * @param {string} source
36
- * @returns {string}
37
- */
38
- function deriveSourceKey(sourceType, source) {
39
- return `${sourceType}:${crypto.createHash("sha1").update(source).digest("hex")}`;
40
- }
41
-
42
- /**
43
- * A torrent pool whose work happens on another thread.
44
- *
45
- * See `protocol.js` for why: the torrent was taking ~85% of the main thread and
46
- * everything owed to a viewer queued behind it.
47
- */
48
- export class WorkerTorrentPool {
49
- #client;
50
- /** Stand-ins by source key, so repeat calls return the same object. */
51
- #torrents = new Map();
52
-
53
- /**
54
- * @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
55
- */
56
- constructor(options = {}) {
57
- this.#client = new TorrentWorkerClient(options);
58
- }
59
-
60
- /**
61
- * Load (or join) a torrent and return a stand-in for it.
62
- *
63
- * @param {"magnet" | "torrent"} sourceType
64
- * @param {string} source
65
- * @returns {Promise<object>}
66
- */
67
- async getTorrent(sourceType, source) {
68
- const sourceKey = deriveSourceKey(sourceType, source);
69
- const existing = this.#torrents.get(sourceKey);
70
- if (existing) {
71
- return existing;
72
- }
73
- const torrent = await this.#client.getTorrent({ sourceKey, sourceType, source });
74
- this.#torrents.set(sourceKey, torrent);
75
- return torrent;
76
- }
77
-
78
- /**
79
- * Claim a file for reading; the returned function releases it.
80
- *
81
- * Synchronous by design — see the file header.
82
- *
83
- * @param {object} torrent - A stand-in from {@link getTorrent}.
84
- * @param {number} fileIndex
85
- * @returns {() => void}
86
- */
87
- acquireFile(torrent, fileIndex) {
88
- const sourceKey = torrent?.sourceKey;
89
- if (!sourceKey) {
90
- return () => undefined;
91
- }
92
- // Dispatched, not awaited — callers use the result immediately and inspect
93
- // nothing. But the release MUST NOT overtake it: both are ordinary messages
94
- // to the worker, and if release arrives first the reader count drops to zero
95
- // while a read is still running. The idle sweep then removes the torrent AND
96
- // its downloaded data out from under the encoder — field 2026-08-02:
97
- // "removed idle torrent ... and its store" mid-playback, after which every
98
- // read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
99
- // Chaining the release onto the acquire keeps them in order.
100
- const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() => undefined);
101
- let released = false;
102
- return () => {
103
- if (released) {
104
- return;
105
- }
106
- released = true;
107
- void acquired.then(() => this.#client.releaseFile(sourceKey, fileIndex)).catch(() => undefined);
108
- };
109
- }
110
-
111
- /**
112
- * Live download figures for the progress display.
113
- *
114
- * @param {object} torrent
115
- * @param {number | null} [fileIndex]
116
- * @param {{ resumeAnchorByteStart?: number | null }} [options]
117
- * @returns {Promise<object | null>}
118
- */
119
- async getFileStats(torrent, fileIndex = null, options = {}) {
120
- const sourceKey = torrent?.sourceKey;
121
- if (!sourceKey) {
122
- return null;
123
- }
124
- return this.#client.getFileStats({
125
- sourceKey,
126
- fileIndex,
127
- resumeAnchorByteStart: options?.resumeAnchorByteStart ?? null
128
- });
129
- }
130
-
131
- /**
132
- * Reorder piece selection around a read position.
133
- *
134
- * Synchronous by design — see the file header.
135
- *
136
- * @param {object} torrent
137
- * @param {number} fileIndex
138
- * @param {number} byteStart
139
- * @param {number} [windowBytes]
140
- * @returns {void}
141
- */
142
- prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes) {
143
- const sourceKey = torrent?.sourceKey;
144
- if (!sourceKey) {
145
- return;
146
- }
147
- void this.#client
148
- .prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes })
149
- .catch(() => undefined);
150
- }
151
-
152
- /**
153
- * Pre-fetch the head and tail the codec probe needs.
154
- *
155
- * @param {object} torrent
156
- * @param {number} fileIndex
157
- * @param {number} [headBytes]
158
- * @param {number} [tailBytes]
159
- * @param {number} [timeoutMs]
160
- * @returns {Promise<unknown>}
161
- */
162
- async prefetchFileEdges(torrent, fileIndex, headBytes, tailBytes, timeoutMs) {
163
- const sourceKey = torrent?.sourceKey;
164
- if (!sourceKey) {
165
- return null;
166
- }
167
- return this.#client.prefetchFileEdges({ sourceKey, fileIndex, headBytes, tailBytes, timeoutMs });
168
- }
169
-
170
- /**
171
- * Shut the torrent client down and stop the thread.
172
- *
173
- * @returns {Promise<void>}
174
- */
175
- async destroyAll() {
176
- this.#torrents.clear();
177
- await this.#client.destroyAll();
178
- }
179
- }
1
+ /**
2
+ * @file `TorrentPool`'s interface, served from the worker thread.
3
+ *
4
+ * The routes, the planner, the health report and the session manager all reach
5
+ * for a torrent pool and use it the same handful of ways. Rather than rewrite
6
+ * every one of them to thread a `sourceKey` through and await what used to be
7
+ * immediate, this presents the shape they already expect and does the thread
8
+ * hop behind it. Swapping the implementation is then a one-line change at
9
+ * construction, and the call sites are untouched — which is what keeps a change
10
+ * of this size reviewable.
11
+ *
12
+ * Two accommodations are needed, and both are deliberate:
13
+ *
14
+ * - **`acquireFile` and `prioritizeByteRange` stay synchronous.** They return
15
+ * nothing the caller inspects, so the command is dispatched and not awaited.
16
+ * `acquireFile` hands back a release function exactly as before, which sends
17
+ * its own command when called. Awaiting them would mean touching every call
18
+ * site for no observable gain.
19
+ * - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
20
+ * thread, so the worker keys them. Callers that have one pass it; the rest
21
+ * get one derived from the source itself, so the identity stays stable
22
+ * across calls for the same torrent.
23
+ */
24
+
25
+ import crypto from "node:crypto";
26
+ import { TorrentWorkerClient } from "./client.js";
27
+
28
+ /**
29
+ * Stable key for a source, matching how the worker keys its torrents.
30
+ *
31
+ * Derived from the source itself rather than handed out per request, so two
32
+ * routes asking for the same torrent name the same thing on the worker side.
33
+ *
34
+ * @param {"magnet" | "torrent"} sourceType
35
+ * @param {string} source
36
+ * @returns {string}
37
+ */
38
+ function deriveSourceKey(sourceType, source) {
39
+ return `${sourceType}:${crypto.createHash("sha1").update(source).digest("hex")}`;
40
+ }
41
+
42
+ /**
43
+ * A torrent pool whose work happens on another thread.
44
+ *
45
+ * See `protocol.js` for why: the torrent was taking ~85% of the main thread and
46
+ * everything owed to a viewer queued behind it.
47
+ */
48
+ export class WorkerTorrentPool {
49
+ #client;
50
+ /** Stand-ins by source key, so repeat calls return the same object. */
51
+ #torrents = new Map();
52
+
53
+ /**
54
+ * @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
55
+ */
56
+ constructor(options = {}) {
57
+ this.#client = new TorrentWorkerClient(options);
58
+ }
59
+
60
+ /**
61
+ * Load (or join) a torrent and return a stand-in for it.
62
+ *
63
+ * @param {"magnet" | "torrent"} sourceType
64
+ * @param {string} source
65
+ * @returns {Promise<object>}
66
+ */
67
+ async getTorrent(sourceType, source) {
68
+ const sourceKey = deriveSourceKey(sourceType, source);
69
+ const existing = this.#torrents.get(sourceKey);
70
+ if (existing) {
71
+ return existing;
72
+ }
73
+ const torrent = await this.#client.getTorrent({ sourceKey, sourceType, source });
74
+ this.#torrents.set(sourceKey, torrent);
75
+ return torrent;
76
+ }
77
+
78
+ /**
79
+ * Claim a file for reading; the returned function releases it.
80
+ *
81
+ * Synchronous by design — see the file header.
82
+ *
83
+ * @param {object} torrent - A stand-in from {@link getTorrent}.
84
+ * @param {number} fileIndex
85
+ * @returns {() => void}
86
+ */
87
+ acquireFile(torrent, fileIndex) {
88
+ const sourceKey = torrent?.sourceKey;
89
+ if (!sourceKey) {
90
+ return () => undefined;
91
+ }
92
+ // Dispatched, not awaited — callers use the result immediately and inspect
93
+ // nothing. But the release MUST NOT overtake it: both are ordinary messages
94
+ // to the worker, and if release arrives first the reader count drops to zero
95
+ // while a read is still running. The idle sweep then removes the torrent AND
96
+ // its downloaded data out from under the encoder — field 2026-08-02:
97
+ // "removed idle torrent ... and its store" mid-playback, after which every
98
+ // read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
99
+ // Chaining the release onto the acquire keeps them in order.
100
+ const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() => null);
101
+ let released = false;
102
+ return () => {
103
+ if (released) {
104
+ return;
105
+ }
106
+ released = true;
107
+ // Release the claim this call opened, not "the file" — waiting for the
108
+ // acquire is also what tells us which claim that is.
109
+ void acquired
110
+ .then((claimId) => (claimId ? this.#client.releaseFile(claimId) : undefined))
111
+ .catch(() => undefined);
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Live download figures for the progress display.
117
+ *
118
+ * @param {object} torrent
119
+ * @param {number | null} [fileIndex]
120
+ * @param {{ resumeAnchorByteStart?: number | null }} [options]
121
+ * @returns {Promise<object | null>}
122
+ */
123
+ async getFileStats(torrent, fileIndex = null, options = {}) {
124
+ const sourceKey = torrent?.sourceKey;
125
+ if (!sourceKey) {
126
+ return null;
127
+ }
128
+ return this.#client.getFileStats({
129
+ sourceKey,
130
+ fileIndex,
131
+ resumeAnchorByteStart: options?.resumeAnchorByteStart ?? null
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Reorder piece selection around a read position.
137
+ *
138
+ * Synchronous by design — see the file header.
139
+ *
140
+ * @param {object} torrent
141
+ * @param {number} fileIndex
142
+ * @param {number} byteStart
143
+ * @param {number} [windowBytes]
144
+ * @returns {void}
145
+ */
146
+ prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes) {
147
+ const sourceKey = torrent?.sourceKey;
148
+ if (!sourceKey) {
149
+ return;
150
+ }
151
+ void this.#client
152
+ .prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes })
153
+ .catch(() => undefined);
154
+ }
155
+
156
+ /**
157
+ * Pre-fetch the head and tail the codec probe needs.
158
+ *
159
+ * Takes an options object, matching `TorrentPool.prefetchFileEdges` — this
160
+ * adapter exists to present that same interface. It previously declared
161
+ * positional parameters instead, so the planner's options object arrived as
162
+ * `headBytes` and only worked because it was passed along far enough to be
163
+ * destructured at the far end. Anyone calling it as documented got the
164
+ * defaults instead of the sizes they asked for.
165
+ *
166
+ * @param {object} torrent
167
+ * @param {number} fileIndex
168
+ * @param {{ headBytes?: number, tailBytes?: number, timeoutMs?: number }} [options]
169
+ * @returns {Promise<unknown>}
170
+ */
171
+ async prefetchFileEdges(torrent, fileIndex, options = {}) {
172
+ const sourceKey = torrent?.sourceKey;
173
+ if (!sourceKey) {
174
+ return null;
175
+ }
176
+ return this.#client.prefetchFileEdges({ sourceKey, fileIndex, options });
177
+ }
178
+
179
+ /**
180
+ * Shut the torrent client down and stop the thread.
181
+ *
182
+ * @returns {Promise<void>}
183
+ */
184
+ async destroyAll() {
185
+ this.#torrents.clear();
186
+ await this.#client.destroyAll();
187
+ }
188
+ }
@@ -80,6 +80,18 @@ export const Event = {
80
80
  ERROR: "error",
81
81
  /** One piece of a READ_RANGE body; its bytes are transferred, never copied. */
82
82
  CHUNK: "chunk",
83
+ /**
84
+ * Where a piece of the body sits in the torrent's shared pool — an offset and
85
+ * a length, no bytes at all. The main thread maps the same memory and reads it
86
+ * in place; see `piece-reader.js` for why this replaces sending the bytes.
87
+ */
88
+ FRAGMENT: "fragment",
89
+ /**
90
+ * The main thread has finished with a FRAGMENT and its pin may be dropped.
91
+ * Distinct from {@link CHUNK_ACK}, which only reports queue capacity: this one
92
+ * is a promise that nothing is reading those bytes any more.
93
+ */
94
+ FRAGMENT_DONE: "fragment-done",
83
95
  /** A READ_RANGE ended; no further CHUNKs bear that request id. */
84
96
  READ_END: "read-end",
85
97
  /** The main thread consumed a chunk — see {@link STREAM_HIGH_WATER_CHUNKS}. */