@torrent-tv/proxy 2.64.5 → 2.64.7

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.
@@ -23,6 +23,17 @@
23
23
  * Evicting a piece deletes its entry and the memory is reclaimable by GC.
24
24
  * `committed` therefore equals `resident`, not a high-water mark.
25
25
  *
26
+ * **Room for a piece is an owned reservation, not a shared number.**
27
+ * `#claimSlot` hands back a release the caller runs in a `finally`; nothing
28
+ * else touches `#outstandingPieces`. It was a counter incremented in one
29
+ * function and decremented in another, and every consequence of that was a
30
+ * defect: a failure between the two lost a slot for the life of the process,
31
+ * `put`'s error path guessed at the correction and could take back a
32
+ * reservation belonging to a different claim, and one lost reservation made
33
+ * the five-second "everything is pinned" error permanently unreachable, so a
34
+ * read retried every 50 ms for ever without completing or failing. Read out of
35
+ * the field failure of 2026-08-31 (`research/worker-heap-oom-2026-08-31.md`).
36
+ *
26
37
  * What this deliberately does NOT do is manage the disk as a cache of its own.
27
38
  * Pieces evicted from memory are written once and read back on demand; the file
28
39
  * is discarded whole when the torrent goes away. libtorrent 2.0 and webtor's
