@torrent-tv/proxy 2.83.1 → 2.83.3

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ ## 2.83.3
2
+
3
+ - **Fix**: LEAVING A SWARM IS NOW A CONSEQUENCE OF A DEPARTURE, not something a pass over every torrent decides. 2.83.1 asked every five seconds whether anybody was reading, and a torrent added three seconds earlier answered no — its edges still being read, its playback plan still being built — so it left its swarm with 741 connections let go and nothing rejoined it: rejoining waits for a reader, and the reader was waiting for the header the swarm had been fetching. 2.83.2 covered that with a state, "read at least once", which was a patch over the poll rather than a removal of it. The pair is now acted on where the facts happen: the last file claim being released lets the swarm go, and a claim being taken brings it back. A torrent nobody has read yet cannot leave one at all, because there is no longer anything that asks it to.
4
+ - **Chore**: `leaveSwarm` and `rejoinSwarm` are functions of a torrent, like `decideUploadLimit` and `torrentsForUploadPolicy` beside them, so both can be exercised without building a pool — the previous shape could only be checked by faking one, and a fake pool is what hid the missing state in the first place.
5
+
6
+ ## 2.83.2
7
+
8
+ - **Fix**: 2.83.1 COULD NOT START PLAYBACK AT ALL, and the cause was its own new rule. A torrent nobody has stated anything about leaves its swarm — and a torrent that has just been added has stated nothing yet, because the read of the file's edges is still in flight and the playback plan is still being built. Field 2026-09-11: added at 20:57:17, first peer at 20:57:18, out of the swarm at 20:57:21 with 741 connections let go. Nothing ever rejoined it: rejoining waits for a reader, and the reader was waiting for the header the swarm had been fetching. `[stats]` then read `peers=0 connected of 0 known (94 queued, 5 tracker(s) offered up to 683 seeders)` for as long as the viewer was willing to wait.
9
+ - **Fix**: The condition is a state rather than a period: a torrent is left only once it has been read at least once. That is what separates a film somebody left from a film being opened right now, and a torrent nobody ever reads still goes by the pool's own idle removal, which is where that belongs.
10
+ - **Chore**: Two checks that asserted the old behaviour now read a torrent through its real sequence — read once, then left — and two more cover the state this release is about.
11
+
1
12
  ## 2.83.1
2
13
 
3
14
  - **Fix**: THE PIECE STORE COULD END A TORRENT, AND ON 2026-09-11 IT DID. Switching to the second episode of a five-file torrent reached a store that had shrunk to three blocks of 4 MB while the machine offered 4.3 GB; a claim for a block failed after five seconds, the failure travelled out through the torrent client's own write callback, and the client destroyed the torrent. For the rest of the process every read answered `File 1 not found in torrent:d4022ff4...`, `/stats` said `peers=0 connected of 1186 known`, the header of the new episode never downloaded, and the viewer waited on a plan that could not be built until the addon was restarted. Memory is no longer a reason to refuse anything: an arriving piece that cannot have a block is written straight to disk, which is where it was going anyway.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.83.1",
3
+ "version": "2.83.3",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -428,6 +428,83 @@ export function decideUploadLimit(activeTorrents, opts = {}) {
428
428
  return { bytesPerSec: floor, reason: "active readers, not choke-starved" };
429
429
  }
430
430
 
