@torrent-tv/proxy 2.83.2 → 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,8 @@
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
+
1
6
  ## 2.83.2
2
7
 
3
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.83.2",
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,88 +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
- if (isRead) {
975
- // Recorded on the torrent, as `hasActiveReader` and `hasUnmetDemand`
976
- // beside it are: it is a fact about this torrent and it lives as long as
977
- // the torrent does.
978
- torrent.hasBeenRead = true;
979
- }
980
- const isWanted = isRead || demandFor(torrent).register.windows().length > 0;
981
- // A TORRENT NOBODY HAS READ YET IS BEING SET UP, NOT ABANDONED. Field
982
- // 2026-09-11, and it broke playback outright: a torrent was added at
983
- // 20:57:17, its first peer connected at 20:57:18, and this pass took it out
984
- // of the swarm at 20:57:21 — while the read of the file's edges was still
985
- // in flight and the playback plan was being built. Nothing rejoined it,
986
- // because rejoining waits for a reader and a reader cannot arrive: the
987
- // header it needs is downloaded by the swarm that was just let go.
988
- //
989
- // The condition is a state and not a period: having been read at least once
990
- // is what tells a film somebody left from a film nobody has opened yet. One
991
- // that is never read at all goes by the pool's own idle removal, which is
992
- // where that belongs.
993
- if (!isWanted && torrent.hasBeenRead !== true) {
994
- return;
995
- }
996
- if (isWanted && torrent.paused === true) {
997
- torrent.resume();
998
- logger.info(
999
- `torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] rejoined the swarm — somebody is reading it again`
1000
- );
1001
- return;
1002
- }
1003
- if (isWanted || torrent.paused === true) {
1004
- return;
1005
- }
1006
- torrent.pause();
1007
- const open = Array.isArray(torrent.wires) ? torrent.wires.length : 0;
1008
- let closed = 0;
1009
- for (const peer of [...(torrent._peers?.values?.() ?? [])]) {
1010
- try {
1011
- peer.destroy();
1012
- closed += 1;
1013
- } catch {
1014
- // A peer already going: nothing to do, and nothing worth failing for.
1015
- }
1016
- }
1017
- logger.info(
1018
- `torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] left the swarm — nobody is reading it, ` +
1019
- `${open} connection(s) open, ${closed} let go; the data stays and the next reader rejoins`
1020
- );
1021
- }
1022
-
1023
1018
  /**
1024
1019
  * Re-evaluate and apply the client-wide upload limit from current swarm state
1025
1020
  * (see {@link decideUploadLimit}). Runs on a timer; only calls into WebTorrent
@@ -1352,9 +1347,6 @@ export class TorrentPool {
1352
1347
  this.#stateBackgroundFill(torrent);
1353
1348
  }
1354
1349
  this.#reportStalledDownloads();
1355
- for (const torrent of this.torrents.values()) {
1356
- this.followTheReaders(torrent);
1357
- }
1358
1350
  const { bytesPerSec, reason } = decideUploadLimit(active);
1359
1351
  if (bytesPerSec === this.#uploadLimit) {
1360
1352
  return;
@@ -1769,15 +1761,10 @@ export class TorrentPool {
1769
1761
  // it recently accessed so LRU eviction keeps it.
1770
1762
  this.#cancelIdleRemoval(torrent);
1771
1763
  this.#lastAccess.set(torrent, Date.now());
1772
- // And rejoin its swarm HERE rather than at the next five-second pass: a
1773
- // reader that has just arrived is about to ask for bytes, and a torrent
1774
- // still paused answers by fetching nothing at all.
1775
- if (torrent.paused === true) {
1776
- torrent.resume();
1777
- logger.info(
1778
- `torrent-pool: [${String(torrent.infoHash ?? "?").slice(0, 8)}] rejoined the swarm — a reader arrived`
1779
- );
1780
- }
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);
1781
1768
  usage.set(fileIndex, (usage.get(fileIndex) ?? 0) + 1);
1782
1769
 
1783
1770
  let released = false;
@@ -1796,6 +1783,8 @@ export class TorrentPool {
1796
1783
  this.fileUsageByTorrent.delete(torrent);
1797
1784
  // No active readers — schedule removal (with store) after an idle TTL.
1798
1785
  this.#scheduleIdleRemoval(torrent);
1786
+ // THE READER LEFT, AND THAT IS THE ONLY MOMENT A SWARM MAY BE LET GO.
1787
+ leaveSwarm(torrent);
1799
1788
  }
1800
1789
  };
1801
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,117 +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
- // Read once and left, which is the state this is about: a film nobody has
71
- // opened yet is being set up, and the test below covers that.
72
- pool.fileUsageByTorrent.set(torrent, new Map([[0, 1]]));
73
- pool.followTheReaders(torrent);
74
- pool.fileUsageByTorrent.set(torrent, new Map());
75
- pool.followTheReaders(torrent);
76
61
 
77
- assert.equal(torrent.paused, true, "the swarm was not left");
78
- assert.equal(torrent._peers.size, 0, "the connections the pause does not close were not let go");
79
- } finally {
80
- forgetTorrent(torrent);
81
- }
82
- });
62
+ const closed = leaveSwarm(torrent);
83
63
 
84
- test("a torrent somebody is reading keeps every connection it has", () => {
85
- const torrent = torrentWith(6);
86
- const pool = poolWith(torrent);
87
- pool.fileUsageByTorrent.set(torrent, new Map([[0, 1]]));
88
- try {
89
- pool.followTheReaders(torrent);
90
-
91
- assert.equal(torrent.paused, false, "a torrent being read was taken out of its swarm");
92
- assert.equal(torrent._peers.size, 6, "connections were closed while somebody was reading");
93
- } finally {
94
- forgetTorrent(torrent);
95
- }
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");
96
67
  });
97
68
 
98
- test("a stated window keeps the swarm even with no file held", () => {
99
- // The register is what the download layer states; a reader that has declared
100
- // a window but not yet taken a file hold is still a reader.
101
- const torrent = torrentWith(2);
102
- const pool = poolWith(torrent);
103
- demandFor(torrent).register.state({
104
- claimant: "read-1",
105
- fileIndex: 0,
106
- byteStart: 0,
107
- byteEnd: 1023,
108
- urgency: 0
109
- });
110
- try {
111
- pool.followTheReaders(torrent);
112
- assert.equal(torrent.paused, false, "a declared window did not count as somebody reading");
113
- } finally {
114
- forgetTorrent(torrent);
115
- }
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);
116
73
  });
117
74
 
118
- test("the swarm is rejoined when a reader comes back", () => {
75
+ test("the reader that comes back takes the swarm with it", () => {
119
76
  const torrent = torrentWith(3);
120
- const pool = poolWith(torrent);
121
- try {
122
- pool.fileUsageByTorrent.set(torrent, new Map([[0, 1]]));
123
- pool.followTheReaders(torrent);
124
- pool.fileUsageByTorrent.set(torrent, new Map());
125
- pool.followTheReaders(torrent);
126
- assert.equal(torrent.paused, true);
77
+ leaveSwarm(torrent);
127
78
 
128
- pool.fileUsageByTorrent.set(torrent, new Map([[0, 1]]));
129
- pool.followTheReaders(torrent);
130
- assert.equal(torrent.paused, false, "the next reader was left with a torrent that fetches nothing");
131
- } finally {
132
- forgetTorrent(torrent);
133
- }
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");
134
82
  });
135
83
 
136
- test("a torrent nobody has read yet is being set up, and is left alone", () => {
137
- // Field 2026-09-11, and it broke playback outright: a torrent added at
138
- // 20:57:17 had its first peer at 20:57:18 and was taken out of the swarm at
139
- // 20:57:21 while the read of the file's edges was still in flight. Nothing
140
- // rejoined it, because rejoining waits for a reader and the reader was
141
- // waiting for the header the swarm was fetching.
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.
142
89
  const torrent = torrentWith(103);
143
- const pool = poolWith(torrent);
144
- try {
145
- pool.followTheReaders(torrent);
146
- assert.equal(torrent.paused, false, "a torrent being opened was taken out of its swarm");
147
- assert.equal(torrent._peers.size, 103, "and its connections were let go");
148
- } finally {
149
- forgetTorrent(torrent);
150
- }
151
- });
152
-
153
- test("once it has been read, leaving is allowed again", () => {
154
- const torrent = torrentWith(4);
155
- const pool = poolWith(torrent);
156
- try {
157
- // A reader arrives and goes.
158
- pool.fileUsageByTorrent.set(torrent, new Map([[0, 1]]));
159
- pool.followTheReaders(torrent);
160
- assert.equal(torrent.paused, false);
161
-
162
- pool.fileUsageByTorrent.set(torrent, new Map());
163
- pool.followTheReaders(torrent);
164
- assert.equal(torrent.paused, true, "a film somebody left keeps its swarm for nobody");
165
- } finally {
166
- forgetTorrent(torrent);
167
- }
90
+ assert.equal(torrent.paused, false);
91
+ assert.equal(torrent._peers.size, 103);
168
92
  });