@torrent-tv/proxy 2.83.0 → 2.83.1

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/package.json +1 -1
  3. package/routes/api/sources/stats/get.js +11 -2
  4. package/routes/stream/get.js +74 -3
  5. package/services/delivery-probe.js +248 -43
  6. package/services/download/SwarmSelection.js +5 -5
  7. package/services/download/registry.js +20 -0
  8. package/services/encode/EncodeRun.js +1 -0
  9. package/services/encode/encode-exit.js +17 -0
  10. package/services/files/CompletedFiles.js +276 -0
  11. package/services/files/piece-from-whole-file.js +118 -0
  12. package/services/output/cut-grid.js +13 -3
  13. package/services/piece-store/piece-disk-store.js +72 -2
  14. package/services/piece-store/shared-piece-store.js +274 -27
  15. package/services/torrent-pool.js +238 -19
  16. package/services/torrent-worker/client.js +21 -0
  17. package/services/torrent-worker/protocol.js +9 -1
  18. package/services/torrent-worker/worker.js +183 -2
  19. package/test/completed-files.test.js +115 -0
  20. package/test/cuts-follow-published-grid.test.js +35 -0
  21. package/test/delivery-probe.test.js +114 -1
  22. package/test/encode-exit.test.js +18 -0
  23. package/test/piece-disk-store.test.js +26 -0
  24. package/test/piece-from-whole-file.test.js +129 -0
  25. package/test/piece-store-eviction.test.js +28 -15
  26. package/test/piece-store-never-refuses.test.js +153 -0
  27. package/test/piece-store-reservations.test.js +16 -3
  28. package/test/probe-wedge-certainty.test.js +3 -3
  29. package/test/shared-piece-store.test.js +27 -13
  30. package/test/stream-route.test.js +41 -0
  31. package/test/swarm-follows-readers.test.js +126 -0
  32. package/test/swarm-reach.test.js +5 -0
  33. package/test/upload-hurry.test.js +27 -0
