@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 +11 -0
- package/package.json +1 -1
- package/services/torrent-pool.js +83 -73
- package/test/swarm-follows-readers.test.js +39 -73
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
package/services/torrent-pool.js
CHANGED
|
@@ -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
|
-
//
|
|
1752
|
-
//
|
|
1753
|
-
//
|
|
1754
|
-
|
|
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
|
|
8
|
-
* connected at the start of one viewing, 596 at the end, 15 862
|
|
9
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
75
|
+
test("the reader that comes back takes the swarm with it", () => {
|
|
114
76
|
const torrent = torrentWith(3);
|
|
115
|
-
|
|
116
|
-
try {
|
|
117
|
-
pool.followTheReaders(torrent);
|
|
118
|
-
assert.equal(torrent.paused, true);
|
|
77
|
+
leaveSwarm(torrent);
|
|
119
78
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
});
|