@torrent-tv/proxy 2.9.38 → 2.9.40
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 +9 -0
- package/docs/socks5-egress-research.md +114 -0
- package/package.json +1 -1
- package/services/torrent-pool.js +202 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 2.9.40
|
|
2
|
+
|
|
3
|
+
- **New**: Adaptive upload throttle. Seeding to the BitTorrent swarm does not help our viewer (we deliver over our own channel) — it is pure uplink cost and the riskiest legal act — so the client-wide upload limit now defaults to **off** (`throttleUpload(0)`, was WebTorrent's unlimited default) and is raised only when needed. A 5 s adjuster sets: **0** when no file has an active reader (stop seeding entirely once nothing is being watched); a low **floor** (50 KB/s) while a reader is active (a token upload so tit-for-tat does not choke us to zero); and a **boost** (512 KB/s) only when a torrent is starving (download barely trickling while it still needs data) AND its wires show reciprocity choke (≥2 peers we want data from are choking us) — earning unchoke slots to un-starve the download. The policy is a pure function (`decideUploadLimit`, unit-tested); each change is logged for field tuning. Client-wide limit (one active torrent is the norm today).
|
|
4
|
+
- **Fix**: Seek-aware piece prioritization now actually makes a far seek download the seek target first. On every `/stream` range request the proxy deselects the pieces BEHIND the read position, so WebTorrent's picker — which scans each selection sequentially from its first undownloaded piece — starts at the playhead instead of fetching the undownloaded gap behind it. Previously only a `critical()` window was marked, but `critical` does not reorder the scan (it only enables hotswap: re-requesting a block from a faster peer), so a seek into a large undownloaded region still waited behind the sequential backlog. Behind-playhead pieces are only dropped from the download set (stop fetching), never deleted — a backward seek re-selects them via the same call, and the whole file is re-selected on the next reader acquire; the pinned head/tail (codec probe) is unaffected. The critical read-ahead window (now 16 MB) is reset each call so it stays a moving window rather than accumulating over the whole file across seeks. Single-active-reader scope (the multi-viewer union window is roadmap item 23).
|
|
5
|
+
|
|
6
|
+
## 2.9.39
|
|
7
|
+
|
|
8
|
+
- **Chore**: Log the stack (first frames) of WebTorrent `warning` events, not just the message. Field diagnosis: a playback froze mid-file with repeated `torrent-pool: … warning: Connection error: Cannot read properties of null (reading 'type')` (a WebTorrent µTP null-peer NPE, webtorrent#1932/#1940) while the swarm had seeders — peer connections were failing and the download starved. The old handler logged only the terse message, hiding which library path threw; the stack pinpoints it before we mitigate (next: prefer HTTP/DHT over the timing-out UDP trackers, then consider disabling µTP).
|
|
9
|
+
|
|
1
10
|
## 2.9.38
|
|
2
11
|
|
|
3
12
|
- **New**: Adaptive bitrate for thin viewer links (OpenSpec change `adaptive-bitrate`). Field evidence (iPhone on cellular): uncapped complex scenes produced 4 s segments of ~18 Mbit/s against a 1–6 Mbit/s link — 45 s prebuffer and a draining buffer. Two parts. (a) Software encodes are now constrained-CRF: `-maxrate`/`-bufsize` per resolution rung (1080p→5000K, 720p→2800K, 480p→1400K, 360p→800K, 240p→400K nominal; ×1.3/×1.5 — webtor's production multipliers), so peaks stay bounded. (b) New data-channel route `POST /api/transcode-sessions/:id/net-report` accepts the browser's measured link throughput + buffered seconds; the realtime-budget loop gains a second downshift trigger — a FRESH report showing the usable link (×0.8 safety) sustainedly (15 s) below the observed produced bitrate while the viewer's buffer is low (<10 s) steps the encode one rung down via the existing machinery (shared 30 s cooldown, step cap, no upswitch). Log reason `viewer-link-bound` distinguishes it from CPU downshifts. Manual-quality sessions are exempt (no budget ladder); old clients that never report simply keep today's behaviour plus the caps.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# SOCKS5 torrent egress — feasibility research (NOT implemented)
|
|
2
|
+
|
|
3
|
+
Research only (2026-07-10), no code. Question: can the proxy route its
|
|
4
|
+
**torrent** traffic (peers, trackers, DHT) through an owner-configured SOCKS5
|
|
5
|
+
endpoint (e.g. a VPN provider's) to hide the owner's home IP from the swarm,
|
|
6
|
+
while WebRTC to the viewer stays direct? Roadmap item 8 in the root
|
|
7
|
+
`CLAUDE.md`.
|
|
8
|
+
|
|
9
|
+
Stack: WebTorrent 2.8.5, Node. Verified against
|
|
10
|
+
`node_modules/webtorrent/lib/*.js` and `index.js`.
|
|
11
|
+
|
|
12
|
+
## What "torrent traffic" actually is (and which parts can be SOCKS5'd)
|
|
13
|
+
|
|
14
|
+
SOCKS5 forwards a connection through a relay so the destination sees the
|
|
15
|
+
relay's IP, not ours. It supports TCP (CONNECT) and, in theory, UDP (UDP
|
|
16
|
+
ASSOCIATE) — but UDP-ASSOCIATE is rarely usable from Node libraries that bind
|
|
17
|
+
their own UDP sockets. So per traffic type:
|
|
18
|
+
|
|
19
|
+
| Traffic | Transport | Where in WebTorrent | SOCKS5? |
|
|
20
|
+
|---|---|---|---|
|
|
21
|
+
| Peer wire (outgoing) | TCP | `torrent.js` `net.connect(opts)` (~L2112) | **Yes** (TCP CONNECT) |
|
|
22
|
+
| Peer wire (outgoing) | µTP (UDP) | `torrent.js` `utp.connect()` (~L2110) | No (UDP, own socket) → **disable** |
|
|
23
|
+
| HTTP/HTTPS trackers | TCP | `bittorrent-tracker` (HTTP agent) | **Yes** (agent) |
|
|
24
|
+
| WSS trackers | TCP (WebSocket) | `bittorrent-tracker` (ws agent) | **Yes** (agent) |
|
|
25
|
+
| UDP trackers (`udp://`) | UDP | `bittorrent-tracker` | No → **drop** (filter announce list) |
|
|
26
|
+
| DHT | UDP | `bittorrent-dht` | No → **disable** (`dht:false`) |
|
|
27
|
+
| LSD (local peer discovery) | UDP multicast | conn-pool | LAN-only leak → **disable** (`lsd:false`) |
|
|
28
|
+
| Incoming peers | TCP/µTP listener | conn-pool | N/A (inbound; see note) |
|
|
29
|
+
|
|
30
|
+
Conclusion: a clean, leak-free config is **TCP-only through the SOCKS5 relay**
|
|
31
|
+
with every UDP path turned OFF. This is the standard "torrent proxy" model and
|
|
32
|
+
its standard caveat: you lose µTP peers, UDP trackers, and DHT peer discovery —
|
|
33
|
+
peer counts drop, but HTTP/WSS-tracker swarms (like the rutracker trackers our
|
|
34
|
+
test torrents use) still work, and TCP peers are plentiful.
|
|
35
|
+
|
|
36
|
+
WebTorrent already exposes the needed off-switches (verified in `index.js`):
|
|
37
|
+
`utp: false` (L82), `dht: false` (L127), `lsd: false` (L76), `webSeeds`
|
|
38
|
+
(L161). Announce-list filtering to `http(s)://`/`wss://` is ours to do.
|
|
39
|
+
|
|
40
|
+
## The hard part: no dialer hook for TCP peers
|
|
41
|
+
|
|
42
|
+
WebTorrent has **no public option** to supply a custom socket/dialer for
|
|
43
|
+
outgoing peer connections — it calls `net.connect(opts)` directly inside
|
|
44
|
+
`torrent.js`. Three ways around it:
|
|
45
|
+
|
|
46
|
+
### Approach A — userland, scoped monkeypatch of `net.connect` (+ disable UDP)
|
|
47
|
+
Wrap `net.connect` so that for a **peer destination** it returns a socket
|
|
48
|
+
tunneled through SOCKS5 (via the already-present `socks` package —
|
|
49
|
+
`SocksClient.createConnection({ proxy, command:'connect', destination })`),
|
|
50
|
+
and for everything else (our loopback HTTP server, the signalling tunnel,
|
|
51
|
+
ffmpeg's `127.0.0.1` fetch, DNS) it calls the real `net.connect`.
|
|
52
|
+
- The `socks` dep is ALREADY in `node_modules` (currently transitive) — would
|
|
53
|
+
become a direct dependency.
|
|
54
|
+
- Shim detail: `net.connect` returns a socket synchronously and connects
|
|
55
|
+
async; `SocksClient.createConnection` is Promise-based. Need a small adapter
|
|
56
|
+
that returns a `net.Socket`-like duplex immediately and attaches the real
|
|
57
|
+
tunneled socket once the SOCKS handshake resolves (or emits `error`). The
|
|
58
|
+
`socks` package's event API supports this.
|
|
59
|
+
- Scoping rule: SOCKS only for non-local IPv4/IPv6 destinations; NEVER for
|
|
60
|
+
`127.0.0.1`/`::1`/the tunnel host — otherwise we'd route our own control
|
|
61
|
+
plane through the VPN.
|
|
62
|
+
- Trackers/webseeds (HTTP/WSS): pass a `socks-proxy-agent` as the HTTP/ws
|
|
63
|
+
agent to `bittorrent-tracker` / webseed fetches (need to confirm WebTorrent
|
|
64
|
+
threads an `agent` option through; may need a small patch).
|
|
65
|
+
- Pros: no fork, no build-chain changes, deployment-agnostic. Cons: global
|
|
66
|
+
monkeypatch is delicate; must be airtight on the scoping rule.
|
|
67
|
+
|
|
68
|
+
### Approach B — patch/fork WebTorrent to add a `createConnection` hook
|
|
69
|
+
Add an option so `torrent.js` uses `opts.createConnection ?? net.connect`.
|
|
70
|
+
Cleaner and explicit, but a fork to maintain (or an upstream PR — issue
|
|
71
|
+
webtorrent#807 "SOCKS Proxy Support" is open and unresolved, so upstream is
|
|
72
|
+
unlikely soon). More correct long-term if A proves too fragile.
|
|
73
|
+
|
|
74
|
+
### Approach C — network-level (VPN container / namespace), NOT in-proxy
|
|
75
|
+
Run the whole proxy process behind a VPN with a kill-switch (e.g. a `gluetun`
|
|
76
|
+
sidecar container, or a Linux network namespace bound to the VPN). Catches
|
|
77
|
+
ALL traffic — TCP, µTP, DHT, UDP trackers — leak-proof, zero proxy code.
|
|
78
|
+
Trade-off: not per-app (the whole proxy egresses via VPN, including the
|
|
79
|
+
signalling tunnel unless split), heavier ops, and awkward for the HA addon
|
|
80
|
+
(`host_network: true`). This is the ROBUST alternative to document alongside;
|
|
81
|
+
for a bare-Docker/npm operator it may actually be the better answer than
|
|
82
|
+
in-proxy SOCKS5.
|
|
83
|
+
|
|
84
|
+
## Kill-switch (fail-closed) — mandatory for the feature to mean anything
|
|
85
|
+
If the SOCKS5 relay is down, torrent traffic must NOT silently fall back to a
|
|
86
|
+
direct connection (that leaks the exact IP we set out to hide). With Approach
|
|
87
|
+
A this is natural: a failed SOCKS handshake fails the peer connection with no
|
|
88
|
+
direct retry, AND all UDP paths are already disabled, so nothing bypasses the
|
|
89
|
+
relay. Must add a startup connectivity self-test + a clear log line
|
|
90
|
+
(BitPlay's "test proxy" pattern), and surface "proxy down → torrent paused"
|
|
91
|
+
rather than leaking.
|
|
92
|
+
|
|
93
|
+
## Recommended direction (when we build it)
|
|
94
|
+
1. Primary: **Approach A** — `socks` for TCP peers via a scoped `net.connect`
|
|
95
|
+
shim + `socks-proxy-agent` for HTTP/WSS trackers + webseeds, with
|
|
96
|
+
`utp:false`, `dht:false`, `lsd:false`, and announce-list filtered to
|
|
97
|
+
TCP-based trackers. Fail-closed by construction.
|
|
98
|
+
2. Document **Approach C** (VPN container) in the addon/deployment docs as the
|
|
99
|
+
leak-proof alternative for operators who prefer it.
|
|
100
|
+
3. Approach B only if A's monkeypatch proves too fragile in practice.
|
|
101
|
+
|
|
102
|
+
## Open questions to resolve at build time (not now)
|
|
103
|
+
- Does WebTorrent 2.8.x thread an `agent`/proxy option to `bittorrent-tracker`
|
|
104
|
+
and webseed fetches, or is a small patch needed there too?
|
|
105
|
+
- `socks` socket-shim: cleanest way to present a pre-connect `net.Socket`
|
|
106
|
+
(custom Duplex vs a paused real socket) without confusing
|
|
107
|
+
`bittorrent-protocol`'s wire setup.
|
|
108
|
+
- Incoming peers: with UDP off and outbound via SOCKS, do we still accept
|
|
109
|
+
inbound TCP peers directly (that inbound listener exposes the home IP to
|
|
110
|
+
connecting peers)? For full hiding, inbound may need to be disabled too
|
|
111
|
+
(accept that peer discovery becomes outbound-only) — decide per privacy goal.
|
|
112
|
+
- Interaction with the UPnP port mapping / WebRTC UDP port (those are for the
|
|
113
|
+
viewer transport, NOT torrent egress — must stay direct; confirm the shim's
|
|
114
|
+
scoping never touches them).
|
package/package.json
CHANGED
package/services/torrent-pool.js
CHANGED
|
@@ -24,10 +24,15 @@ const WEBTORRENT_STORE_ROOT = path.join(os.tmpdir(), "webtorrent");
|
|
|
24
24
|
// idle (viewer gone) frees the disk. Re-requesting re-adds (re-downloads) it.
|
|
25
25
|
const TORRENT_IDLE_TTL_MS = 300_000;
|
|
26
26
|
|
|
27
|
-
// Bytes ahead of a read position to mark CRITICAL
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
27
|
+
// Bytes ahead of a read position to mark CRITICAL on each range request. In
|
|
28
|
+
// WebTorrent, `critical` does NOT reorder the sequential piece scan — it enables
|
|
29
|
+
// HOTSWAP (re-request a block from a faster peer when a slower one already
|
|
30
|
+
// reserved it), so this is the near read-ahead cushion where stealing from slow
|
|
31
|
+
// peers pays off. The actual "download the seek target first" effect comes from
|
|
32
|
+
// deselecting the gap BEHIND the playhead (see prioritizeByteRange). Kept a
|
|
33
|
+
// moving window (reset each call) so criticality never accumulates over the
|
|
34
|
+
// whole file across seeks, which would make hotswap thrash.
|
|
35
|
+
const PRIORITY_WINDOW_BYTES = 16 * 1024 * 1024;
|
|
31
36
|
|
|
32
37
|
// The file's header/index region the codec probe needs (phase 1). Must match
|
|
33
38
|
// the ranges prefetchFileEdges fetches: leading bytes + trailing bytes.
|
|
@@ -43,12 +48,91 @@ const HEADER_TAIL_BYTES = 2 * 1024 * 1024;
|
|
|
43
48
|
const DISK_CAP_ABSOLUTE_MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GB
|
|
44
49
|
const DISK_CAP_SWEEP_INTERVAL_MS = 30_000;
|
|
45
50
|
|
|
51
|
+
// Adaptive upload. Seeding to the BitTorrent swarm does not help our viewer (we
|
|
52
|
+
// deliver over our own WebRTC/HTTPS channel) — it is pure uplink cost and the
|
|
53
|
+
// riskiest legal act (active distribution). So the default is minimal: no
|
|
54
|
+
// seeding when nothing is being watched, and only a token upload while actively
|
|
55
|
+
// downloading. BUT zero upload can get us choked by tit-for-tat (peers re-rank
|
|
56
|
+
// and stop sending) → slower download → the exact starvation we fight. So the
|
|
57
|
+
// limit is ADAPTIVE: raised only when download is starving AND the wires show
|
|
58
|
+
// reciprocity is the cause (many peers we want data from are choking us).
|
|
59
|
+
const UPLOAD_FLOOR_BYTES = 50 * 1024; // token upload while a reader is active
|
|
60
|
+
const UPLOAD_BOOST_BYTES = 512 * 1024; // raised to earn tit-for-tat unchoke slots
|
|
61
|
+
const UPLOAD_STARVING_SPEED_BYTES = 200 * 1024; // download below this (with demand) = starving
|
|
62
|
+
const UPLOAD_CHOKED_WIRE_THRESHOLD = 2; // interested-but-choked wires implying reciprocity
|
|
63
|
+
const UPLOAD_ADJUST_INTERVAL_MS = 5_000;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Decide the client-wide upload limit (bytes/sec) from the torrents that
|
|
67
|
+
* currently have an active reader. Pure function so the policy is unit-testable
|
|
68
|
+
* without a live swarm.
|
|
69
|
+
*
|
|
70
|
+
* - No active readers → 0 (stop seeding entirely; nothing is being watched).
|
|
71
|
+
* - Any active torrent starving (still wants data, download barely trickling)
|
|
72
|
+
* AND showing reciprocity choke (>= threshold wires we are interested in that
|
|
73
|
+
* are choking us) → boost, to earn unchoke slots.
|
|
74
|
+
* - Otherwise → floor (token upload, avoids an immediate choke without seeding).
|
|
75
|
+
*
|
|
76
|
+
* @param {Array<{ wires?: Array<{ amInterested?: boolean, peerChoking?: boolean }>, downloadSpeed?: number, progress?: number, name?: string }>} activeTorrents
|
|
77
|
+
* @param {{ floor?: number, boost?: number, starvingSpeed?: number, chokedThreshold?: number }} [opts]
|
|
78
|
+
* @returns {{ bytesPerSec: number, reason: string }}
|
|
79
|
+
*/
|
|
80
|
+
export function decideUploadLimit(activeTorrents, opts = {}) {
|
|
81
|
+
const floor = opts.floor ?? UPLOAD_FLOOR_BYTES;
|
|
82
|
+
const boost = opts.boost ?? UPLOAD_BOOST_BYTES;
|
|
83
|
+
const starvingSpeed = opts.starvingSpeed ?? UPLOAD_STARVING_SPEED_BYTES;
|
|
84
|
+
const chokedThreshold = opts.chokedThreshold ?? UPLOAD_CHOKED_WIRE_THRESHOLD;
|
|
85
|
+
|
|
86
|
+
if (!Array.isArray(activeTorrents) || activeTorrents.length === 0) {
|
|
87
|
+
return { bytesPerSec: 0, reason: "idle: no active readers — stop seeding" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const torrent of activeTorrents) {
|
|
91
|
+
const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
|
|
92
|
+
const chokedInterested = wires.filter(
|
|
93
|
+
(wire) => wire && wire.amInterested === true && wire.peerChoking === true
|
|
94
|
+
).length;
|
|
95
|
+
const downloadSpeed = typeof torrent?.downloadSpeed === "number" ? torrent.downloadSpeed : 0;
|
|
96
|
+
const progress = typeof torrent?.progress === "number" ? torrent.progress : 0;
|
|
97
|
+
const starving = progress < 1 && downloadSpeed < starvingSpeed;
|
|
98
|
+
if (starving && chokedInterested >= chokedThreshold) {
|
|
99
|
+
const name = typeof torrent?.name === "string" ? torrent.name : "?";
|
|
100
|
+
return {
|
|
101
|
+
bytesPerSec: boost,
|
|
102
|
+
reason:
|
|
103
|
+
`earn unchoke — "${name}" choked=${chokedInterested}/${wires.length} ` +
|
|
104
|
+
`down=${Math.round(downloadSpeed / 1024)}KB/s`
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { bytesPerSec: floor, reason: "active readers, not choke-starved" };
|
|
110
|
+
}
|
|
111
|
+
|
|
46
112
|
/**
|
|
47
113
|
* Compute the default disk cap: the smaller of a fixed 10 GB and half of the
|
|
48
114
|
* currently free space on the store's filesystem (so a tiny host is never
|
|
49
115
|
* asked to hold more than it can). Best-effort; falls back to the fixed max
|
|
50
116
|
* when the filesystem cannot be stat'd.
|
|
51
117
|
*
|
|
118
|
+
* Format a WebTorrent warning for logging: message plus a bounded stack.
|
|
119
|
+
* WebTorrent surfaces internal peer-connection failures (e.g. the µTP
|
|
120
|
+
* null-peer NPEs, webtorrent#1932/#1940) as non-fatal "warning" events
|
|
121
|
+
* carrying only a terse message; the stack pinpoints the exact library path,
|
|
122
|
+
* so we log the first few frames to diagnose which failure it is.
|
|
123
|
+
*
|
|
124
|
+
* @param {unknown} warning
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
function formatWarning(warning) {
|
|
128
|
+
if (!(warning instanceof Error)) {
|
|
129
|
+
return String(warning);
|
|
130
|
+
}
|
|
131
|
+
const stack = typeof warning.stack === "string" ? warning.stack.split("\n").slice(0, 4).join(" | ") : "";
|
|
132
|
+
return stack || warning.message;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
52
136
|
* @param {string} storePath
|
|
53
137
|
* @returns {number}
|
|
54
138
|
*/
|
|
@@ -122,6 +206,12 @@ export class TorrentPool {
|
|
|
122
206
|
/** Periodic disk-cap enforcement timer. */
|
|
123
207
|
#diskSweepTimer = null;
|
|
124
208
|
|
|
209
|
+
/** Current client-wide upload limit in bytes/sec (adaptive). -1 = not yet set. */
|
|
210
|
+
#uploadLimit = -1;
|
|
211
|
+
|
|
212
|
+
/** Periodic adaptive-upload adjustment timer. */
|
|
213
|
+
#uploadAdjustTimer = null;
|
|
214
|
+
|
|
125
215
|
/**
|
|
126
216
|
* @param {{ maxDiskBytes?: number }} [options]
|
|
127
217
|
* `maxDiskBytes` caps total downloaded torrent data; when omitted a
|
|
@@ -162,8 +252,7 @@ export class TorrentPool {
|
|
|
162
252
|
logger.error(`WebTorrent client error: ${error.message}`);
|
|
163
253
|
});
|
|
164
254
|
this.client.on("warning", (warning) => {
|
|
165
|
-
|
|
166
|
-
logger.warn(`torrent-pool: client warning: ${message}`);
|
|
255
|
+
logger.warn(`torrent-pool: client warning: ${formatWarning(warning)}`);
|
|
167
256
|
});
|
|
168
257
|
|
|
169
258
|
this.#maxDiskBytes = Number.isFinite(maxDiskBytes) && maxDiskBytes >= 0
|
|
@@ -175,6 +264,41 @@ export class TorrentPool {
|
|
|
175
264
|
this.#diskSweepTimer = setInterval(() => this.#enforceDiskCap(), DISK_CAP_SWEEP_INTERVAL_MS);
|
|
176
265
|
this.#diskSweepTimer.unref?.();
|
|
177
266
|
}
|
|
267
|
+
|
|
268
|
+
// Adaptive upload: start with seeding OFF (nothing is being watched yet),
|
|
269
|
+
// then let the periodic adjuster raise it to the floor while a reader is
|
|
270
|
+
// active and to the boost when download is choke-starved. WebTorrent's
|
|
271
|
+
// default is unlimited upload, which we explicitly do NOT want.
|
|
272
|
+
if (typeof this.client.throttleUpload === "function") {
|
|
273
|
+
this.client.throttleUpload(0);
|
|
274
|
+
this.#uploadLimit = 0;
|
|
275
|
+
}
|
|
276
|
+
this.#uploadAdjustTimer = setInterval(() => this.#adjustUploadLimit(), UPLOAD_ADJUST_INTERVAL_MS);
|
|
277
|
+
this.#uploadAdjustTimer.unref?.();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Re-evaluate and apply the client-wide upload limit from current swarm state
|
|
282
|
+
* (see {@link decideUploadLimit}). Runs on a timer; only calls into WebTorrent
|
|
283
|
+
* when the target changes, and logs each change for field tuning.
|
|
284
|
+
*
|
|
285
|
+
* @returns {void}
|
|
286
|
+
*/
|
|
287
|
+
#adjustUploadLimit() {
|
|
288
|
+
if (!this.client || this.client.destroyed || typeof this.client.throttleUpload !== "function") {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const active = [...this.torrents.values()].filter((torrent) => {
|
|
292
|
+
const usage = this.fileUsageByTorrent.get(torrent);
|
|
293
|
+
return usage && usage.size > 0;
|
|
294
|
+
});
|
|
295
|
+
const { bytesPerSec, reason } = decideUploadLimit(active);
|
|
296
|
+
if (bytesPerSec === this.#uploadLimit) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
this.#uploadLimit = bytesPerSec;
|
|
300
|
+
this.client.throttleUpload(bytesPerSec);
|
|
301
|
+
logger.info(`torrent-pool: upload limit -> ${Math.round(bytesPerSec / 1024)} KB/s (${reason})`);
|
|
178
302
|
}
|
|
179
303
|
|
|
180
304
|
/**
|
|
@@ -250,8 +374,7 @@ export class TorrentPool {
|
|
|
250
374
|
);
|
|
251
375
|
|
|
252
376
|
torrent.on("warning", (warning) => {
|
|
253
|
-
|
|
254
|
-
logger.warn(`torrent-pool: [${label}] warning: ${message}`);
|
|
377
|
+
logger.warn(`torrent-pool: [${label}] warning: ${formatWarning(warning)}`);
|
|
255
378
|
});
|
|
256
379
|
|
|
257
380
|
// bittorrent-tracker's Client emits "update" with each announce response.
|
|
@@ -685,16 +808,42 @@ export class TorrentPool {
|
|
|
685
808
|
}
|
|
686
809
|
|
|
687
810
|
/**
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
691
|
-
*
|
|
692
|
-
*
|
|
811
|
+
* Bias the torrent's download toward the current read position, so a seek
|
|
812
|
+
* downloads the seek target first instead of waiting behind the sequential
|
|
813
|
+
* backlog (which caused ~15-18 s stalls when seeking into an undownloaded
|
|
814
|
+
* region). Called on every range request.
|
|
815
|
+
*
|
|
816
|
+
* Two levers, matched to how WebTorrent's picker actually works:
|
|
817
|
+
*
|
|
818
|
+
* 1. **Demote the gap BEHIND the playhead** — `deselect(fileStart, playhead-1)`.
|
|
819
|
+
* The picker scans each selection sequentially from its first UNdownloaded
|
|
820
|
+
* piece; with the whole file selected, a far forward seek would make it
|
|
821
|
+
* fetch the undownloaded gap behind the new position first. Removing that
|
|
822
|
+
* gap from the selection makes the scan START at the playhead, so all peer
|
|
823
|
+
* capacity goes to the pieces the player needs next. This only STOPS
|
|
824
|
+
* fetching the gap; already-downloaded pieces stay on disk (deleting them
|
|
825
|
+
* is Disk hygiene Level 2), and a later backward seek re-selects the region
|
|
826
|
+
* via this same call. The whole file is re-selected by `file.select()` on
|
|
827
|
+
* the next `acquireFile`, so nothing is permanently dropped.
|
|
828
|
+
*
|
|
829
|
+
* 2. **Critical read-ahead window** — `critical(playhead, playhead+window)`.
|
|
830
|
+
* `critical` does not reorder the scan; it enables HOTSWAP (re-request a
|
|
831
|
+
* block from a faster peer when a slow one reserved it) over the near
|
|
832
|
+
* window. Reset first so criticality stays a moving window rather than
|
|
833
|
+
* accumulating over the whole file across seeks.
|
|
834
|
+
*
|
|
835
|
+
* Scope: single active reader per file (≈100% today). The multi-viewer union
|
|
836
|
+
* window — demote only where behind for ALL sessions — is deferred (roadmap
|
|
837
|
+
* item 23); here the latest read position wins.
|
|
838
|
+
*
|
|
839
|
+
* The pinned head/tail (prefetchFileEdges, codec probe) is downloaded up front
|
|
840
|
+
* and lives forward of the playhead (tail) or is already on disk (head), so
|
|
841
|
+
* demotion never costs the probe its data.
|
|
693
842
|
*
|
|
694
843
|
* @param {import("webtorrent").Torrent} torrent
|
|
695
844
|
* @param {number} fileIndex
|
|
696
845
|
* @param {number} byteStart - Start offset within the file.
|
|
697
|
-
* @param {number} [windowBytes] - Bytes ahead of `byteStart` to
|
|
846
|
+
* @param {number} [windowBytes] - Bytes ahead of `byteStart` to mark critical.
|
|
698
847
|
* @returns {void}
|
|
699
848
|
*/
|
|
700
849
|
prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes = PRIORITY_WINDOW_BYTES) {
|
|
@@ -710,18 +859,43 @@ export class TorrentPool {
|
|
|
710
859
|
return;
|
|
711
860
|
}
|
|
712
861
|
const fileOffset = Number.isFinite(file.offset) ? file.offset : 0;
|
|
862
|
+
const fileLength = Number(file.length);
|
|
863
|
+
if (!Number.isFinite(fileLength) || fileLength <= 0) {
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const fileStartPiece = Math.floor(fileOffset / pieceLength);
|
|
867
|
+
const fileEndPiece = Math.floor((fileOffset + fileLength - 1) / pieceLength);
|
|
868
|
+
|
|
713
869
|
const safeStart = Math.max(0, Number(byteStart) || 0);
|
|
714
870
|
const absStart = fileOffset + safeStart;
|
|
715
|
-
const
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
871
|
+
const playheadPiece = Math.floor(absStart / pieceLength);
|
|
872
|
+
const absWindowEnd = Math.min(
|
|
873
|
+
fileOffset + fileLength - 1,
|
|
874
|
+
absStart + Math.max(1, windowBytes) - 1
|
|
875
|
+
);
|
|
876
|
+
const windowEndPiece = Math.floor(absWindowEnd / pieceLength);
|
|
877
|
+
|
|
878
|
+
// (1) Demote the gap behind the playhead so the picker scans forward from
|
|
879
|
+
// the read position. Only when there IS a gap (not at the file start).
|
|
880
|
+
if (playheadPiece > fileStartPiece && typeof torrent.deselect === "function") {
|
|
881
|
+
try {
|
|
882
|
+
torrent.deselect(fileStartPiece, playheadPiece - 1);
|
|
883
|
+
} catch {
|
|
884
|
+
// Best effort — never break streaming because demotion failed.
|
|
885
|
+
}
|
|
720
886
|
}
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
887
|
+
|
|
888
|
+
// (2) Reset criticality to a moving read-ahead window (hotswap over the near
|
|
889
|
+
// pieces), so it does not accumulate over the whole file across seeks.
|
|
890
|
+
if (Array.isArray(torrent._critical)) {
|
|
891
|
+
torrent._critical.length = 0;
|
|
892
|
+
}
|
|
893
|
+
if (windowEndPiece >= playheadPiece) {
|
|
894
|
+
try {
|
|
895
|
+
torrent.critical(playheadPiece, windowEndPiece);
|
|
896
|
+
} catch {
|
|
897
|
+
// Best effort.
|
|
898
|
+
}
|
|
725
899
|
}
|
|
726
900
|
}
|
|
727
901
|
|
|
@@ -744,6 +918,11 @@ export class TorrentPool {
|
|
|
744
918
|
clearInterval(this.#diskSweepTimer);
|
|
745
919
|
this.#diskSweepTimer = null;
|
|
746
920
|
}
|
|
921
|
+
// Stop periodic adaptive-upload adjustment.
|
|
922
|
+
if (this.#uploadAdjustTimer) {
|
|
923
|
+
clearInterval(this.#uploadAdjustTimer);
|
|
924
|
+
this.#uploadAdjustTimer = null;
|
|
925
|
+
}
|
|
747
926
|
// Cancel any pending idle-removal timers — destroyAll handles teardown.
|
|
748
927
|
for (const timer of this.#idleTimers.values()) {
|
|
749
928
|
clearTimeout(timer);
|