@torrent-tv/proxy 2.9.104 → 2.9.106

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.
@@ -1,663 +1,672 @@
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 os from "node:os";
30
- import { PieceLru } from "./piece-lru.js";
31
- import { DiskTier } from "./disk-tier.js";
32
-
33
- /**
34
- * Live stores, so the worker can report on them.
35
- *
36
- * WebTorrent constructs the store itself, deep inside its own wrappers, so
37
- * there is no handle to reach for from outside. Registering here is what makes
38
- * the store's behaviour visible in the field at all — without it the first
39
- * strange case has nothing to go on.
40
- *
41
- * @type {Set<SharedPieceStore>}
42
- */
43
- const liveStores = new Set();
44
-
45
- /**
46
- * A snapshot of every live store, for logging.
47
- *
48
- * @returns {{ name: string, resident: number, capacity: number, spilled: number, fromMemory: number, fromDisk: number, spills: number, revivals: number, blockedByPins: number }[]}
49
- */
50
- export function collectStoreStats() {
51
- return [...liveStores].map((store) => store.stats());
52
- }
53
-
54
- /**
55
- * The shared store behind a torrent, or `null` if it is not one of ours.
56
- *
57
- * WebTorrent wraps whatever store it is given — today in `ImmediateChunkStore`,
58
- * and historically in a piece cache as well — and offers no way to ask for the
59
- * innermost one. Walking the `store` chain finds it regardless of how many
60
- * wrappers there are or what order they sit in, which is sturdier than reaching
61
- * for a fixed `torrent.store.store`.
62
- *
63
- * @param {{ store?: object } | null | undefined} torrent
64
- * @returns {SharedPieceStore | null}
65
- */
66
- export function findSharedStore(torrent) {
67
- let candidate = torrent?.store;
68
- // Bounded rather than `while (candidate)`: a store that referenced itself
69
- // would otherwise hang the thread instead of failing.
70
- for (let depth = 0; candidate && depth < 8; depth += 1) {
71
- if (candidate instanceof SharedPieceStore) {
72
- return candidate;
73
- }
74
- candidate = candidate.store;
75
- }
76
- return null;
77
- }
78
-
79
- /**
80
- * Ceiling for the automatic budget, and the share of free memory it will take.
81
- *
82
- * A flat default would be a guess dressed as a decision: the proxy runs on
83
- * whatever the owner has, from a Pi to a rented box, and the budget is **per
84
- * torrent** — several viewers mean several of these. Measured on the field host
85
- * after one session: the proxy container sat at 796 MB with 4.1 GB free and 1.3
86
- * GB already in swap, so a fixed half-gigabyte per torrent is not something to
87
- * hand out blindly. Hence: a quarter of what is free, capped.
88
- */
89
- const MEMORY_BUDGET_CEILING_BYTES = 512 * 1024 * 1024;
90
- const FREE_MEMORY_SHARE = 0.25;
91
-
92
- /**
93
- * Budget for one torrent's resident pieces when the caller names none.
94
- *
95
- * @returns {number}
96
- */
97
- function defaultMemoryBytes() {
98
- const share = Math.floor(os.freemem() * FREE_MEMORY_SHARE);
99
- return Math.max(MIN_BUDGET_BYTES, Math.min(MEMORY_BUDGET_CEILING_BYTES, share));
100
- }
101
-
102
- /** Floor for the automatic budget — below this the store thrashes to disk. */
103
- const MIN_BUDGET_BYTES = 64 * 1024 * 1024;
104
- /**
105
- * Never keep fewer than this many pieces resident, whatever the budget says.
106
- *
107
- * Two is the smallest workable number rather than a round one: a piece being
108
- * read holds its slot, so a second slot must exist for the next piece to land
109
- * in. With one, a single reader would deadlock the store against itself.
110
- */
111
- const MIN_RESIDENT_PIECES = 2;
112
-
113
- /**
114
- * A chunk store holding pieces in a `SharedArrayBuffer`, spilling to disk.
115
- *
116
- * Implements the `abstract-chunk-store` shape WebTorrent expects — `put`,
117
- * `get`, `close`, `destroy` — plus {@link locate}, {@link pin} and
118
- * {@link unpin}, which is how the main thread reads a piece without it being
119
- * copied or moved.
120
- */
121
- export class SharedPieceStore {
122
- #chunkLength;
123
- #lastChunkLength;
124
- #lastChunkIndex;
125
- #capacity;
126
- /** @type {SharedArrayBuffer} */
127
- #shared;
128
- /** @type {Buffer} A view over the whole pool, for slot arithmetic. */
129
- #pool;
130
- /** Piece index → slot number. */
131
- #slotOf = new Map();
132
- /** Slot numbers not currently holding a piece. */
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 = [];
156
- #lru;
157
- #disk;
158
- /** Slots backed by memory right now; grows towards {@link capacity}. */
159
- #allocatedSlots = 0;
160
- #closed = false;
161
- #name;
162
- /**
163
- * What the store has actually been doing. Reported, not just kept: the
164
- * balance between memory and disk reads is the number that says whether the
165
- * budget is right, and it cannot be guessed from outside.
166
- */
167
- #counters = {
168
- fromMemory: 0,
169
- fromDisk: 0,
170
- spills: 0,
171
- revivals: 0,
172
- blockedByPins: 0
173
- };
174
-
175
- /**
176
- * @param {number} chunkLength - Piece length, and therefore the slot size.
177
- * @param {object} [options]
178
- * @param {number} [options.length] - Total torrent length, so the short last piece is sized correctly.
179
- * @param {number} [options.memoryBytes] - Budget for resident pieces.
180
- * @param {string} [options.path] - Directory for the spill file.
181
- * @param {string} [options.name] - Spill file name; must be unique per torrent.
182
- */
183
- constructor(chunkLength, options = {}) {
184
- if (!Number.isInteger(chunkLength) || chunkLength < 1) {
185
- throw new Error(`Chunk length must be a positive integer, got ${chunkLength}.`);
186
- }
187
- this.#chunkLength = chunkLength;
188
-
189
- const totalLength = Number.isFinite(options.length) ? options.length : 0;
190
- this.#lastChunkIndex = totalLength > 0 ? Math.ceil(totalLength / chunkLength) - 1 : -1;
191
- const remainder = totalLength % chunkLength;
192
- this.#lastChunkLength = remainder === 0 ? chunkLength : remainder;
193
-
194
- const memoryBytes = Number.isFinite(options.memoryBytes) && options.memoryBytes > 0
195
- ? options.memoryBytes
196
- : defaultMemoryBytes();
197
- this.#capacity = Math.max(MIN_RESIDENT_PIECES, Math.floor(memoryBytes / chunkLength));
198
-
199
- // Grows into the budget instead of taking it up front. The budget is per
200
- // torrent, so claiming all of it on `add` would charge a host for pieces
201
- // nobody has asked for — and a torrent that is merely open, or one being
202
- // probed for its codecs, needs a handful of slots, not the ceiling.
203
- this.#shared = new SharedArrayBuffer(MIN_RESIDENT_PIECES * chunkLength, {
204
- maxByteLength: this.#capacity * chunkLength
205
- });
206
- this.#pool = Buffer.from(this.#shared);
207
- for (let slot = 0; slot < MIN_RESIDENT_PIECES; slot += 1) {
208
- this.#freeSlots.push(slot);
209
- }
210
- this.#allocatedSlots = MIN_RESIDENT_PIECES;
211
- this.#lru = new PieceLru(this.#capacity);
212
- this.#name = options.name ?? "pieces";
213
- this.#disk = new DiskTier({
214
- directory: options.path ?? ".",
215
- name: `${this.#name}.pieces`,
216
- chunkLength
217
- });
218
- liveStores.add(this);
219
- }
220
-
221
- /**
222
- * What this store has been doing, for the periodic report.
223
- *
224
- * @returns {{ name: string, resident: number, capacity: number, spilled: number, fromMemory: number, fromDisk: number, spills: number, revivals: number, blockedByPins: number }}
225
- */
226
- stats() {
227
- return {
228
- name: this.#name,
229
- resident: this.#slotOf.size,
230
- capacity: this.#capacity,
231
- pinned: this.#lru.pinnedCount,
232
- spilled: this.#disk.size,
233
- ...this.#counters
234
- };
235
- }
236
-
237
- /** `abstract-chunk-store` exposes the piece size under this name. */
238
- get chunkLength() {
239
- return this.#chunkLength;
240
- }
241
-
242
- /**
243
- * The pool itself, so another thread can map the same memory and read a piece
244
- * by the offset {@link locate} reports.
245
- *
246
- * @returns {SharedArrayBuffer}
247
- */
248
- get sharedBuffer() {
249
- return this.#shared;
250
- }
251
-
252
- /** How many pieces fit in memory at once. */
253
- get capacity() {
254
- return this.#capacity;
255
- }
256
-
257
- /** How many pieces are resident right now. */
258
- get residentCount() {
259
- return this.#slotOf.size;
260
- }
261
-
262
- /** How many pieces have been spilled to disk. */
263
- get spilledCount() {
264
- return this.#disk.size;
265
- }
266
-
267
- /**
268
- * Length of a given piece — the last one is usually short.
269
- *
270
- * @param {number} index
271
- * @returns {number}
272
- */
273
- #lengthOf(index) {
274
- return index === this.#lastChunkIndex ? this.#lastChunkLength : this.#chunkLength;
275
- }
276
-
277
- /**
278
- * Where a resident piece sits in the shared pool, or `null` if it is not
279
- * resident.
280
- *
281
- * The main thread reads straight from those bytes, so callers MUST hold a pin
282
- * across the read — see {@link pin}.
283
- *
284
- * @param {number} index
285
- * @returns {{ offset: number, length: number } | null}
286
- */
287
- locate(index) {
288
- const slot = this.#slotOf.get(index);
289
- if (slot === undefined) {
290
- return null;
291
- }
292
- return { offset: slot * this.#chunkLength, length: this.#lengthOf(index) };
293
- }
294
-
295
- /**
296
- * Hold a piece in memory across a read. Nested; release with {@link unpin}.
297
- *
298
- * @param {number} index
299
- * @returns {void}
300
- */
301
- pin(index) {
302
- this.#lru.pin(index);
303
- }
304
-
305
- /**
306
- * @param {number} index
307
- * @returns {void}
308
- */
309
- unpin(index) {
310
- this.#lru.unpin(index);
311
- }
312
-
313
- /**
314
- * Make a slot available, spilling the least recently used piece if need be.
315
- *
316
- * @returns {Promise<number>} Slot number.
317
- */
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.
375
- const free = this.#freeSlots.pop();
376
- if (free !== undefined) {
377
- this.#outstandingSlots += 1;
378
- return free;
379
- }
380
-
381
- // Room left in the budget: take more memory rather than evicting. Growing
382
- // replaces the view over the pool, so every slot offset stays valid — the
383
- // bytes do not move.
384
- if (this.#allocatedSlots < this.#capacity) {
385
- this.#allocatedSlots += 1;
386
- this.#shared.grow(this.#allocatedSlots * this.#chunkLength);
387
- this.#pool = Buffer.from(this.#shared);
388
- this.#outstandingSlots += 1;
389
- return this.#allocatedSlots - 1;
390
- }
391
-
392
- const victim = this.#lru.evictionCandidate();
393
- if (victim === null) {
394
- if (this.#evicting.size > 0 || this.#outstandingSlots > 0) {
395
- return null;
396
- }
397
- // Every resident piece is being read. Taking one anyway is precisely the
398
- // failure this store exists to make impossible.
399
- this.#counters.blockedByPins += 1;
400
- throw new Error("Every resident piece is pinned; no slot can be freed.");
401
- }
402
-
403
- const slot = this.#slotOf.get(victim);
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.
411
- this.#slotOf.delete(victim);
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;
428
- return slot;
429
- }
430
-
431
- /**
432
- * Store a piece.
433
- *
434
- * @param {number} index
435
- * @param {Uint8Array} bytes
436
- * @param {(error?: Error | null) => void} [callback]
437
- * @returns {void}
438
- */
439
- put(index, bytes, callback = () => undefined) {
440
- if (this.#closed) {
441
- queueMicrotask(() => callback(new Error("Piece store is closed.")));
442
- return;
443
- }
444
-
445
- const existing = this.#slotOf.get(index);
446
- const write = async () => {
447
- const slot = existing ?? (await this.#claimSlot());
448
- bytes.copy
449
- ? bytes.copy(this.#pool, slot * this.#chunkLength)
450
- : this.#pool.set(bytes, slot * this.#chunkLength);
451
- if (existing === undefined) {
452
- this.#registerSlot(index, slot);
453
- } else {
454
- this.#lru.touch(index);
455
- }
456
- // A newer copy is in memory; whatever is on disk is stale.
457
- this.#disk.forget(index);
458
- };
459
-
460
- write().then(() => callback(null), (error) => callback(error));
461
- }
462
-
463
- /**
464
- * Fetch a piece, or a range within it.
465
- *
466
- * Returns a buffer of its own rather than a view into the pool: WebTorrent
467
- * keeps what it is given — to verify a hash, to serve a peer — and the slot
468
- * underneath may be reused meanwhile. The thread-crossing path avoids this
469
- * copy entirely by going through {@link locate}.
470
- *
471
- * @param {number} index
472
- * @param {{ offset?: number, length?: number } | ((error: Error | null, bytes?: Buffer) => void)} [options]
473
- * @param {(error: Error | null, bytes?: Buffer) => void} [callback]
474
- * @returns {void}
475
- */
476
- get(index, options, callback) {
477
- if (typeof options === "function") {
478
- return this.get(index, undefined, options);
479
- }
480
- const done = callback ?? (() => undefined);
481
- if (this.#closed) {
482
- queueMicrotask(() => done(new Error("Piece store is closed.")));
483
- return;
484
- }
485
-
486
- const pieceLength = this.#lengthOf(index);
487
- const offset = options?.offset ?? 0;
488
- const length = options?.length ?? pieceLength - offset;
489
-
490
- const fetch = async () => {
491
- const slot = this.#slotOf.get(index);
492
- if (slot !== undefined) {
493
- this.#lru.touch(index);
494
- this.#counters.fromMemory += 1;
495
- const start = slot * this.#chunkLength + offset;
496
- return Buffer.from(this.#pool.subarray(start, start + length));
497
- }
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
-
506
- if (!this.#disk.has(index)) {
507
- throw new Error(`Piece ${index} is not in the store.`);
508
- }
509
-
510
- // Bring it back into memory: it was just asked for, so it is likely to be
511
- // asked for again, and the caller may follow up with `locate`.
512
- const revived = await this.#claimSlot();
513
- const target = this.#pool.subarray(
514
- revived * this.#chunkLength,
515
- revived * this.#chunkLength + pieceLength
516
- );
517
- await this.#disk.read(index, target);
518
- this.#registerSlot(index, revived);
519
- this.#counters.fromDisk += 1;
520
- this.#counters.revivals += 1;
521
- const start = revived * this.#chunkLength + offset;
522
- return Buffer.from(this.#pool.subarray(start, start + length));
523
- };
524
-
525
- fetch().then((bytes) => done(null, bytes), (error) => done(error));
526
- }
527
-
528
- /**
529
- * Ensure a piece is in memory and say where it sits — without copying it.
530
- *
531
- * This is {@link get} minus its final copy, and it exists for exactly one
532
- * caller: the reader that hands pieces to the other thread. That thread maps
533
- * the same {@link sharedBuffer}, so an offset and a length are all it needs,
534
- * and the bytes never move. `get` cannot serve that purpose because
535
- * WebTorrent keeps what `get` returns while the slot underneath may be
536
- * reused.
537
- *
538
- * The caller MUST hold a pin across the whole read — the returned offset
539
- * stays valid only while the piece is pinned.
540
- *
541
- * @param {number} index
542
- * @returns {Promise<{ offset: number, length: number } | null>} `null` when
543
- * the store holds no such piece, in memory or on disk.
544
- */
545
- /**
546
- * Declare the pieces a reader is about to need, so eviction takes something
547
- * else while it can. Replaces that reader's previous declaration.
548
- *
549
- * @param {string|number} readerId
550
- * @param {number} from - First piece, inclusive.
551
- * @param {number} to - Last piece, inclusive.
552
- * @returns {void}
553
- */
554
- protectRange(readerId, from, to) {
555
- this.#lru.protect(readerId, from, to);
556
- }
557
-
558
- /**
559
- * Forget a reader's declaration. Call it when the reader ends.
560
- *
561
- * @param {string|number} readerId
562
- * @returns {void}
563
- */
564
- releaseProtection(readerId) {
565
- this.#lru.unprotect(readerId);
566
- }
567
-
568
- /**
569
- * Bring back into memory, in parallel and without waiting, the pieces of a
570
- * range that have been spilled to disk.
571
- *
572
- * A piece is otherwise revived only when the reader arrives at it, one at a
573
- * time and in step with decoding, so a seek backward into content already
574
- * downloaded pays a disk round trip per piece. The disk is local; the whole
575
- * window can be brought back at once while the reader is still on its first
576
- * piece.
577
- *
578
- * Bounded, because each revival needs a slot and unbounded revival of a
579
- * window larger than the store would simply thrash. Errors are swallowed: a
580
- * failed warm-up costs nothing, the reader will ask for the piece properly.
581
- *
582
- * @param {number} from - First piece, inclusive.
583
- * @param {number} to - Last piece, inclusive.
584
- * @param {number} [limit] - Most pieces to revive at once.
585
- * @returns {number} How many revivals were started.
586
- */
587
- warmRange(from, to, limit = Math.max(1, Math.floor(this.#capacity / 4))) {
588
- if (this.#closed || !Number.isInteger(from) || !Number.isInteger(to)) {
589
- return 0;
590
- }
591
- let started = 0;
592
- for (let index = from; index <= to && started < limit; index += 1) {
593
- if (this.#slotOf.has(index) || !this.#disk.has(index)) {
594
- continue;
595
- }
596
- started += 1;
597
- void this.reside(index).catch(() => undefined);
598
- }
599
- return started;
600
- }
601
-
602
- async reside(index) {
603
- if (this.#closed) {
604
- throw new Error("Piece store is closed.");
605
- }
606
-
607
- const slot = this.#slotOf.get(index);
608
- if (slot !== undefined) {
609
- this.#lru.touch(index);
610
- this.#counters.fromMemory += 1;
611
- return this.locate(index);
612
- }
613
-
614
- // Caught mid-spill: the slot is already gone, the disk copy is not there
615
- // yet. Waiting is the only correct answer — reporting it missing would make
616
- // the caller re-download a piece we are in the middle of keeping.
617
- const spill = this.#evicting.get(index);
618
- if (spill) {
619
- await spill.catch(() => undefined);
620
- }
621
-
622
- if (!this.#disk.has(index)) {
623
- return null;
624
- }
625
-
626
- const pieceLength = this.#lengthOf(index);
627
- const revived = await this.#claimSlot();
628
- const target = this.#pool.subarray(
629
- revived * this.#chunkLength,
630
- revived * this.#chunkLength + pieceLength
631
- );
632
- await this.#disk.read(index, target);
633
- this.#registerSlot(index, revived);
634
- this.#counters.fromDisk += 1;
635
- this.#counters.revivals += 1;
636
- return this.locate(index);
637
- }
638
-
639
- /**
640
- * Close the store, keeping the spill file.
641
- *
642
- * @param {(error?: Error | null) => void} [callback]
643
- * @returns {void}
644
- */
645
- close(callback = () => undefined) {
646
- this.#closed = true;
647
- liveStores.delete(this);
648
- this.#disk.close().then(() => callback(null), (error) => callback(error));
649
- }
650
-
651
- /**
652
- * Close the store and delete everything it wrote.
653
- *
654
- * @param {(error?: Error | null) => void} [callback]
655
- * @returns {void}
656
- */
657
- destroy(callback = () => undefined) {
658
- this.#closed = true;
659
- liveStores.delete(this);
660
- this.#slotOf.clear();
661
- this.#disk.destroy().then(() => callback(null), (error) => callback(error));
662
- }
663
- }
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 os from "node:os";
30
+ import { PieceLru } from "./piece-lru.js";
31
+ import { DiskTier } from "./disk-tier.js";
32
+
33
+ /**
34
+ * Live stores, so the worker can report on them.
35
+ *
36
+ * WebTorrent constructs the store itself, deep inside its own wrappers, so
37
+ * there is no handle to reach for from outside. Registering here is what makes
38
+ * the store's behaviour visible in the field at all — without it the first
39
+ * strange case has nothing to go on.
40
+ *
41
+ * @type {Set<SharedPieceStore>}
42
+ */
43
+ const liveStores = new Set();
44
+
45
+ /**
46
+ * A snapshot of every live store, for logging.
47
+ *
48
+ * @returns {{ name: string, resident: number, capacity: number, spilled: number, fromMemory: number, fromDisk: number, spills: number, revivals: number, blockedByPins: number }[]}
49
+ */
50
+ export function collectStoreStats() {
51
+ return [...liveStores].map((store) => store.stats());
52
+ }
53
+
54
+ /**
55
+ * The shared store behind a torrent, or `null` if it is not one of ours.
56
+ *
57
+ * WebTorrent wraps whatever store it is given — today in `ImmediateChunkStore`,
58
+ * and historically in a piece cache as well — and offers no way to ask for the
59
+ * innermost one. Walking the `store` chain finds it regardless of how many
60
+ * wrappers there are or what order they sit in, which is sturdier than reaching
61
+ * for a fixed `torrent.store.store`.
62
+ *
63
+ * @param {{ store?: object } | null | undefined} torrent
64
+ * @returns {SharedPieceStore | null}
65
+ */
66
+ export function findSharedStore(torrent) {
67
+ let candidate = torrent?.store;
68
+ // Bounded rather than `while (candidate)`: a store that referenced itself
69
+ // would otherwise hang the thread instead of failing.
70
+ for (let depth = 0; candidate && depth < 8; depth += 1) {
71
+ if (candidate instanceof SharedPieceStore) {
72
+ return candidate;
73
+ }
74
+ candidate = candidate.store;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * Ceiling for the automatic budget, and the share of free memory it will take.
81
+ *
82
+ * A flat default would be a guess dressed as a decision: the proxy runs on
83
+ * whatever the owner has, from a Pi to a rented box, and the budget is **per
84
+ * torrent** — several viewers mean several of these. Measured on the field host
85
+ * after one session: the proxy container sat at 796 MB with 4.1 GB free and 1.3
86
+ * GB already in swap, so a fixed half-gigabyte per torrent is not something to
87
+ * hand out blindly. Hence: a quarter of what is free, capped.
88
+ */
89
+ const MEMORY_BUDGET_CEILING_BYTES = 512 * 1024 * 1024;
90
+ const FREE_MEMORY_SHARE = 0.25;
91
+
92
+ /**
93
+ * Budget for one torrent's resident pieces when the caller names none.
94
+ *
95
+ * @returns {number}
96
+ */
97
+ function defaultMemoryBytes() {
98
+ const share = Math.floor(os.freemem() * FREE_MEMORY_SHARE);
99
+ return Math.max(MIN_BUDGET_BYTES, Math.min(MEMORY_BUDGET_CEILING_BYTES, share));
100
+ }
101
+
102
+ /** Floor for the automatic budget — below this the store thrashes to disk. */
103
+ const MIN_BUDGET_BYTES = 64 * 1024 * 1024;
104
+ /**
105
+ * Never keep fewer than this many pieces resident, whatever the budget says.
106
+ *
107
+ * Two is the smallest workable number rather than a round one: a piece being
108
+ * read holds its slot, so a second slot must exist for the next piece to land
109
+ * in. With one, a single reader would deadlock the store against itself.
110
+ */
111
+ const MIN_RESIDENT_PIECES = 2;
112
+
113
+ /**
114
+ * A chunk store holding pieces in a `SharedArrayBuffer`, spilling to disk.
115
+ *
116
+ * Implements the `abstract-chunk-store` shape WebTorrent expects — `put`,
117
+ * `get`, `close`, `destroy` — plus {@link locate}, {@link pin} and
118
+ * {@link unpin}, which is how the main thread reads a piece without it being
119
+ * copied or moved.
120
+ */
121
+ export class SharedPieceStore {
122
+ #chunkLength;
123
+ #lastChunkLength;
124
+ #lastChunkIndex;
125
+ #capacity;
126
+ /** @type {SharedArrayBuffer} */
127
+ #shared;
128
+ /** @type {Buffer} A view over the whole pool, for slot arithmetic. */
129
+ #pool;
130
+ /** Piece index → slot number. */
131
+ #slotOf = new Map();
132
+ /** Slot numbers not currently holding a piece. */
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 = [];
156
+ #lru;
157
+ #disk;
158
+ /** Slots backed by memory right now; grows towards {@link capacity}. */
159
+ #allocatedSlots = 0;
160
+ #closed = false;
161
+ #name;
162
+ /**
163
+ * What the store has actually been doing. Reported, not just kept: the
164
+ * balance between memory and disk reads is the number that says whether the
165
+ * budget is right, and it cannot be guessed from outside.
166
+ */
167
+ #counters = {
168
+ fromMemory: 0,
169
+ fromDisk: 0,
170
+ spills: 0,
171
+ revivals: 0,
172
+ blockedByPins: 0
173
+ };
174
+
175
+ /**
176
+ * @param {number} chunkLength - Piece length, and therefore the slot size.
177
+ * @param {object} [options]
178
+ * @param {number} [options.length] - Total torrent length, so the short last piece is sized correctly.
179
+ * @param {number} [options.memoryBytes] - Budget for resident pieces.
180
+ * @param {string} [options.path] - Directory for the spill file.
181
+ * @param {string} [options.name] - Spill file name; must be unique per torrent.
182
+ */
183
+ constructor(chunkLength, options = {}) {
184
+ if (!Number.isInteger(chunkLength) || chunkLength < 1) {
185
+ throw new Error(`Chunk length must be a positive integer, got ${chunkLength}.`);
186
+ }
187
+ this.#chunkLength = chunkLength;
188
+
189
+ const totalLength = Number.isFinite(options.length) ? options.length : 0;
190
+ this.#lastChunkIndex = totalLength > 0 ? Math.ceil(totalLength / chunkLength) - 1 : -1;
191
+ const remainder = totalLength % chunkLength;
192
+ this.#lastChunkLength = remainder === 0 ? chunkLength : remainder;
193
+
194
+ const memoryBytes = Number.isFinite(options.memoryBytes) && options.memoryBytes > 0
195
+ ? options.memoryBytes
196
+ : defaultMemoryBytes();
197
+ this.#capacity = Math.max(MIN_RESIDENT_PIECES, Math.floor(memoryBytes / chunkLength));
198
+
199
+ // Grows into the budget instead of taking it up front. The budget is per
200
+ // torrent, so claiming all of it on `add` would charge a host for pieces
201
+ // nobody has asked for — and a torrent that is merely open, or one being
202
+ // probed for its codecs, needs a handful of slots, not the ceiling.
203
+ this.#shared = new SharedArrayBuffer(MIN_RESIDENT_PIECES * chunkLength, {
204
+ maxByteLength: this.#capacity * chunkLength
205
+ });
206
+ this.#pool = Buffer.from(this.#shared);
207
+ for (let slot = 0; slot < MIN_RESIDENT_PIECES; slot += 1) {
208
+ this.#freeSlots.push(slot);
209
+ }
210
+ this.#allocatedSlots = MIN_RESIDENT_PIECES;
211
+ this.#lru = new PieceLru(this.#capacity);
212
+ this.#name = options.name ?? "pieces";
213
+ this.#disk = new DiskTier({
214
+ directory: options.path ?? ".",
215
+ name: `${this.#name}.pieces`,
216
+ chunkLength
217
+ });
218
+ liveStores.add(this);
219
+ }
220
+
221
+ /**
222
+ * What this store has been doing, for the periodic report.
223
+ *
224
+ * @returns {{ name: string, resident: number, capacity: number, spilled: number, fromMemory: number, fromDisk: number, spills: number, revivals: number, blockedByPins: number }}
225
+ */
226
+ stats() {
227
+ return {
228
+ name: this.#name,
229
+ resident: this.#slotOf.size,
230
+ capacity: this.#capacity,
231
+ pinned: this.#lru.pinnedCount,
232
+ spilled: this.#disk.size,
233
+ ...this.#counters
234
+ };
235
+ }
236
+
237
+ /** `abstract-chunk-store` exposes the piece size under this name. */
238
+ get chunkLength() {
239
+ return this.#chunkLength;
240
+ }
241
+
242
+ /**
243
+ * The pool itself, so another thread can map the same memory and read a piece
244
+ * by the offset {@link locate} reports.
245
+ *
246
+ * @returns {SharedArrayBuffer}
247
+ */
248
+ get sharedBuffer() {
249
+ return this.#shared;
250
+ }
251
+
252
+ /** How many pieces fit in memory at once. */
253
+ get capacity() {
254
+ return this.#capacity;
255
+ }
256
+
257
+ /** How many pieces are resident right now. */
258
+ get residentCount() {
259
+ return this.#slotOf.size;
260
+ }
261
+
262
+ /** How many pieces have been spilled to disk. */
263
+ get spilledCount() {
264
+ return this.#disk.size;
265
+ }
266
+
267
+ /**
268
+ * Length of a given piece — the last one is usually short.
269
+ *
270
+ * @param {number} index
271
+ * @returns {number}
272
+ */
273
+ #lengthOf(index) {
274
+ return index === this.#lastChunkIndex ? this.#lastChunkLength : this.#chunkLength;
275
+ }
276
+
277
+ /**
278
+ * Where a resident piece sits in the shared pool, or `null` if it is not
279
+ * resident.
280
+ *
281
+ * The main thread reads straight from those bytes, so callers MUST hold a pin
282
+ * across the read — see {@link pin}.
283
+ *
284
+ * @param {number} index
285
+ * @returns {{ offset: number, length: number } | null}
286
+ */
287
+ locate(index) {
288
+ const slot = this.#slotOf.get(index);
289
+ if (slot === undefined) {
290
+ return null;
291
+ }
292
+ return { offset: slot * this.#chunkLength, length: this.#lengthOf(index) };
293
+ }
294
+
295
+ /**
296
+ * Hold a piece in memory across a read. Nested; release with {@link unpin}.
297
+ *
298
+ * @param {number} index
299
+ * @returns {void}
300
+ */
301
+ pin(index) {
302
+ this.#lru.pin(index);
303
+ }
304
+
305
+ /**
306
+ * @param {number} index
307
+ * @returns {void}
308
+ */
309
+ unpin(index) {
310
+ this.#lru.unpin(index);
311
+ }
312
+
313
+ /**
314
+ * Make a slot available, spilling the least recently used piece if need be.
315
+ *
316
+ * @returns {Promise<number>} Slot number.
317
+ */
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.
375
+ const free = this.#freeSlots.pop();
376
+ if (free !== undefined) {
377
+ this.#outstandingSlots += 1;
378
+ return free;
379
+ }
380
+
381
+ // Room left in the budget: take more memory rather than evicting. Growing
382
+ // replaces the view over the pool, so every slot offset stays valid — the
383
+ // bytes do not move.
384
+ if (this.#allocatedSlots < this.#capacity) {
385
+ this.#allocatedSlots += 1;
386
+ this.#shared.grow(this.#allocatedSlots * this.#chunkLength);
387
+ this.#pool = Buffer.from(this.#shared);
388
+ this.#outstandingSlots += 1;
389
+ return this.#allocatedSlots - 1;
390
+ }
391
+
392
+ const victim = this.#lru.evictionCandidate();
393
+ if (victim === null) {
394
+ if (this.#evicting.size > 0 || this.#outstandingSlots > 0) {
395
+ return null;
396
+ }
397
+ // Every resident piece is being read. Taking one anyway is precisely the
398
+ // failure this store exists to make impossible.
399
+ this.#counters.blockedByPins += 1;
400
+ throw new Error("Every resident piece is pinned; no slot can be freed.");
401
+ }
402
+
403
+ const slot = this.#slotOf.get(victim);
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.
411
+ this.#slotOf.delete(victim);
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;
428
+ return slot;
429
+ }
430
+
431
+ /**
432
+ * Store a piece.
433
+ *
434
+ * @param {number} index
435
+ * @param {Uint8Array} bytes
436
+ * @param {(error?: Error | null) => void} [callback]
437
+ * @returns {void}
438
+ */
439
+ put(index, bytes, callback = () => undefined) {
440
+ if (this.#closed) {
441
+ queueMicrotask(() => callback(new Error("Piece store is closed.")));
442
+ return;
443
+ }
444
+
445
+ const existing = this.#slotOf.get(index);
446
+ const write = async () => {
447
+ const slot = existing ?? (await this.#claimSlot());
448
+ bytes.copy
449
+ ? bytes.copy(this.#pool, slot * this.#chunkLength)
450
+ : this.#pool.set(bytes, slot * this.#chunkLength);
451
+ if (existing === undefined) {
452
+ this.#registerSlot(index, slot);
453
+ } else {
454
+ this.#lru.touch(index);
455
+ }
456
+ // A newer copy is in memory; whatever is on disk is stale.
457
+ this.#disk.forget(index);
458
+ };
459
+
460
+ write().then(() => callback(null), (error) => callback(error));
461
+ }
462
+
463
+ /**
464
+ * Fetch a piece, or a range within it.
465
+ *
466
+ * Returns a buffer of its own rather than a view into the pool: WebTorrent
467
+ * keeps what it is given — to verify a hash, to serve a peer — and the slot
468
+ * underneath may be reused meanwhile. The thread-crossing path avoids this
469
+ * copy entirely by going through {@link locate}.
470
+ *
471
+ * @param {number} index
472
+ * @param {{ offset?: number, length?: number } | ((error: Error | null, bytes?: Buffer) => void)} [options]
473
+ * @param {(error: Error | null, bytes?: Buffer) => void} [callback]
474
+ * @returns {void}
475
+ */
476
+ get(index, options, callback) {
477
+ if (typeof options === "function") {
478
+ return this.get(index, undefined, options);
479
+ }
480
+ const done = callback ?? (() => undefined);
481
+ if (this.#closed) {
482
+ queueMicrotask(() => done(new Error("Piece store is closed.")));
483
+ return;
484
+ }
485
+
486
+ const pieceLength = this.#lengthOf(index);
487
+ const offset = options?.offset ?? 0;
488
+ const length = options?.length ?? pieceLength - offset;
489
+
490
+ const fetch = async () => {
491
+ const slot = this.#slotOf.get(index);
492
+ if (slot !== undefined) {
493
+ this.#lru.touch(index);
494
+ this.#counters.fromMemory += 1;
495
+ const start = slot * this.#chunkLength + offset;
496
+ return Buffer.from(this.#pool.subarray(start, start + length));
497
+ }
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
+
506
+ if (!this.#disk.has(index)) {
507
+ throw new Error(`Piece ${index} is not in the store.`);
508
+ }
509
+
510
+ // Bring it back into memory: it was just asked for, so it is likely to be
511
+ // asked for again, and the caller may follow up with `locate`.
512
+ const revived = await this.#claimSlot();
513
+ const target = this.#pool.subarray(
514
+ revived * this.#chunkLength,
515
+ revived * this.#chunkLength + pieceLength
516
+ );
517
+ await this.#disk.read(index, target);
518
+ this.#registerSlot(index, revived);
519
+ this.#counters.fromDisk += 1;
520
+ this.#counters.revivals += 1;
521
+ const start = revived * this.#chunkLength + offset;
522
+ return Buffer.from(this.#pool.subarray(start, start + length));
523
+ };
524
+
525
+ fetch().then((bytes) => done(null, bytes), (error) => done(error));
526
+ }
527
+
528
+ /**
529
+ * Ensure a piece is in memory and say where it sits — without copying it.
530
+ *
531
+ * This is {@link get} minus its final copy, and it exists for exactly one
532
+ * caller: the reader that hands pieces to the other thread. That thread maps
533
+ * the same {@link sharedBuffer}, so an offset and a length are all it needs,
534
+ * and the bytes never move. `get` cannot serve that purpose because
535
+ * WebTorrent keeps what `get` returns while the slot underneath may be
536
+ * reused.
537
+ *
538
+ * The caller MUST hold a pin across the whole read — the returned offset
539
+ * stays valid only while the piece is pinned.
540
+ *
541
+ * @param {number} index
542
+ * @returns {Promise<{ offset: number, length: number } | null>} `null` when
543
+ * the store holds no such piece, in memory or on disk.
544
+ */
545
+ /**
546
+ * Declare the pieces a reader is about to need, so eviction takes something
547
+ * else while it can. Replaces that reader's previous declaration.
548
+ *
549
+ * @param {string|number} readerId
550
+ * @param {number} from - First piece, inclusive.
551
+ * @param {number} to - Last piece, inclusive.
552
+ * @returns {void}
553
+ */
554
+ protectRange(readerId, from, to) {
555
+ this.#lru.protect(readerId, from, to);
556
+ }
557
+
558
+ /**
559
+ * Forget a reader's declaration. Call it when the reader ends.
560
+ *
561
+ * @param {string|number} readerId
562
+ * @returns {void}
563
+ */
564
+ /**
565
+ * The windows live readers have declared. See {@link PieceLru.protectedRanges}.
566
+ *
567
+ * @returns {Array<{ from: number, to: number }>}
568
+ */
569
+ protectedRanges() {
570
+ return this.#lru.protectedRanges();
571
+ }
572
+
573
+ releaseProtection(readerId) {
574
+ this.#lru.unprotect(readerId);
575
+ }
576
+
577
+ /**
578
+ * Bring back into memory, in parallel and without waiting, the pieces of a
579
+ * range that have been spilled to disk.
580
+ *
581
+ * A piece is otherwise revived only when the reader arrives at it, one at a
582
+ * time and in step with decoding, so a seek backward into content already
583
+ * downloaded pays a disk round trip per piece. The disk is local; the whole
584
+ * window can be brought back at once while the reader is still on its first
585
+ * piece.
586
+ *
587
+ * Bounded, because each revival needs a slot and unbounded revival of a
588
+ * window larger than the store would simply thrash. Errors are swallowed: a
589
+ * failed warm-up costs nothing, the reader will ask for the piece properly.
590
+ *
591
+ * @param {number} from - First piece, inclusive.
592
+ * @param {number} to - Last piece, inclusive.
593
+ * @param {number} [limit] - Most pieces to revive at once.
594
+ * @returns {number} How many revivals were started.
595
+ */
596
+ warmRange(from, to, limit = Math.max(1, Math.floor(this.#capacity / 4))) {
597
+ if (this.#closed || !Number.isInteger(from) || !Number.isInteger(to)) {
598
+ return 0;
599
+ }
600
+ let started = 0;
601
+ for (let index = from; index <= to && started < limit; index += 1) {
602
+ if (this.#slotOf.has(index) || !this.#disk.has(index)) {
603
+ continue;
604
+ }
605
+ started += 1;
606
+ void this.reside(index).catch(() => undefined);
607
+ }
608
+ return started;
609
+ }
610
+
611
+ async reside(index) {
612
+ if (this.#closed) {
613
+ throw new Error("Piece store is closed.");
614
+ }
615
+
616
+ const slot = this.#slotOf.get(index);
617
+ if (slot !== undefined) {
618
+ this.#lru.touch(index);
619
+ this.#counters.fromMemory += 1;
620
+ return this.locate(index);
621
+ }
622
+
623
+ // Caught mid-spill: the slot is already gone, the disk copy is not there
624
+ // yet. Waiting is the only correct answer — reporting it missing would make
625
+ // the caller re-download a piece we are in the middle of keeping.
626
+ const spill = this.#evicting.get(index);
627
+ if (spill) {
628
+ await spill.catch(() => undefined);
629
+ }
630
+
631
+ if (!this.#disk.has(index)) {
632
+ return null;
633
+ }
634
+
635
+ const pieceLength = this.#lengthOf(index);
636
+ const revived = await this.#claimSlot();
637
+ const target = this.#pool.subarray(
638
+ revived * this.#chunkLength,
639
+ revived * this.#chunkLength + pieceLength
640
+ );
641
+ await this.#disk.read(index, target);
642
+ this.#registerSlot(index, revived);
643
+ this.#counters.fromDisk += 1;
644
+ this.#counters.revivals += 1;
645
+ return this.locate(index);
646
+ }
647
+
648
+ /**
649
+ * Close the store, keeping the spill file.
650
+ *
651
+ * @param {(error?: Error | null) => void} [callback]
652
+ * @returns {void}
653
+ */
654
+ close(callback = () => undefined) {
655
+ this.#closed = true;
656
+ liveStores.delete(this);
657
+ this.#disk.close().then(() => callback(null), (error) => callback(error));
658
+ }
659
+
660
+ /**
661
+ * Close the store and delete everything it wrote.
662
+ *
663
+ * @param {(error?: Error | null) => void} [callback]
664
+ * @returns {void}
665
+ */
666
+ destroy(callback = () => undefined) {
667
+ this.#closed = true;
668
+ liveStores.delete(this);
669
+ this.#slotOf.clear();
670
+ this.#disk.destroy().then(() => callback(null), (error) => callback(error));
671
+ }
672
+ }