@@ -306,10 +306,19 @@ export class SharedPieceStore {
306
306
  #counters = {
307
307
  fromMemory: 0,
308
308
  fromDisk: 0,
309
+ // Reads answered from the file this torrent has been assembled into. Not a
310
+ // miss and not a spill: the bytes were never lost, they are simply kept as
311
+ // a file now.
312
+ fromWholeFile: 0,
309
313
  spills: 0,
310
314
  revivals: 0,
311
315
  blockedByPins: 0,
312
316
  waitedForPins: 0,
317
+ // Pieces that went to disk because no block could be had for them. Not a
318
+ // failure — the piece is kept and a later read revives it — but it is the
319
+ // measure of how often the store is asked to hold more than it may, and
320
+ // before 2026-09-11 this case ended the torrent instead of being counted.
321
+ admittedWithoutSlot: 0,
313
322
  evictedOnRevise: 0,
314
323
  spillFailures: 0,
315
324
  // Whether the store is doing its job or being asked to hold more than it
@@ -375,8 +384,35 @@ export class SharedPieceStore {
375
384
  #freeBlocks = [];
376
385
  /** Blocks that exist at all: free plus holding a piece. */
377
386
  #blocksAllocated = 0;
378
- /** Whether a reader has ever declared a window here. See `wantedBytes`. */
379
- #everHadReader = false;
387
+ /**
388
+ * The widest window any reader has declared here, in pieces.
389
+ *
390
+ * The floor this store asks for between readers: one window is what the next
391
+ * read will ask for, and it is a measurement rather than a chosen number.
392
+ * See `wantedBytes`.
393
+ */
394
+ #widestSeenPieces = 0;
395
+ /** The torrent's files in order, passed through to whoever can read them. @type {object[]} */
396
+ #files = [];
397
+
398
+ /** What the whole torrent weighs, so a last piece is not read past its end. */
399
+ #torrentLength = 0;
400
+
401
+ /**
402
+ * How to read a piece neither tier has, or null when there is nowhere else.
403
+ *
404
+ * @type {((where: object) => Promise<Uint8Array | null>) | null}
405
+ */
406
+ #readElsewhere = null;
407
+
408
+ /**
409
+ * Whether a piece can be had elsewhere, asked without reading it — so a
410
+ * spilled copy that has become a duplicate can be dropped.
411
+ *
412
+ * @type {((where: object) => boolean) | null}
413
+ */
414
+ #isElsewhere = null;
415
+
380
416
  /** Whether the last revision had to exceed the machine's share. */
381
417
  #beyondTheMachine = false;
382
418
  /**
@@ -432,6 +468,20 @@ export class SharedPieceStore {
432
468
  this.#growthCeiling = openingCeiling;
433
469
  this.#lru = new PieceLru(openingCeiling);
434
470
  this.#name = options.name ?? "pieces";
471
+ // THE LAST PLACE A PIECE CAN COME FROM, handed in as two plain functions.
472
+ // Where those bytes are, and what a file is, is not this store's business:
473
+ // it holds pieces, and these say whether one can be had elsewhere and how.
474
+ this.#readElsewhere = typeof options.readPieceElsewhere === "function"
475
+ ? options.readPieceElsewhere
476
+ : null;
477
+ this.#isElsewhere = typeof options.isPieceElsewhere === "function"
478
+ ? options.isPieceElsewhere
479
+ : null;
480
+ // Plain data the layer above needs to answer those two, and which this
481
+ // store is handed anyway: where each file of the torrent begins and how
482
+ // long the whole of it is.
483
+ this.#files = Array.isArray(options.files) ? options.files : [];
484
+ this.#torrentLength = totalLength;
435
485
  // `options.disk` exists so a test can hold a write open or make one fail on
436
486
  // purpose. Four of the defects fixed here live in what happens when the
437
487
  // disk tier does not answer immediately or at all, and none of them is
@@ -471,6 +521,13 @@ export class SharedPieceStore {
471
521
  // is never returned is invisible until the store cannot admit anything,
472
522
  // and by then the reason is long gone. At rest this is zero.
473
523
  outstanding: this.#outstandingPieces,
524
+ // How long since ANYTHING in this store moved — a write finishing, a
525
+ // piece admitted, a pin released. A claim gives up after
526
+ // `PINNED_WAIT_MS` of exactly this, so it is the figure that says whether
527
+ // a refusal was the store being busy or the store being stuck, and until
528
+ // 2026-09-11 nothing printed it: the field had 152 refusals in 104
529
+ // seconds and no way to tell which.
530
+ stillMs: this.#lastProgressAt > 0 ? Date.now() - this.#lastProgressAt : 0,
474
531
  spilled: this.#disk.size,
475
532
  // What the spill file ACTUALLY weighs, piece by piece, rather than the
476
533
  // piece count times a full piece length. The last piece of a torrent is
@@ -568,7 +625,7 @@ export class SharedPieceStore {
568
625
  get wantedBytes() {
569
626
  const demand = this.#lru.demand();
570
627
  if (demand.readers > 0) {
571
- this.#everHadReader = true;
628
+ this.#widestSeenPieces = Math.max(this.#widestSeenPieces, demand.widestPieces);
572
629
  const pieces = Math.max(MIN_RESIDENT_PIECES, demand.unionPieces, demand.widestPieces);
573
630
  // Plus room to absorb what arrives while a write is finishing. Asking for
574
631
  // exactly what the readers want leaves no free place ever, so every
@@ -577,16 +634,23 @@ export class SharedPieceStore {
577
634
  // that followed.
578
635
  return (pieces + this.slackPieces()) * this.#chunkLength;
579
636
  }
580
- // Readers that have GONE are not the same as readers that have not arrived.
581
- // A store whose readers ended has nothing to hold pieces for — its torrent
582
- // sits until the pool's idle timer removes it, which needs a refcount of
583
- // zero and can be a quarter of an hour away — so it asks for nothing and
584
- // its memory goes back to the machine now. A store that has never had a
585
- // reader is being filled for one that is on its way, and asks for what it
586
- // was opened with until the first read says what it needs.
587
- return this.#everHadReader
588
- ? (MIN_RESIDENT_PIECES + this.slackPieces()) * this.#chunkLength
589
- : this.#growthCeiling * this.#chunkLength;
637
+ // WITH NO READER, THE FLOOR IS ONE READER'S WINDOW the widest this store
638
+ // has actually been asked for, which is measured rather than chosen, and
639
+ // `MIN_RESIDENT_PIECES` only until it has been asked for anything.
640
+ //
641
+ // It used to fall to the minimum the moment the last reader went, and the
642
+ // allowance is re-derived once a minute (`STORE_REPORT_INTERVAL_MS`) while
643
+ // a claim gives up after five seconds. A reader arriving into that minute
644
+ // met a store of two or three blocks: field 2026-09-11, the viewer switched
645
+ // to another file of the same torrent and the store was at
646
+ // `0/3 (0MB of 12MB allowed)` with the machine offering 4.3 GB.
647
+ //
648
+ // Holding one window's worth between readers is not waste: it is what the
649
+ // next read will ask for within seconds, and the pieces in it are the ones
650
+ // that reader left off at. A store that has never been read keeps nothing
651
+ // either — its pieces are arriving for a reader on the way, and they have
652
+ // the disk.
653
+ return (Math.max(MIN_RESIDENT_PIECES, this.#widestSeenPieces) + this.slackPieces()) * this.#chunkLength;
590
654
  }
591
655
 
592
656
  reviseGrowthCeiling(allowedBytes) {
@@ -720,6 +784,28 @@ export class SharedPieceStore {
720
784
  * @returns {Promise<() => void>} The release, which the caller MUST call in a
721
785
  * `finally`. Calling it twice is harmless.
722
786
  */
787
+ /**
788
+ * The same reservation, answering null instead of throwing.
789
+ *
790
+ * For callers that have somewhere else to go: an arriving piece has the disk,
791
+ * so it writes through rather than failing. Keeping the throwing form for
792
+ * callers that genuinely have no alternative is what makes the difference
793
+ * visible at the call site rather than in a catch.
794
+ *
795
+ * @returns {Promise<(() => void) | null>}
796
+ */
797
+ async #claimSlotOrNull() {
798
+ try {
799
+ return await this.#claimSlot();
800
+ } catch (error) {
801
+ if (this.#closed) {
802
+ throw error;
803
+ }
804
+ this.#counters.admittedWithoutSlot += 1;
805
+ return null;
806
+ }
807
+ }
808
+
723
809
  async #claimSlot() {
724
810
  // How long THIS claim has been trying, kept here and not on the store.
725
811
  // It was a field, `#pinnedWaitStartedAt`, shared by the two waits inside
@@ -736,8 +822,8 @@ export class SharedPieceStore {
736
822
  if (this.#closed) {
737
823
  throw new Error("Piece store is closed.");
738
824
  }
739
- const ok = await this.#claimSlotOnce(waitingSince);
740
- if (ok) {
825
+ const isReserved = await this.#claimSlotOnce(waitingSince);
826
+ if (isReserved) {
741
827
  let released = false;
742
828
  return () => {
743
829
  if (released) {
@@ -1028,6 +1114,68 @@ export class SharedPieceStore {
1028
1114
  }
1029
1115
  }
1030
1116
 
1117
+ /**
1118
+ * One piece, from wherever else it can be had.
1119
+ *
1120
+ * @param {number} index
1121
+ * @returns {Promise<Buffer | null>}
1122
+ */
1123
+ async #fromWholeFiles(index) {
1124
+ return this.#readElsewhere
1125
+ ? this.#readElsewhere({
1126
+ index,
1127
+ pieceLength: this.#chunkLength,
1128
+ length: this.#torrentLength,
1129
+ files: this.#files
1130
+ })
1131
+ : null;
1132
+ }
1133
+
1134
+ /**
1135
+ * Whether a spilled piece is now a duplicate of bytes held elsewhere.
1136
+ *
1137
+ * @param {number} index
1138
+ * @returns {boolean}
1139
+ */
1140
+ isInWholeFiles(index) {
1141
+ return this.#isElsewhere
1142
+ ? this.#isElsewhere({
1143
+ index,
1144
+ pieceLength: this.#chunkLength,
1145
+ length: this.#torrentLength,
1146
+ files: this.#files
1147
+ }) === true
1148
+ : false;
1149
+ }
1150
+
1151
+ /**
1152
+ * Drop spilled pieces that are now duplicates of bytes held elsewhere.
1153
+ *
1154
+ * A film assembled into a file is on the disk twice until this runs: once as
1155
+ * the file and once as the pieces it was built from. Field 2026-09-11, one
1156
+ * episode: 1417 MB of segments beside 1424 MB of spilled pieces. Nothing is
1157
+ * lost by dropping the second — a read that wants one of those pieces is
1158
+ * answered from the file.
1159
+ *
1160
+ * @returns {number} How many were dropped.
1161
+ */
1162
+ dropDuplicatesHeldElsewhere() {
1163
+ if (!this.#isElsewhere) {
1164
+ return 0;
1165
+ }
1166
+ let dropped = 0;
1167
+ for (const index of this.#disk.indexes()) {
1168
+ if (this.#buffers.has(index) || this.#evicting.has(index)) {
1169
+ continue;
1170
+ }
1171
+ if (this.isInWholeFiles(index)) {
1172
+ this.#disk.forget(index);
1173
+ dropped += 1;
1174
+ }
1175
+ }
1176
+ return dropped;
1177
+ }
1178
+
1031
1179
  #wake() {