@@ -98,6 +109,15 @@ function availableMemorySync() {
98
109
 
99
110
  const MIN_BUDGET_BYTES = 64 * 1024 * 1024;
100
111
  const MIN_RESIDENT_PIECES = 2;
112
+ /**
113
+ * How long a claim may go without ANYTHING moving before it gives up.
114
+ *
115
+ * Measured against progress, not against activity. The earlier rule skipped
116
+ * this timer entirely while a spill was in flight or a reservation was held —
117
+ * so one reservation that was never returned made the timer unreachable and a
118
+ * read retried every 50 ms for the life of the process, never completing and
119
+ * never failing (field 2026-08-31).
120
+ */
101
121
  const PINNED_WAIT_MS = 5_000;
102
122
  const CLAIM_RETRY_MS = 50;
103
123
 
@@ -111,8 +131,21 @@ export class SharedPieceStore {
111
131
  #buffers = new Map();
112
132
  /** @type {Map<number, Promise<void>>} */
113
133
  #evicting = new Map();
134
+ /**
135
+ * Slots claimed but not yet filled.
136
+ *
137
+ * Handed out by {@link SharedPieceStore##claimSlot} as a release function the
138
+ * caller must call in a `finally`, never as a number one function increments
139
+ * and another decrements. The counter used to be paired across
140
+ * `#claimSlot`/`#registerPiece`, so any failure between the two lost a slot
141
+ * for the life of the process, and `put`'s error path tried to correct that
142
+ * by guessing — which could take back a reservation belonging to a different
143
+ * claim and let the store admit past its allowance.
144
+ */
114
145
  #outstandingPieces = 0;
115
146
  #pinnedWaitStartedAt = 0;
147
+ /** When something last actually moved: a piece admitted, spilled or unpinned. */
148
+ #lastProgressAt = 0;
116
149
  #waiters = [];
117
150
  #lru;
118
151
  #disk;
@@ -125,7 +158,8 @@ export class SharedPieceStore {
125
158
  revivals: 0,
126
159
  blockedByPins: 0,
127
160
  waitedForPins: 0,
128
- evictedOnRevise: 0
161
+ evictedOnRevise: 0,
162
+ spillFailures: 0
129
163
  };
130
164
 
131
165
  constructor(chunkLength, options = {}) {
@@ -147,7 +181,11 @@ export class SharedPieceStore {
147
181
  this.#growthCeiling = this.#capacity;
148
182
  this.#lru = new PieceLru(this.#capacity);
149
183
  this.#name = options.name ?? "pieces";
150
- this.#disk = new DiskTier({
184
+ // `options.disk` exists so a test can hold a write open or make one fail on
185
+ // purpose. Four of the defects fixed here live in what happens when the
186
+ // disk tier does not answer immediately or at all, and none of them is
187
+ // reachable from outside without saying so.
188
+ this.#disk = options.disk ?? new DiskTier({
151
189
  directory: options.path ?? ".",
152
190
  name: `${this.#name}.pieces`,
153
191
  chunkLength
@@ -168,6 +206,10 @@ export class SharedPieceStore {
168
206
  committedBytes: residentBytes,
169
207
  budgetBytes: this.#growthCeiling * this.#chunkLength,
170
208
  pinned: this.#lru.pinnedCount,
209
+ // Slots claimed and not yet filled. Reported because a reservation that
210
+ // is never returned is invisible until the store cannot admit anything,
211
+ // and by then the reason is long gone. At rest this is zero.
212
+ outstanding: this.#outstandingPieces,
171
213
  spilled: this.#disk.size,
172
214
  spilledBytes: this.#disk.size * this.#chunkLength,
173
215
  ...this.#counters
@@ -180,6 +222,11 @@ export class SharedPieceStore {
180
222
  this.#capacity,
181
223
  Math.max(MIN_RESIDENT_PIECES, Number.isFinite(wanted) ? wanted : this.#capacity)
182
224
  );
225
+ // The LRU is told too. It was constructed with the store's original
226
+ // capacity and never revised, so `isFull()` answered against a number that
227
+ // had not been the limit for some time — dormant only because nothing calls
228
+ // it, which is a trap for whoever calls it next.
229
+ this.#lru.setCapacity(this.#growthCeiling);
183
230
  // With per-piece buffers memory CAN be given back immediately, unlike the
184
231
  // old growable pool. Eagerly evict excess to honour the new ceiling.
185
232
  let evicted = 0;
@@ -197,23 +244,13 @@ export class SharedPieceStore {
197
244
  this.#lru.remove(victim);
198
245
  evicted += 1;
199
246
  this.#counters.evictedOnRevise += 1;
200
- const bytes = Buffer.from(victimBuffer, 0, this.#lengthOf(victim));
201
- const spill = this.#disk.write(victim, bytes).then(
202
- () => {
203
- this.#counters.spills += 1;
204
- this.#evicting.delete(victim);
205
- this.#wake();
206
- },
207
- (error) => {
208
- this.#evicting.delete(victim);
209
- this.#wake();
210
- throw error;
211
- }
212
- );
213
- this.#evicting.set(victim, spill);
214
- }
215
- if (evicted > 0) {
216
- // Logged by the caller (worker) via reviseStoreBudgets, but also countable here.
247
+ // Nobody awaits this spill, so its failure has to end here. Rethrowing
248
+ // made it an unhandled rejection, and an unhandled rejection in the
249
+ // torrent worker ends the thread — a second way to lose the torrent
250
+ // client, on top of the one that already loses it.
251
+ void this.#spill(victim, victimBuffer).catch(() => {
252
+ this.#counters.spillFailures += 1;
253
+ });
217
254
  }
218
255
  return {
219
256
  name: this.#name,
@@ -272,30 +309,102 @@ export class SharedPieceStore {
272
309
 
273
310
  unpin(index) {
274
311
  this.#lru.unpin(index);
275
- this.#wake();
312
+ this.#noteProgress();
276
313
  }
277
314
 
315
+ /**
316
+ * Reserve room for one piece.
317
+ *
318
+ * @returns {Promise<() => void>} The release, which the caller MUST call in a
319
+ * `finally`. Calling it twice is harmless.
320
+ */
278
321
  async #claimSlot() {
279
322
  for (;;) {
323
+ if (this.#closed) {
324
+ throw new Error("Piece store is closed.");
325
+ }
280
326
  const ok = await this.#claimSlotOnce();
281
327
  if (ok) {
282
- return;
328
+ let released = false;
329
+ return () => {
330
+ if (released) {
331
+ return;
332
+ }
333
+ released = true;
334
+ this.#outstandingPieces -= 1;
335
+ this.#noteProgress();
336
+ };
283
337
  }
284
- await new Promise((resolve) => {
285
- this.#waiters.push(resolve);
286
- for (const spill of this.#evicting.values()) {
287
- void spill.then(() => this.#wake(), () => this.#wake());
288
- }
289
- const retry = setTimeout(() => this.#wake(), CLAIM_RETRY_MS);
290
- retry.unref?.();
291
- });
338
+ await this.#waitForSlot();
292
339
  }
293
340
  }
294
341
 
342
+ /**
343
+ * Sleep until something moves, or until the retry interval, whichever first.
344
+ *
345
+ * One handler, idempotent, and its timer is cleared when it is woken. The
346
+ * earlier version attached a fresh pair of handlers to EVERY pending spill on
347
+ * every attempt and left a timer running each time, so a claim that could not
348
+ * be satisfied allocated in proportion to attempts times pending spills. A
349
+ * settling spill now wakes the store itself, which is where that belongs.
350
+ *
351
+ * @returns {Promise<void>}
352
+ */
353
+ #waitForSlot() {
354
+ return new Promise((resolve) => {
355
+ let settled = false;
356
+ /** @type {ReturnType<typeof setTimeout> | null} */
357
+ let retry = null;
358
+ const finish = () => {
359
+ if (settled) {
360
+ return;
361
+ }
362
+ settled = true;
363
+ if (retry !== null) {
364
+ clearTimeout(retry);
365
+ }
366
+ resolve();
367
+ };
368
+ this.#waiters.push(finish);
369
+ retry = setTimeout(finish, CLAIM_RETRY_MS);
370
+ retry.unref?.();
371
+ });
372
+ }
373
+
295
374
  #registerPiece(index, buffer) {
296
375
  this.#buffers.set(index, buffer);
297
376
  this.#lru.touch(index);
298
- this.#outstandingPieces -= 1;
377
+ this.#noteProgress();
378
+ }
379
+
380
+ /**
381
+ * Write a piece out and account for it, from the one place that does it.
382
+ *
383
+ * @param {number} index
384
+ * @param {SharedArrayBuffer} buffer
385
+ * @returns {Promise<void>}
386
+ */
387
+ #spill(index, buffer) {
388
+ const bytes = Buffer.from(buffer, 0, this.#lengthOf(index));
389
+ const spill = this.#disk.write(index, bytes).then(
390
+ () => {
391
+ this.#counters.spills += 1;
392
+ this.#evicting.delete(index);
393
+ this.#noteProgress();
394
+ },
395
+ (error) => {
396
+ this.#evicting.delete(index);
397
+ this.#noteProgress();
398
+ throw error;
399
+ }
400
+ );
401
+ this.#evicting.set(index, spill);
402
+ return spill;
403
+ }
404
+
405
+ /** Something actually moved: wake whoever is waiting and restart the clock. */
406
+ #noteProgress() {
407
+ this.#lastProgressAt = Date.now();
299
408
  this.#wake();
300
409
  }
301
410
 
@@ -311,25 +420,30 @@ export class SharedPieceStore {
311
420
  // Reserve before suspension so concurrent callers see the reservation.
312
421
  if (this.#buffers.size + this.#outstandingPieces < this.#growthCeiling) {
313
422
  this.#outstandingPieces += 1;
423
+ this.#pinnedWaitStartedAt = 0;
314
424
  return true;
315
425
  }
316
426
 
317
427
  const victim = this.#lru.evictionCandidate();
318
428
  if (victim === null) {
319
- if (this.#evicting.size > 0 || this.#outstandingPieces > 0) {
320
- return false;
321
- }
429
+ // Nothing may leave. Wait while the store is still MOVING — a spill
430
+ // completing, a piece admitted, a pin released — and give up when it has
431
+ // not moved for PINNED_WAIT_MS, whatever is nominally in flight. The old
432
+ // rule asked whether anything was in flight rather than whether anything
433
+ // had happened, which is why one lost reservation could hold a read here
434
+ // for ever.
322
435
  if (this.#pinnedWaitStartedAt === 0) {
323
436
  this.#pinnedWaitStartedAt = Date.now();
324
437
  }
325
- if (Date.now() - this.#pinnedWaitStartedAt < PINNED_WAIT_MS) {
438
+ const stillFor = Date.now() - Math.max(this.#pinnedWaitStartedAt, this.#lastProgressAt);
439
+ if (stillFor < PINNED_WAIT_MS) {
326
440
  this.#counters.waitedForPins += 1;
327
441
  return false;
328
442
  }
329
443
  this.#pinnedWaitStartedAt = 0;
330
444
  this.#counters.blockedByPins += 1;
331
445
  throw new Error(
332
- `Every resident piece is pinned and none was released in ${PINNED_WAIT_MS}ms; no slot can be freed.`
446
+ `Every resident piece is pinned and nothing moved for ${PINNED_WAIT_MS}ms; no slot can be freed.`
333
447
  );
334
448
  }
335
449
  this.#pinnedWaitStartedAt = 0;
@@ -346,24 +460,95 @@ export class SharedPieceStore {
346
460
  this.#lru.remove(victim);
347
461
  this.#outstandingPieces += 1;
348
462
 
349
- const bytes = Buffer.from(victimBuffer, 0, this.#lengthOf(victim));
350
- const spill = this.#disk.write(victim, bytes).then(
351
- () => {
352
- this.#counters.spills += 1;
353
- this.#evicting.delete(victim);
354
- },
355
- (error) => {
356
- this.#evicting.delete(victim);
357
- throw error;
358
- }
359
- );
360
- this.#evicting.set(victim, spill);
361
- await spill;
463
+ try {
464
+ await this.#spill(victim, victimBuffer);
465
+ } catch (error) {
466
+ // The caller never received a release for this reservation, so it is
467
+ // given back here rather than left outstanding for ever.
468
+ this.#outstandingPieces -= 1;
469
+ this.#noteProgress();
470
+ throw error;
471
+ }
362
472
  // Outstanding stays +1 for the caller; the slot for the new piece is now free.
363
473
  return true;
364
474
  }
365
475
 
366
- // For compatibility: some callers check #freeSlots / #allocatedSlots — not needed.
476
+ /**
477
+ * Bring a spilled piece back into memory, once, however many callers ask.
478
+ *
479
+ * @param {number} index
480
+ * @returns {Promise<SharedArrayBuffer | null>} `null` when the piece is on
481
+ * neither tier.
482
+ */
483
+ async #revive(index) {
484
+ const spill = this.#evicting.get(index);
485
+ if (spill) {
486
+ await spill.catch(() => undefined);
487
+ }
488
+
489
+ if (!this.#disk.has(index)) {
490
+ return null;
491
+ }
492
+
493
+ const release = await this.#claimSlot();
494
+ try {
495
+ // Another caller may have brought it back while this one waited for a
496
+ // slot. Registering a second buffer for the same piece would leave
497
+ // whoever holds the first reading memory nothing evicts.
498
+ const already = this.#buffers.get(index);
499
+ if (already !== undefined) {
500
+ this.#lru.touch(index);
501
+ this.#counters.fromMemory += 1;
502
+ return already;
503
+ }
504
+ const target = new SharedArrayBuffer(this.#lengthOf(index));
505
+ await this.#disk.read(index, Buffer.from(target));
506
+ this.#registerPiece(index, target);
507
+ this.#counters.fromDisk += 1;
508
+ this.#counters.revivals += 1;
509
+ return target;
510
+ } finally {
511
+ release();
512
+ }
513
+ }
514
+
515
+ /**
516
+ * A fresh buffer holding this piece's bytes.
517
+ *
518
+ * @param {number} index
519
+ * @param {Uint8Array} bytes
520
+ * @returns {SharedArrayBuffer}
521
+ */
522
+ #copyIntoNewBuffer(index, bytes) {
523
+ const length = this.#lengthOf(index);
524
+ const sab = new SharedArrayBuffer(length);
525
+ const view = Buffer.from(sab);
526
+ if (bytes.copy) {
527
+ bytes.copy(view, 0, 0, length);
528
+ } else {
529
+ view.set(bytes.subarray(0, length), 0);
530
+ }
531
+ return sab;
532
+ }
533
+
534
+ /**
535
+ * Drop the disk copy of a piece that memory now holds — after any spill of
536
+ * that same piece has finished.
537
+ *
538
+ * `DiskTier.write` records the index when it COMPLETES, so forgetting while a
539
+ * spill of that index is still running let the completing write put it back,
540
+ * and a later read then returned the stale bytes.
541
+ *
542
+ * @param {number} index
543
+ * @returns {Promise<void>}
544
+ */
545
+ async #forgetOnDisk(index) {
546
+ const spill = this.#evicting.get(index);
547
+ if (spill) {
548
+ await spill.catch(() => undefined);
549
+ }
550
+ this.#disk.forget(index);
551
+ }
367
552
 
368
553
  put(index, bytes, callback = () => undefined) {
369
554
  if (this.#closed) {
@@ -371,65 +556,28 @@ export class SharedPieceStore {
371
556
  return;
372
557
  }
373
558
 
374
- // Overwrite in place if already resident: no eviction needed.
375
- if (this.#buffers.has(index)) {
376
- const write = async () => {
377
- const length = this.#lengthOf(index);
378
- // Replace buffer so readers with old reference don't see torn write.
379
- const sab = new SharedArrayBuffer(length);
380
- const view = Buffer.from(sab);
381
- if (bytes.copy) {
382
- bytes.copy(view, 0, 0, length);
383
- } else {
384
- view.set(bytes.subarray(0, length), 0);
385
- }
386
- this.#buffers.set(index, sab);
559
+ const write = async () => {
560
+ // Already resident: the buffer is replaced, not added, so no slot is
561
+ // needed and none is claimed. A fresh buffer rather than a write into the
562
+ // old one, so a reader holding the old reference cannot see a torn write.
563
+ if (this.#buffers.has(index)) {
564
+ this.#buffers.set(index, this.#copyIntoNewBuffer(index, bytes));
387
565
  this.#lru.touch(index);
388
- this.#disk.forget(index);
389
- };
390
- write().then(() => callback(null), (error) => callback(error));
391
- return;
392
- }
566
+ await this.#forgetOnDisk(index);
567
+ this.#noteProgress();
568
+ return;
569
+ }
393
570
 
394
- const write = async () => {
395
- await this.#claimSlot();
396
- const length = this.#lengthOf(index);
397
- const sab = new SharedArrayBuffer(length);
398
- const view = Buffer.from(sab);
399
- if (bytes.copy) {
400
- bytes.copy(view, 0, 0, length);
401
- } else {
402
- view.set(bytes.subarray(0, length), 0);
571
+ const release = await this.#claimSlot();
572
+ try {
573
+ this.#registerPiece(index, this.#copyIntoNewBuffer(index, bytes));
574
+ await this.#forgetOnDisk(index);
575
+ } finally {
576
+ release();
403
577
  }
404
- this.#registerPiece(index, sab);
405
- this.#disk.forget(index);
406
578
  };
407
579
 
408
- write().then(() => callback(null), (error) => {
409
- // If claim failed, outstanding was already incremented; correct it.
410
- // #registerPiece decrements on success; on failure we must decrement too.
411
- // But #claimSlotOnce already handles increment; we need to decrement if write threw before register.
412
- // Easiest: if error and outstanding still +1 and piece not registered, decrement.
413
- if (error) {
414
- // If we reserved but never registered, outstanding is still +1.
415
- // Check if piece is not in map and we have outstanding.
416
- if (!this.#buffers.has(index) && this.#outstandingPieces > 0) {
417
- // Only decrement if the failure happened before register.
418
- // Heuristic: if error message is pinned exhaustion, it came from claimSlotOnce which did not increment? Actually claimSlotOnce increments only on success/eviction.
419
- // For pinned error, outstanding was not incremented? Let's handle: claimSlot throws before increment? No, it throws after check, without increment.
420
- // So only failures after claim (disk write etc) need decrement — those have outstanding +1.
421
- // We conservatively decrement if outstanding >0 and piece not registered.
422
- // But to avoid double-decrement we check if this specific write's outstanding is still held.
423
- // Simple: decrement if outstanding >0 and piece not in map, and the error is not the pinned throw's pre-increment case.
424
- // The pinned throw does not increment, so outstanding is 0 there.
425
- if (this.#outstandingPieces > 0) {
426
- this.#outstandingPieces -= 1;
427
- this.#wake();
428
- }
429
- }
430
- }
431
- callback(error);
432
- });
580
+ write().then(() => callback(null), (error) => callback(error));
433
581
  }
434
582
 
435
583
  get(index, options, callback) {
@@ -451,28 +599,14 @@ export class SharedPieceStore {
451
599
  if (buffer !== undefined) {
452
600
  this.#lru.touch(index);
453
601
  this.#counters.fromMemory += 1;
454
- const view = Buffer.from(buffer, offset, length);
455
- return Buffer.from(view);
456
- }
457
-
458
- const spill = this.#evicting.get(index);
459
- if (spill) {
460
- await spill.catch(() => undefined);
602
+ return Buffer.from(Buffer.from(buffer, offset, length));
461
603
  }
462
604
 
463
- if (!this.#disk.has(index)) {
605
+ const revived = await this.#revive(index);
606
+ if (revived === null) {
464
607
  throw new Error(`Piece ${index} is not in the store.`);
465
608
  }
466
-
467
- await this.#claimSlot();
468
- const targetSab = new SharedArrayBuffer(pieceLength);
469
- const target = Buffer.from(targetSab);
470
- await this.#disk.read(index, target);
471
- this.#registerPiece(index, targetSab);
472
- this.#counters.fromDisk += 1;
473
- this.#counters.revivals += 1;
474
- const view = Buffer.from(targetSab, offset, length);
475
- return Buffer.from(view);
609
+ return Buffer.from(Buffer.from(revived, offset, length));
476
610
  };
477
611
 
478
612
  fetch().then((bytes) => done(null, bytes), (error) => done(error));
@@ -517,30 +651,20 @@ export class SharedPieceStore {
517
651
  return this.locate(index);
518
652
  }
519
653
 
520
- const spill = this.#evicting.get(index);
521
- if (spill) {
522
- await spill.catch(() => undefined);
523
- }
524
-
525
- if (!this.#disk.has(index)) {
654
+ const revived = await this.#revive(index);
655
+ if (revived === null) {
526
656
  return null;
527
657
  }
528
-
529
- const pieceLength = this.#lengthOf(index);
530
- await this.#claimSlot();
531
- const targetSab = new SharedArrayBuffer(pieceLength);
532
- const target = Buffer.from(targetSab);
533
- await this.#disk.read(index, target);
534
- this.#registerPiece(index, targetSab);
535
- this.#counters.fromDisk += 1;
536
- this.#counters.revivals += 1;
537
- return this.locate(index);
658
+ return { buffer: revived, offset: 0, length: this.#lengthOf(index) };
538
659
  }
539
660
 
540
661
  close(callback = () => undefined) {
541
662
  this.#closed = true;
542
663
  liveStores.delete(this);
543
664
  this.#buffers.clear();
665
+ // Whoever is waiting for a slot is woken and finds the store closed, which
666
+ // is an error they can report. Left asleep they simply never returned.
667
+ this.#wake();
544
668
  this.#disk.close().then(() => callback(null), (error) => callback(error));
545
669
  }
546
670
 
@@ -548,6 +672,7 @@ export class SharedPieceStore {
548
672
  this.#closed = true;
549
673
  liveStores.delete(this);
550
674
  this.#buffers.clear();
675
+ this.#wake();
551
676
  this.#disk.destroy().then(() => callback(null), (error) => callback(error));
552
677
  }
553
678
  }
@@ -67,11 +67,15 @@ export class TorrentWorkerClient {
67
67
  #onSubtitleCues;
68
68
 
69
69
  /**
70
- * @param {{ maxDiskBytes?: number, memoryBytes?: number, onSubtitleCues?: (event: object) => void }} [options]
70
+ * @param {{ maxDiskBytes?: number, memoryBytes?: number, stateDir?: string, onSubtitleCues?: (event: object) => void }} [options]
71
71
  */
72
- constructor({ maxDiskBytes, memoryBytes, onSubtitleCues } = {}) {
72
+ constructor({ maxDiskBytes, memoryBytes, stateDir, onSubtitleCues } = {}) {
73
73
  this.#worker = new Worker(fileURLToPath(WORKER_URL), {
74
- workerData: { maxDiskBytes, memoryBytes }
74
+ // `stateDir` travels because the worker writes heap snapshots of its own
75
+ // isolate there. It cannot choose a directory any other way: a worker may
76
+ // not change the process's working directory, and the isolate that has
77
+ // died three times is the one no snapshot has ever been taken of.
78
+ workerData: { maxDiskBytes, memoryBytes, stateDir }
75
79
  });
76
80
  this.#caller = createCaller(this.#worker);
77
81
  this.#onSubtitleCues = onSubtitleCues ?? (() => undefined);
@@ -37,7 +37,7 @@ export class WorkerTorrentPool {
37
37
  #torrents = new Map();
38
38
 
39
39
  /**
40
- * @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
40
+ * @param {{ maxDiskBytes?: number, memoryBytes?: number, stateDir?: string }} [options]
41
41
  */
42
42
  constructor(options = {}) {
43
43
  this.#client = new TorrentWorkerClient(options);
@@ -29,7 +29,7 @@ import { createFileClaims } from "./file-claims.js";
29
29
  import { readFragments, supplyFiguresFor } from "./piece-reader.js";
30
30
  import { cuesHeldFor, declaredSubtitleTracksOf, subtitleTracksOf, warmSubtitleCues } from "./subtitle-cues.js";
31
31
  import { Command, Event } from "./protocol.js";
32
- import { startMemoryReport } from "../memory-report.js";
32
+ import { startMemoryReport, WORKER_MEMORY_SAMPLE_MS } from "../memory-report.js";
33
33
 
34
34
  // Imported dynamically, and that is load-bearing: static imports are RESOLVED
35
35
  // during linking, before any module body runs, so a statically imported pool
@@ -485,11 +485,25 @@ parentPort.on("message", async (message) => {
485
485
  // a `SharedArrayBuffer` allocated here, so the main thread's counters cannot
486
486
  // see it however carefully they are read — which is half the reason 650 MB of a
487
487
  // 893 MB process had no explanation on 2026-08-28 (roadmap item 2).
488
+ // A second between readings, a minute between lines unless the heap moved by
489
+ // 25 MB, and a heap snapshot of THIS isolate on every new high-water above
490
+ // 400 MB. Three deaths — 2026-08-30 14:00 and 23:19, and
491
+ // 2026-08-31 13:27 — went from a 30 MB heap to the 2240 MB ceiling inside one
492
+ // sixty-second gap, and the only snapshots ever written were of the main
493
+ // isolate, whose heap is 26 MB. So the isolate that dies has never once been
494
+ // looked at (roadmap item 2, `research/worker-heap-oom-2026-08-31.md`).
488
495
  startMemoryReport({
489
496
  log,
490
497
  readStores: collectStoreStats,
491
498
  scope: "thread",
492
- label: "torrent worker"
499
+ label: "torrent worker",
500
+ intervalMs: WORKER_MEMORY_SAMPLE_MS,
501
+ quietMs: 60_000,
502
+ changeBytes: 25 * 1024 * 1024,
503
+ snapshotDir: workerData?.stateDir || undefined,
504
+ snapshotFloorBytes: 400 * 1024 * 1024,
505
+ snapshotGrowthBytes: 400 * 1024 * 1024,
506
+ keepSnapshots: 3
493
507
  });
494
508
 
495
509
  const STORE_REPORT_INTERVAL_MS = 60_000;
@@ -518,7 +532,7 @@ setInterval(() => {
518
532
  }
519
533
  }
520
534
  for (const stats of collectStoreStats()) {
521
- const signature = `${stats.fromMemory}/${stats.fromDisk}/${stats.spills}/${stats.revivals}/${stats.blockedByPins}/${stats.evictedOnRevise}`;
535
+ const signature = `${stats.fromMemory}/${stats.fromDisk}/${stats.spills}/${stats.revivals}/${stats.blockedByPins}/${stats.evictedOnRevise}/${stats.spillFailures}`;
522
536
  if (lastReported.get(stats.name) === signature) {
523
537
  continue;
524
538
  }
@@ -535,7 +549,9 @@ setInterval(() => {
535
549
  `pinned=${stats.pinned} spilled=${stats.spilled} reads=${reads} (${fromMemoryShare}% from memory) ` +
536
550
  `spills=${stats.spills} revivals=${stats.revivals}` +
537
551
  (stats.blockedByPins > 0 ? ` blocked-by-pins=${stats.blockedByPins}` : "") +
538
- (stats.evictedOnRevise > 0 ? ` evictedOnRevise=${stats.evictedOnRevise}` : "")
552
+ (stats.evictedOnRevise > 0 ? ` evictedOnRevise=${stats.evictedOnRevise}` : "") +
553
+ (stats.spillFailures > 0 ? ` spill-failures=${stats.spillFailures}` : "") +
554
+ (stats.outstanding > 0 ? ` outstanding=${stats.outstanding}` : "")
539
555
  );
540
556
  }
541
557
  }, STORE_REPORT_INTERVAL_MS).unref();