@torrent-tv/proxy 2.9.72 → 2.9.74

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,315 @@
1
+ /**
2
+ * @file Torrent pieces in shared memory, with disk as the second tier.
3
+ *
4
+ * Replaces the chunk store WebTorrent would otherwise build for itself. Two
5
+ * reasons, one forced and one chosen.
6
+ *
7
+ * **Forced.** WebTorrent's own piece cache hands out the buffer it keeps using
8
+ * and slices again on the next read. The torrent client now runs on its own
9
+ * thread, and moving a piece to the main thread by transferring ownership
10
+ * detached the cache's memory: proxy 2.9.71-2.9.73 answered every read with an
11
+ * empty body (`Stream ends prematurely at 0`) and the failure was invisible,
12
+ * because the error never reached the reader. Owning the memory ourselves
13
+ * removes the question of whose it was.
14
+ *
15
+ * **Chosen.** Memory this side of the thread boundary can be *shared* memory,
16
+ * which the main thread reads by offset instead of receiving as bytes — see
17
+ * {@link SharedPieceStore#locate}. Measured on the field host: a piece copy
18
+ * costs 3.64 ms, a read from the page cache into a buffer we own 7.63 ms, and
19
+ * re-downloading a piece from the swarm ~1430 ms. So memory first, disk under
20
+ * it, and never the swarm twice.
21
+ *
22
+ * What this deliberately does NOT do is manage the disk as a cache of its own.
23
+ * Pieces evicted from memory are written once and read back on demand; the file
24
+ * is discarded whole when the torrent goes away. libtorrent 2.0 and webtor's
25
+ * seeder both concluded that a hand-rolled disk cache earns less than it costs,
26
+ * and nothing here disagrees.
27
+ */
28
+
29
+ import { PieceLru } from "./piece-lru.js";
30
+ import { DiskTier } from "./disk-tier.js";
31
+
32
+ /** Fallback budget when the caller names none: enough for a comfortable window. */
33
+ const DEFAULT_MEMORY_BYTES = 512 * 1024 * 1024;
34
+ /**
35
+ * Never keep fewer than this many pieces resident, whatever the budget says.
36
+ *
37
+ * Two is the smallest workable number rather than a round one: a piece being
38
+ * read holds its slot, so a second slot must exist for the next piece to land
39
+ * in. With one, a single reader would deadlock the store against itself.
40
+ */
41
+ const MIN_RESIDENT_PIECES = 2;
42
+
43
+ /**
44
+ * A chunk store holding pieces in a `SharedArrayBuffer`, spilling to disk.
45
+ *
46
+ * Implements the `abstract-chunk-store` shape WebTorrent expects — `put`,
47
+ * `get`, `close`, `destroy` — plus {@link locate}, {@link pin} and
48
+ * {@link unpin}, which is how the main thread reads a piece without it being
49
+ * copied or moved.
50
+ */
51
+ export class SharedPieceStore {
52
+ #chunkLength;
53
+ #lastChunkLength;
54
+ #lastChunkIndex;
55
+ #capacity;
56
+ /** @type {SharedArrayBuffer} */
57
+ #shared;
58
+ /** @type {Buffer} A view over the whole pool, for slot arithmetic. */
59
+ #pool;
60
+ /** Piece index → slot number. */
61
+ #slotOf = new Map();
62
+ /** Slot numbers not currently holding a piece. */
63
+ #freeSlots = [];
64
+ #lru;
65
+ #disk;
66
+ #closed = false;
67
+
68
+ /**
69
+ * @param {number} chunkLength - Piece length, and therefore the slot size.
70
+ * @param {object} [options]
71
+ * @param {number} [options.length] - Total torrent length, so the short last piece is sized correctly.
72
+ * @param {number} [options.memoryBytes] - Budget for resident pieces.
73
+ * @param {string} [options.path] - Directory for the spill file.
74
+ * @param {string} [options.name] - Spill file name; must be unique per torrent.
75
+ */
76
+ constructor(chunkLength, options = {}) {
77
+ if (!Number.isInteger(chunkLength) || chunkLength < 1) {
78
+ throw new Error(`Chunk length must be a positive integer, got ${chunkLength}.`);
79
+ }
80
+ this.#chunkLength = chunkLength;
81
+
82
+ const totalLength = Number.isFinite(options.length) ? options.length : 0;
83
+ this.#lastChunkIndex = totalLength > 0 ? Math.ceil(totalLength / chunkLength) - 1 : -1;
84
+ const remainder = totalLength % chunkLength;
85
+ this.#lastChunkLength = remainder === 0 ? chunkLength : remainder;
86
+
87
+ const memoryBytes = Number.isFinite(options.memoryBytes) && options.memoryBytes > 0
88
+ ? options.memoryBytes
89
+ : DEFAULT_MEMORY_BYTES;
90
+ this.#capacity = Math.max(MIN_RESIDENT_PIECES, Math.floor(memoryBytes / chunkLength));
91
+
92
+ this.#shared = new SharedArrayBuffer(this.#capacity * chunkLength);
93
+ this.#pool = Buffer.from(this.#shared);
94
+ for (let slot = 0; slot < this.#capacity; slot += 1) {
95
+ this.#freeSlots.push(slot);
96
+ }
97
+ this.#lru = new PieceLru(this.#capacity);
98
+ this.#disk = new DiskTier({
99
+ directory: options.path ?? ".",
100
+ name: `${options.name ?? "pieces"}.pieces`,
101
+ chunkLength
102
+ });
103
+ }
104
+
105
+ /** `abstract-chunk-store` exposes the piece size under this name. */
106
+ get chunkLength() {
107
+ return this.#chunkLength;
108
+ }
109
+
110
+ /**
111
+ * The pool itself, so another thread can map the same memory and read a piece
112
+ * by the offset {@link locate} reports.
113
+ *
114
+ * @returns {SharedArrayBuffer}
115
+ */
116
+ get sharedBuffer() {
117
+ return this.#shared;
118
+ }
119
+
120
+ /** How many pieces fit in memory at once. */
121
+ get capacity() {
122
+ return this.#capacity;
123
+ }
124
+
125
+ /** How many pieces are resident right now. */
126
+ get residentCount() {
127
+ return this.#slotOf.size;
128
+ }
129
+
130
+ /** How many pieces have been spilled to disk. */
131
+ get spilledCount() {
132
+ return this.#disk.size;
133
+ }
134
+
135
+ /**
136
+ * Length of a given piece — the last one is usually short.
137
+ *
138
+ * @param {number} index
139
+ * @returns {number}
140
+ */
141
+ #lengthOf(index) {
142
+ return index === this.#lastChunkIndex ? this.#lastChunkLength : this.#chunkLength;
143
+ }
144
+
145
+ /**
146
+ * Where a resident piece sits in the shared pool, or `null` if it is not
147
+ * resident.
148
+ *
149
+ * The main thread reads straight from those bytes, so callers MUST hold a pin
150
+ * across the read — see {@link pin}.
151
+ *
152
+ * @param {number} index
153
+ * @returns {{ offset: number, length: number } | null}
154
+ */
155
+ locate(index) {
156
+ const slot = this.#slotOf.get(index);
157
+ if (slot === undefined) {
158
+ return null;
159
+ }
160
+ return { offset: slot * this.#chunkLength, length: this.#lengthOf(index) };
161
+ }
162
+
163
+ /**
164
+ * Hold a piece in memory across a read. Nested; release with {@link unpin}.
165
+ *
166
+ * @param {number} index
167
+ * @returns {void}
168
+ */
169
+ pin(index) {
170
+ this.#lru.pin(index);
171
+ }
172
+
173
+ /**
174
+ * @param {number} index
175
+ * @returns {void}
176
+ */
177
+ unpin(index) {
178
+ this.#lru.unpin(index);
179
+ }
180
+
181
+ /**
182
+ * Make a slot available, spilling the least recently used piece if need be.
183
+ *
184
+ * @returns {Promise<number>} Slot number.
185
+ */
186
+ async #claimSlot() {
187
+ const free = this.#freeSlots.pop();
188
+ if (free !== undefined) {
189
+ return free;
190
+ }
191
+
192
+ const victim = this.#lru.evictionCandidate();
193
+ if (victim === null) {
194
+ // Every resident piece is being read. Taking one anyway is precisely the
195
+ // failure this store exists to make impossible.
196
+ throw new Error("Every resident piece is pinned; no slot can be freed.");
197
+ }
198
+
199
+ const slot = this.#slotOf.get(victim);
200
+ const bytes = this.#pool.subarray(slot * this.#chunkLength, slot * this.#chunkLength + this.#lengthOf(victim));
201
+ await this.#disk.write(victim, bytes);
202
+
203
+ this.#slotOf.delete(victim);
204
+ this.#lru.remove(victim);
205
+ return slot;
206
+ }
207
+
208
+ /**
209
+ * Store a piece.
210
+ *
211
+ * @param {number} index
212
+ * @param {Uint8Array} bytes
213
+ * @param {(error?: Error | null) => void} [callback]
214
+ * @returns {void}
215
+ */
216
+ put(index, bytes, callback = () => undefined) {
217
+ if (this.#closed) {
218
+ queueMicrotask(() => callback(new Error("Piece store is closed.")));
219
+ return;
220
+ }
221
+
222
+ const existing = this.#slotOf.get(index);
223
+ const write = async () => {
224
+ const slot = existing ?? (await this.#claimSlot());
225
+ bytes.copy
226
+ ? bytes.copy(this.#pool, slot * this.#chunkLength)
227
+ : this.#pool.set(bytes, slot * this.#chunkLength);
228
+ this.#slotOf.set(index, slot);
229
+ this.#lru.touch(index);
230
+ // A newer copy is in memory; whatever is on disk is stale.
231
+ this.#disk.forget(index);
232
+ };
233
+
234
+ write().then(() => callback(null), (error) => callback(error));
235
+ }
236
+
237
+ /**
238
+ * Fetch a piece, or a range within it.
239
+ *
240
+ * Returns a buffer of its own rather than a view into the pool: WebTorrent
241
+ * keeps what it is given — to verify a hash, to serve a peer — and the slot
242
+ * underneath may be reused meanwhile. The thread-crossing path avoids this
243
+ * copy entirely by going through {@link locate}.
244
+ *
245
+ * @param {number} index
246
+ * @param {{ offset?: number, length?: number } | ((error: Error | null, bytes?: Buffer) => void)} [options]
247
+ * @param {(error: Error | null, bytes?: Buffer) => void} [callback]
248
+ * @returns {void}
249
+ */
250
+ get(index, options, callback) {
251
+ if (typeof options === "function") {
252
+ return this.get(index, undefined, options);
253
+ }
254
+ const done = callback ?? (() => undefined);
255
+ if (this.#closed) {
256
+ queueMicrotask(() => done(new Error("Piece store is closed.")));
257
+ return;
258
+ }
259
+
260
+ const pieceLength = this.#lengthOf(index);
261
+ const offset = options?.offset ?? 0;
262
+ const length = options?.length ?? pieceLength - offset;
263
+
264
+ const fetch = async () => {
265
+ const slot = this.#slotOf.get(index);
266
+ if (slot !== undefined) {
267
+ this.#lru.touch(index);
268
+ const start = slot * this.#chunkLength + offset;
269
+ return Buffer.from(this.#pool.subarray(start, start + length));
270
+ }
271
+
272
+ if (!this.#disk.has(index)) {
273
+ throw new Error(`Piece ${index} is not in the store.`);
274
+ }
275
+
276
+ // Bring it back into memory: it was just asked for, so it is likely to be
277
+ // asked for again, and the caller may follow up with `locate`.
278
+ const revived = await this.#claimSlot();
279
+ const target = this.#pool.subarray(
280
+ revived * this.#chunkLength,
281
+ revived * this.#chunkLength + pieceLength
282
+ );
283
+ await this.#disk.read(index, target);
284
+ this.#slotOf.set(index, revived);
285
+ this.#lru.touch(index);
286
+ const start = revived * this.#chunkLength + offset;
287
+ return Buffer.from(this.#pool.subarray(start, start + length));
288
+ };
289
+
290
+ fetch().then((bytes) => done(null, bytes), (error) => done(error));
291
+ }
292
+
293
+ /**
294
+ * Close the store, keeping the spill file.
295
+ *
296
+ * @param {(error?: Error | null) => void} [callback]
297
+ * @returns {void}
298
+ */
299
+ close(callback = () => undefined) {
300
+ this.#closed = true;
301
+ this.#disk.close().then(() => callback(null), (error) => callback(error));
302
+ }
303
+
304
+ /**
305
+ * Close the store and delete everything it wrote.
306
+ *
307
+ * @param {(error?: Error | null) => void} [callback]
308
+ * @returns {void}
309
+ */
310
+ destroy(callback = () => undefined) {
311
+ this.#closed = true;
312
+ this.#slotOf.clear();
313
+ this.#disk.destroy().then(() => callback(null), (error) => callback(error));
314
+ }
315
+ }
@@ -12,6 +12,7 @@ import path from "node:path";
12
12
  import { rmSync, statfsSync } from "node:fs";
13
13
  import WebTorrent from "webtorrent";
14
14
  import { logger } from "../utils/logger.js";
15
+ import { SharedPieceStore } from "./piece-store/shared-piece-store.js";
15
16
 
16
17
  // WebTorrent's default download root (see webtorrent lib/torrent.js: TMP =
17
18
  // path.join(os.tmpdir(), 'webtorrent')). We use the default store, so all
@@ -308,6 +309,9 @@ export class TorrentPool {
308
309
  /** Global disk cap in bytes (0 = disabled). */
309
310
  #maxDiskBytes = 0;
310
311
 
312
+ /** Memory budget per torrent for resident pieces; undefined = store default. */
313
+ #memoryBytes;
314
+
311
315
  /** Periodic disk-cap enforcement timer. */
312
316
  #diskSweepTimer = null;
313
317
 
@@ -323,7 +327,9 @@ export class TorrentPool {
323
327
  * default is computed from free disk (min(10 GB, half free)). Pass 0 to
324
328
  * disable the cap.
325
329
  */
326
- constructor({ maxDiskBytes } = {}) {
330
+ constructor({ maxDiskBytes, memoryBytes } = {}) {
331
+ this.#memoryBytes = Number.isFinite(memoryBytes) && memoryBytes > 0 ? memoryBytes : undefined;
332
+
327
333
  // Sweep orphaned torrent data left by a previous hard kill (no graceful
328
334
  // shutdown ran, so destroyAll never cleaned the store). Safe here: no
329
335
  // torrents are loaded yet at construction. Best-effort, synchronous so it
@@ -558,7 +564,17 @@ export class TorrentPool {
558
564
  reject(error);
559
565
  };
560
566
  this.client.once("error", onError);
561
- this.client.add(torrentId, (readyTorrent) => {
567
+ // Our own store, and WebTorrent's piece cache switched off in front of it
568
+ // (`storeCacheSlots: 0`). That cache is what made the thread split fail:
569
+ // it hands out the buffer it keeps and re-slices it later, so moving a
570
+ // piece across threads detached memory still in use. Ours owns what it
571
+ // hands out, holds pieces in shared memory the main thread can read
572
+ // directly, and spills to disk instead of losing them.
573
+ this.client.add(torrentId, {
574
+ store: SharedPieceStore,
575
+ storeCacheSlots: 0,
576
+ storeOpts: { memoryBytes: this.#memoryBytes }
577
+ }, (readyTorrent) => {
562
578
  this.client.off("error", onError);
563
579
  this.torrents.set(key, readyTorrent);
564
580
  this.#lastAccess.set(readyTorrent, Date.now());