1032
1180
  const waiting = this.#waiters;
1033
1181
  this.#waiters = [];
@@ -1064,7 +1212,15 @@ export class SharedPieceStore {
1064
1212
  //
1065
1213
  // So when the disk is what the store is waiting for, it waits. A completing
1066
1214
  // write calls `#noteProgress`, which wakes whoever is here.
1067
- if (this.#blocksInFlight() > 0 && this.#blocksInUse() >= this.#growthCeiling) {
1215
+ // THE SAME QUANTITY THE GRANT ABOVE TESTS. It used to ask whether
1216
+ // `blocksInUse` alone had reached the ceiling, while what reaches the
1217
+ // ceiling is `blocksInUse + outstanding` — so a store held at its ceiling
1218
+ // by reservations fell past this branch and into the eviction below, which
1219
+ // has nothing to evict when nothing is resident. Field 2026-09-11:
1220
+ // `blocks=2 (0 spare)` with `outstanding=2` against a ceiling of 3, two
1221
+ // writes in flight each of which would have returned a block, and the claim
1222
+ // threw rather than waiting the moment out.
1223
+ if (this.#blocksInFlight() > 0 && this.#blocksInUse() + this.#outstandingPieces >= this.#growthCeiling) {
1068
1224
  if (stillFor() < PINNED_WAIT_MS) {
1069
1225
  this.#counters.waitedForDisk += 1;
1070
1226
  return false;
@@ -1090,8 +1246,19 @@ export class SharedPieceStore {
1090
1246
  return false;
1091
1247
  }
1092
1248
  this.#counters.blockedByPins += 1;
1249
+ // SAY WHICH STATE REFUSED, with the numbers it was read from. The old
1250
+ // text named one state — every resident piece pinned — and the field case
1251
+ // of 2026-09-11 was a different one: `resident=0 pinned=0`, nothing to
1252
+ // evict because nothing was in memory at all, the ceiling held by
1253
+ // reservations and by two writes on their way to disk. A message that
1254
+ // names a state that did not exist costs a day of reading the wrong code.
1093
1255
  throw new Error(
1094
- `Every resident piece is pinned and nothing moved for ${PINNED_WAIT_MS}ms; no slot can be freed.`
1256
+ `No block can be had for ${PINNED_WAIT_MS}ms: ` +
1257
+ `${this.#buffers.size} resident (${this.#lru.pinnedCount} pinned, ` +
1258
+ `${this.#lru.protectedCount} range(s) declared), ` +
1259
+ `${this.#blocksInUse()} block(s) in use of ${this.#growthCeiling} allowed, ` +
1260
+ `${this.#blocksInFlight()} on their way to disk, ` +
1261
+ `${this.#outstandingPieces} reservation(s) outstanding.`
1095
1262
  );
1096
1263
  }
1097
1264
 
@@ -1135,7 +1302,25 @@ export class SharedPieceStore {
1135
1302
  }
1136
1303
 
1137
1304
  if (!this.#disk.has(index)) {
1138
- return null;
1305
+ // Not spilled, but the file it belongs to may be here whole — which is
1306
+ // the ordinary case once a film has been assembled and its spilled copy
1307
+ // dropped as the duplicate it had become.
1308
+ const whole = await this.#fromWholeFiles(index);
1309
+ if (!whole) {
1310
+ return null;
1311
+ }
1312
+ this.#counters.fromWholeFile += 1;
1313
+ const release = await this.#claimSlotOrNull();
1314
+ if (release === null) {
1315
+ // No block to put it in. The caller gets the bytes anyway; what it
1316
+ // loses is only that the next read pays for this one again.
1317
+ return whole;
1318
+ }
1319
+ try {
1320
+ return this.#registerPiece(index, this.#copyIntoNewBuffer(index, whole));
1321
+ } finally {
1322
+ release();
1323
+ }
1139
1324
  }
1140
1325
 
1141
1326
  const release = await this.#claimSlot();
@@ -1382,8 +1567,8 @@ export class SharedPieceStore {
1382
1567
  }
1383
1568
 
1384
1569
  this.#noteArrival();
1385
- const declared = this.#lru.wants(index);
1386
- if (declared) {
1570
+ const isDeclared = this.#lru.wants(index);
1571
+ if (isDeclared) {
1387
1572
  this.#counters.admittedInsideWindow += 1;
1388
1573
  } else {
1389
1574
  this.#counters.admittedOutsideWindow += 1;
@@ -1407,7 +1592,7 @@ export class SharedPieceStore {
1407
1592
  //
1408
1593
  // Only when SOMETHING is stated: before that there is no basis for
1409
1594
  // calling one piece more wanted than another.
1410
- const worseThanTheVictim = () => {
1595
+ const isWorseThanTheVictim = () => {
1411
1596
  const victim = this.#lru.nextVictim();
1412
1597
  if (victim.index === null) {
1413
1598
  return false;
@@ -1420,14 +1605,32 @@ export class SharedPieceStore {
1420
1605
  return arriving >= 0 && victim.wait >= 0 && arriving > victim.wait;
1421
1606
  };
1422
1607
  if (this.#lru.protectedCount > 0 && this.#isFullNow()
1423
- && (!declared || worseThanTheVictim())) {
1608
+ && (!isDeclared || isWorseThanTheVictim())) {
1424
1609
  this.#counters.admittedToDisk += 1;
1425
1610
  await this.#writeThrough(index, bytes);
1426
1611
  this.#noteProgress();
1427
1612
  return;
1428
1613
  }
1429
1614
 
1430
- const release = await this.#claimSlot();
1615
+ // MEMORY IS NEVER A REASON TO REFUSE A PIECE. The piece is verified and
1616
+ // the disk will take it; failing here fails the torrent client's own
1617
+ // write, and the client answers that by destroying the torrent — field
1618
+ // 2026-09-11, `WebTorrent client error: Every resident piece is pinned`,
1619
+ // after which every read of that torrent answered `File 1 not found` for
1620
+ // the life of the process and the viewer could not open anything.
1621
+ //
1622
+ // The branch above already writes through when the store is full and the
1623
+ // arrival is wanted less than what it would displace. It cannot fire
1624
+ // where nothing is declared (`protectedCount > 0`), which is exactly the
1625
+ // state a store between readers is in — and that state is where the
1626
+ // refusal happened.
1627
+ const release = await this.#claimSlotOrNull();
1628
+ if (release === null) {
1629
+ this.#counters.admittedToDisk += 1;
1630
+ await this.#writeThrough(index, bytes);
1631
+ this.#noteProgress();
1632
+ return;
1633
+ }
1431
1634
  try {
1432
1635
  this.#registerPiece(index, this.#copyIntoNewBuffer(index, bytes));
1433
1636
  await this.#forgetOnDisk(index);
@@ -1461,11 +1664,43 @@ export class SharedPieceStore {
1461
1664
  return Buffer.from(Buffer.from(buffer, offset, length));
1462
1665
  }
1463
1666
 
1464
- const revived = await this.#revive(index);
1465
- if (revived === null) {
1466
- throw new Error(`Piece ${index} is not in the store.`);
1667
+ // NOT revived into memory. This entry point is the torrent client's: it
1668
+ // reads to answer a peer, and a peer asks for 16 KB while a piece here is
1669
+ // megabytes. Reviving would take a whole block for bytes nobody is going
1670
+ // to read again — and it is the ONE read that can be answered without a
1671
+ // block at all, since the caller is handed its own copy either way.
1672
+ //
1673
+ // Field 2026-09-11, and this is the whole reason the store could not
1674
+ // serve anybody: an upload capped at 512 KB/s produced 63 416 reads of
1675
+ // which 78.4 % missed memory, 49 696 pieces revived whole, and every one
1676
+ // of those revivals competed for a ceiling of three blocks. Claiming a
1677
+ // block for an upload is also how this path reached `#claimSlot`, whose
1678
+ // failure the torrent client turns into a destroyed torrent.
1679
+ // A spill that is still being written owns the only copy there is, so
1680
+ // wait for it exactly as `#revive` does before asking the disk.
1681
+ const spill = this.#evicting.get(index);
1682
+ if (spill) {
1683
+ await spill.catch(() => undefined);
1467
1684
  }
1468
- return Buffer.from(Buffer.from(revived, offset, length));
1685
+ const resident = this.#buffers.get(index);
1686
+ if (resident !== undefined) {
1687
+ this.#lru.touch(index);
1688
+ this.#counters.fromMemory += 1;
1689
+ return Buffer.from(Buffer.from(resident, offset, length));
1690
+ }
1691
+ if (this.#disk.has(index)) {
1692
+ const want = Math.max(0, Math.min(length, this.#lengthOf(index) - offset));
1693
+ const target = Buffer.allocUnsafe(want);
1694
+ await this.#disk.read(index, target, offset);
1695
+ this.#counters.fromDisk += 1;
1696
+ return target;
1697
+ }
1698
+ const whole = await this.#fromWholeFiles(index);
1699
+ if (whole) {
1700
+ this.#counters.fromWholeFile += 1;
1701
+ return Buffer.from(whole.subarray(offset, offset + length));
1702
+ }
1703
+ throw new Error(`Piece ${index} is not in the store.`);
1469
1704
  };
1470
1705
 
1471
1706
  fetch().then((bytes) => done(null, bytes), (error) => done(error));
@@ -1484,6 +1719,18 @@ export class SharedPieceStore {
1484
1719
  */
1485
1720
  protectRange(readerId, from, to, urgency) {
1486
1721
  this.#lru.protect(readerId, from, to, urgency);
1722
+ // A READER DECLARES ITSELF IN A MOMENT; the allowance was re-derived once a
1723
+ // minute. Between the two a read met whatever the store had shrunk to while
1724
+ // nobody was reading, and a claim gives up after five seconds — twelve
1725
+ // times sooner than the store could have grown (field 2026-09-11).
1726
+ //
1727
+ // DELIBERATELY NOT raising the ceiling here. A reader declaring a window
1728
+ // wider than the store may hold is a shortage to absorb, not an instruction
1729
+ // to take more memory: with several viewers on several films the unions add
1730
+ // up across stores, and each store's floor already wins over the machine's
1731
+ // share at the next revision. What the shortage must not do is fail a read,
1732
+ // and that is answered where it arises — no path in this store refuses for
1733
+ // want of memory any more.
1487
1734
  }
1488
1735
 
1489
1736
  protectedRanges() {