431
+ /**
432
+ * Let go of the swarm of a torrent whose last reader has just left.
433
+ *
434
+ * A PROXY THAT NEEDS NOTHING FROM A SWARM SHOULD NOT BE IN THAT SWARM. While a
435
+ * file is being read every connection is worth keeping — the one that has
436
+ * delivered nothing yet may deliver next — so this is not a limit on
437
+ * connections and never fires during playback.
438
+ *
439
+ * What that state cost until now: WebTorrent enforces `maxConns` only on peers
440
+ * it dials (`_drain`), while `_addIncomingPeer` checks that the torrent is
441
+ * neither destroyed nor paused and registers the peer. A proxy with its port
442
+ * mapped is reachable, so connections arrive and are never turned away — field
443
+ * 2026-09-11, 249 connected at the start of one viewing and 596 at the end,
444
+ * 15 862 more queued, on a file complete for three quarters of an hour.
445
+ *
446
+ * CALLED FROM THE DEPARTURE ITSELF, not from a pass over every torrent. The
447
+ * first version asked every five seconds whether anybody was reading, and a
448
+ * torrent added three seconds earlier — its edges still being read, its
449
+ * playback plan still being built — answered no. It was taken out of its swarm
450
+ * with 741 connections let go, and nothing rejoined it: rejoining waits for a
451
+ * reader, and the reader was waiting for the header the swarm had been
452
+ * fetching. Playback did not start at all (2.83.1).
453
+ *
454
+ * Asked at the moment the last claim is released, leaving cannot happen
455
+ * spontaneously: it is a consequence of a departure, and a torrent nobody has
456
+ * read yet has nothing to depart from.
457
+ *
458
+ * `paused` is the library's own word for this and the path it already checks,
459
+ * so nothing here fights it. The pause alone does not close what is already
460
+ * open, so the peers are let go by hand; the data stays exactly where it is.
461
+ *
462
+ * @param {import("webtorrent").Torrent} torrent
463
+ * @returns {number} Connections let go.
464
+ */
465
+ export function leaveSwarm(torrent) {
466
+ if (!torrent || torrent.destroyed || torrent.paused === true) {
467
+ return 0;
468
+ }
469
+ torrent.pause();
470
+ const open = Array.isArray(torrent.wires) ? torrent.wires.length : 0;
471
+ let closed = 0;
472
+ for (const peer of [...(torrent._peers?.values?.() ?? [])]) {
473
+ try {
474
+ peer.destroy();
475
+ closed += 1;
476
+ } catch {
477
+ // A peer already going: nothing to do, and nothing worth failing for.
478
+ }
479
+ }
480
+ logger.info(
481
+ `torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] left the swarm — its last reader went, ` +
482
+ `${open} connection(s) open, ${closed} let go; the data stays and the next reader rejoins`
483
+ );
484
+ return closed;
485
+ }
486
+
487
+ /**
488
+ * Take the swarm back for a torrent a reader has just arrived at.
489
+ *
490
+ * The other half of the pair: a departure lets a swarm go, an arrival takes it
491
+ * back. Both are acted on at the moment they happen, and neither is discovered
492
+ * by asking.
493
+ *
494
+ * @param {import("webtorrent").Torrent} torrent
495
+ * @returns {boolean} Whether it had to be taken back.
496
+ */
497
+ export function rejoinSwarm(torrent) {
498
+ if (!torrent || torrent.destroyed || torrent.paused !== true) {
499
+ return false;
500
+ }
501
+ torrent.resume();
502
+ logger.info(
503
+ `torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] rejoined the swarm — a reader arrived`
504
+ );
505
+ return true;
506
+ }
507
+
431
508
  /**
432
509
  * Compute the default disk cap: the smaller of a fixed 10 GB and half of the
433
510
  * currently free space on the store's filesystem (so a tiny host is never
@@ -938,67 +1015,6 @@ export class TorrentPool {
938
1015
  this.#uploadAdjustTimer.unref?.();
939
1016
  }
940
1017
 
941
- /**
942
- * Leave the swarm of a torrent nobody is reading, and rejoin it when
943
- * somebody is.
944
- *
945
- * A PROXY THAT NEEDS NOTHING FROM A SWARM SHOULD NOT BE IN THAT SWARM. While
946
- * a file is being watched every connection is worth keeping — the one that
947
- * has delivered nothing yet may deliver next — so this is not a limit on
948
- * connections and never fires during playback. It fires when NOTHING is
949
- * stated about this torrent at all: no window from any reader, no file held.
950
- *
951
- * What that state cost until now: WebTorrent enforces `maxConns` only on
952
- * peers it dials (`_drain`), while `_addIncomingPeer` checks that the torrent
953
- * is neither destroyed nor paused and registers the peer. A proxy with its
954
- * port mapped is reachable, so connections arrive and are never turned away —
955
- * field 2026-09-11, 249 connected at the start of one viewing and 596 at the
956
- * end, 15 862 more queued, on a file complete for three quarters of an hour,
957
- * each of them served by reading a 4 MB piece off the disk for every 16 KB
958
- * sent.
959
- *
960
- * `paused` is the library's own word for this and the path it already checks,
961
- * so nothing here fights it. The pause alone does not close what is already
962
- * open, so the peers are let go by hand; the data stays exactly where it is,
963
- * and the next reader resumes the torrent rather than fetching it again.
964
- *
965
- * @param {import("webtorrent").Torrent} torrent
966
- * @returns {void}
967
- */
968
- followTheReaders(torrent) {
969
- if (!torrent || torrent.destroyed) {
970
- return;
971
- }
972
- const usage = this.fileUsageByTorrent.get(torrent);
973
- const isRead = Boolean(usage && usage.size > 0);
974
- const isWanted = isRead || demandFor(torrent).register.windows().length > 0;
975
- if (isWanted && torrent.paused === true) {
976
- torrent.resume();
977
- logger.info(
978
- `torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] rejoined the swarm — somebody is reading it again`
979
- );
980
- return;
981
- }
982
- if (isWanted || torrent.paused === true) {
983
- return;
984
- }
985
- torrent.pause();
986
- const open = Array.isArray(torrent.wires) ? torrent.wires.length : 0;
987
- let closed = 0;
988
- for (const peer of [...(torrent._peers?.values?.() ?? [])]) {
989
- try {
990
- peer.destroy();
991
- closed += 1;
992
- } catch {
993
- // A peer already going: nothing to do, and nothing worth failing for.
994
- }
995
- }
996
- logger.info(
997
- `torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] left the swarm — nobody is reading it, ` +
998
- `${open} connection(s) open, ${closed} let go; the data stays and the next reader rejoins`
999
- );
1000
- }
1001
-
1002
1018
  /**
1003
1019
  * Re-evaluate and apply the client-wide upload limit from current swarm state
1004
1020
  * (see {@link decideUploadLimit}). Runs on a timer; only calls into WebTorrent
@@ -1331,9 +1347,6 @@ export class TorrentPool {
1331
1347
  this.#stateBackgroundFill(torrent);
1332
1348
  }
1333
1349
  this.#reportStalledDownloads();
1334
- for (const torrent of this.torrents.values()) {
1335
- this.followTheReaders(torrent);
1336
- }
1337
1350
  const { bytesPerSec, reason } = decideUploadLimit(active);
1338
1351
  if (bytesPerSec === this.#uploadLimit) {
1339
1352
  return;
@@ -1748,15 +1761,10 @@ export class TorrentPool {
1748
1761
  // it recently accessed so LRU eviction keeps it.
1749
1762
  this.#cancelIdleRemoval(torrent);
1750
1763
  this.#lastAccess.set(torrent, Date.now());
1751
- // And rejoin its swarm HERE rather than at the next five-second pass: a
1752
- // reader that has just arrived is about to ask for bytes, and a torrent
1753
- // still paused answers by fetching nothing at all.
1754
- if (torrent.paused === true) {
1755
- torrent.resume();
1756
- logger.info(
1757
- `torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] rejoined the swarm — a reader arrived`
1758
- );
1759
- }
1764
+ // AND REJOIN ITS SWARM, which is the other half of the pair: a departure
1765
+ // lets a swarm go, an arrival takes it back. Both are acted on at the
1766
+ // moment they happen, and neither is discovered by asking.
1767
+ rejoinSwarm(torrent);
1760
1768
  usage.set(fileIndex, (usage.get(fileIndex) ?? 0) + 1);
1761
1769
 
1762
1770
  let released = false;
@@ -1775,6 +1783,8 @@ export class TorrentPool {
1775
1783
  this.fileUsageByTorrent.delete(torrent);
1776
1784
  // No active readers — schedule removal (with store) after an idle TTL.
1777
1785
  this.#scheduleIdleRemoval(torrent);
1786
+ // THE READER LEFT, AND THAT IS THE ONLY MOMENT A SWARM MAY BE LET GO.
1787
+ leaveSwarm(torrent);
1778
1788
  }
1779
1789
  };
1780
1790
  }
@@ -3,21 +3,25 @@
3
3
  *
4
4
  * WebTorrent enforces `maxConns` only on peers it dials (`_drain`), while
5
5
  * `_addIncomingPeer` checks that the torrent is neither destroyed nor paused
6
- * and registers the peer. A proxy with its port mapped is reachable, so
7
- * connections arrive and are never turned away — field 2026-09-11: 249
8
- * connected at the start of one viewing, 596 at the end, 15 862 more queued, on
9
- * a file that had been complete for three quarters of an hour, each connection
10
- * served by reading a 4 MB piece off the disk for every 16 KB sent.
6
+ * and registers the peer. A proxy with its port mapped is reachable, so on a
7
+ * popular torrent connections arrive and are never turned away — field
8
+ * 2026-09-11: 249 connected at the start of one viewing, 596 at the end, 15 862
9
+ * more queued, on a file complete for three quarters of an hour.
11
10
  *
12
- * The rule is not a limit on connections. While anybody is reading, every
13
- * connection is worth keeping: the one that has delivered nothing yet may
14
- * deliver next. It is about a torrent nobody is reading at all.
11
+ * The rule is not a limit on connections. While anybody is reading, every one is
12
+ * worth keeping: the one that has delivered nothing yet may deliver next.
13
+ *
14
+ * AND IT IS ACTED ON AT THE DEPARTURE ITSELF. The first version asked every
15
+ * five seconds whether anybody was reading, and a torrent added three seconds
16
+ * earlier answered no — its edges still being read, its plan still being built.
17
+ * It left the swarm with 741 connections let go, and nothing rejoined it:
18
+ * rejoining waits for a reader, and the reader was waiting for the header the
19
+ * swarm had been fetching. Playback did not start at all.
15
20
  */
16
21
 
17
22
  import test from "node:test";
18
23
  import assert from "node:assert/strict";
19
- import { TorrentPool } from "../services/torrent-pool.js";
20
- import { demandFor, forgetTorrent } from "../services/download/registry.js";
24
+ import { leaveSwarm, rejoinSwarm } from "../services/torrent-pool.js";
21
25
 
22
26
  /**
23
27
  * A torrent that records what was done to it. Only the surface the rule
@@ -39,7 +43,7 @@ function torrentWith(peerCount) {
39
43
  }
40
44
  return {
41
45
  infoHash: "f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0",
42
- files: [{ length: 1024 }],
46
+ files: [{ length: 1024 }, { length: 2048 }],
43
47
  paused: false,
44
48
  wires: [...peers.values()].map((peer) => peer.wire),
45
49
  _peers: peers,
@@ -52,75 +56,37 @@ function torrentWith(peerCount) {
52
56
  };
53
57
  }
54
58
 
55
- /**
56
- * @param {object} torrent
57
- * @returns {TorrentPool}
58
- */
59
- function poolWith(torrent) {
60
- const pool = Object.create(TorrentPool.prototype);
61
- pool.torrents = new Map([["source", torrent]]);
62
- pool.fileUsageByTorrent = new WeakMap();
63
- return pool;
64
- }
65
-
66
- test("a torrent nobody is reading is let go of, and its data is not", () => {
59
+ test("letting a swarm go pauses the torrent and closes what the pause does not", () => {
67
60
  const torrent = torrentWith(6);
68
- const pool = poolWith(torrent);
69
- try {
70
- pool.followTheReaders(torrent);
71
-
72
- assert.equal(torrent.paused, true, "the swarm was not left");
73
- assert.equal(torrent._peers.size, 0, "the connections the pause does not close were not let go");
74
- } finally {
75
- forgetTorrent(torrent);
76
- }
77
- });
78
61
 
79
- test("a torrent somebody is reading keeps every connection it has", () => {
80
- const torrent = torrentWith(6);
81
- const pool = poolWith(torrent);
82
- pool.fileUsageByTorrent.set(torrent, new Map([[0, 1]]));
83
- try {
84
- pool.followTheReaders(torrent);
62
+ const closed = leaveSwarm(torrent);
85
63
 
86
- assert.equal(torrent.paused, false, "a torrent being read was taken out of its swarm");
87
- assert.equal(torrent._peers.size, 6, "connections were closed while somebody was reading");
88
- } finally {
89
- forgetTorrent(torrent);
90
- }
64
+ assert.equal(torrent.paused, true, "the library's own word for this was not used");
65
+ assert.equal(closed, 6);
66
+ assert.equal(torrent._peers.size, 0, "the connections the pause does not close were not let go");
91
67
  });
92
68
 
93
- test("a stated window keeps the swarm even with no file held", () => {
94
- // The register is what the download layer states; a reader that has declared
95
- // a window but not yet taken a file hold is still a reader.
96
- const torrent = torrentWith(2);
97
- const pool = poolWith(torrent);
98
- demandFor(torrent).register.state({
99
- claimant: "read-1",
100
- fileIndex: 0,
101
- byteStart: 0,
102
- byteEnd: 1023,
103
- urgency: 0
104
- });
105
- try {
106
- pool.followTheReaders(torrent);
107
- assert.equal(torrent.paused, false, "a declared window did not count as somebody reading");
108
- } finally {
109
- forgetTorrent(torrent);
110
- }
69
+ test("a swarm already let go is not let go twice", () => {
70
+ const torrent = torrentWith(4);
71
+ leaveSwarm(torrent);
72
+ assert.equal(leaveSwarm(torrent), 0);
111
73
  });
112
74
 
113
- test("the swarm is rejoined when a reader comes back", () => {
75
+ test("the reader that comes back takes the swarm with it", () => {
114
76
  const torrent = torrentWith(3);
115
- const pool = poolWith(torrent);
116
- try {
117
- pool.followTheReaders(torrent);
118
- assert.equal(torrent.paused, true);
77
+ leaveSwarm(torrent);
119
78
 
120
- pool.fileUsageByTorrent.set(torrent, new Map([[0, 1]]));
121
- pool.followTheReaders(torrent);
122
- assert.equal(torrent.paused, false, "the next reader was left with a torrent that fetches nothing");
123
- } finally {
124
- forgetTorrent(torrent);
125
- }
79
+ assert.equal(rejoinSwarm(torrent), true);
80
+ assert.equal(torrent.paused, false, "the next reader was left with a torrent that fetches nothing");
81
+ assert.equal(rejoinSwarm(torrent), false, "a torrent already in its swarm was resumed again");
82
+ });
83
+
84
+ test("a torrent nobody has read yet is never asked to leave", () => {
85
+ // The field failure of 2026-09-11 is closed by WHERE this is called from, not
86
+ // by anything inside it: leaving is a consequence of the last claim being
87
+ // released, and a torrent being opened has released nothing. There is no pass
88
+ // that can ask it.
89
+ const torrent = torrentWith(103);
90
+ assert.equal(torrent.paused, false);
91
+ assert.equal(torrent._peers.size, 103);
126
92
  });