@torrent-tv/proxy 2.80.8 → 2.80.9
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 +1666 -1658
- package/bin/cli.js +606 -596
- package/package.json +1 -1
- package/services/encode/CoverageMap.js +428 -401
- package/services/encode/SegmentStore.js +718 -669
- package/services/hls-session-manager.js +7 -19
- package/services/memory-report.js +596 -592
- package/services/orchestrators/EncodeOrchestrator.js +726 -594
- package/test/coverage-follows-the-disk.test.js +187 -0
- package/test/coverage-map.test.js +195 -178
- package/test/encode-plan.test.js +539 -540
- package/test/run-intervals.test.js +100 -100
- package/test/segment-store.test.js +216 -187
package/bin/cli.js
CHANGED
|
@@ -1,596 +1,606 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* @file CLI entry point for the torrent-tv proxy.
|
|
4
|
-
*
|
|
5
|
-
* Parses command-line arguments, starts the local HTTP server, opens a
|
|
6
|
-
* persistent WebSocket tunnel to the registry server, and registers this
|
|
7
|
-
* proxy. Liveness is tracked via the tunnel connection — the proxy re-registers
|
|
8
|
-
* automatically on reconnect so the server's in-memory store stays consistent.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
// FIRST, and it must stay first: it sets how many blocking calls this process
|
|
12
|
-
// can have in flight, and a module's imports are evaluated before its body, so
|
|
13
|
-
// anything imported above it would get the default pool. See the file itself
|
|
14
|
-
// for the measurement that made it necessary.
|
|
15
|
-
import "../services/thread-pool.js";
|
|
16
|
-
import { Command } from "commander";
|
|
17
|
-
import crypto from "node:crypto";
|
|
18
|
-
import os from "node:os";
|
|
19
|
-
import { spawnSync } from "node:child_process";
|
|
20
|
-
import { createRequire } from "node:module";
|
|
21
|
-
import ffmpegStatic from "ffmpeg-static";
|
|
22
|
-
import { startProxyServer } from "../server.js";
|
|
23
|
-
import { registerClient } from "../services/registry-api.js";
|
|
24
|
-
import { createTunnelClient } from "../services/tunnel-client.js";
|
|
25
|
-
import { createWebRtcManager } from "../services/webrtc-manager.js";
|
|
26
|
-
import { createDataChannelHandler } from "../services/data-channel-handler.js";
|
|
27
|
-
import { pruneCoreDumps } from "../services/core-dumps.js";
|
|
28
|
-
import { adoptOrphanRingFiles, createPacketWitness, pruneWitnessCaptures } from "../services/packet-witness.js";
|
|
29
|
-
import { createUsrsctpStateReader } from "../services/usrsctp-state.js";
|
|
30
|
-
import { startMemoryReport } from "../services/memory-report.js";
|
|
31
|
-
import { fragmentBufferCollection } from "../services/torrent-worker/client.js";
|
|
32
|
-
import { collectHealthMetrics } from "../services/health-collector.js";
|
|
33
|
-
import { createPortMapper } from "../services/port-mapper.js";
|
|
34
|
-
import { classifyNat } from "../services/nat-classifier.js";
|
|
35
|
-
import { DEFAULT_SEGMENT_FORMAT_ID, SEGMENT_FORMAT_IDS } from "../services/segment-formats/index.js";
|
|
36
|
-
import { logToFile, logger } from "../utils/logger.js";
|
|
37
|
-
|
|
38
|
-
const require = createRequire(import.meta.url);
|
|
39
|
-
const { version: PROXY_VERSION } = require("../package.json");
|
|
40
|
-
|
|
41
|
-
// Last-resort process guard. This proxy runs UNTRUSTED torrents through
|
|
42
|
-
// WebTorrent, which can throw ASYNCHRONOUSLY on a malformed source — e.g. a
|
|
43
|
-
// v2-only / hybrid magnet crashes `arr2hex(parsedTorrent.infoHash)` deep inside
|
|
44
|
-
// `Torrent._onTorrentId` (undefined v1 infohash), in a microtask that bypasses
|
|
45
|
-
// the client "error" event. Without this, one bad torrent takes down the whole
|
|
46
|
-
// node and every viewer on it, and the addon restarts in a crash loop. Log the
|
|
47
|
-
// full stack and keep serving: the offending session fails on its own; everyone
|
|
48
|
-
// else is unaffected. (The add path also pre-validates the infohash, so this is
|
|
49
|
-
// a backstop for anything not caught there.)
|
|
50
|
-
process.on("uncaughtException", (error) => {
|
|
51
|
-
logger.error(`uncaughtException (kept alive): ${error?.stack ?? error}`);
|
|
52
|
-
});
|
|
53
|
-
process.on("unhandledRejection", (reason) => {
|
|
54
|
-
logger.error(`unhandledRejection (kept alive): ${reason instanceof Error ? reason.stack : String(reason)}`);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
const program = new Command();
|
|
58
|
-
|
|
59
|
-
const HELP_EXAMPLES = `
|
|
60
|
-
Examples:
|
|
61
|
-
torrent-tv-proxy --server-url http://localhost:8080
|
|
62
|
-
torrent-tv-proxy --server-url http://localhost:8080 --host 0.0.0.0 --port 9090
|
|
63
|
-
torrent-tv-proxy --server-url http://localhost:8080 --public-base-url https://proxy.example.com
|
|
64
|
-
torrent-tv-proxy --server-url http://localhost:8080 --ffmpeg-bin /usr/local/bin/ffmpeg
|
|
65
|
-
torrent-tv-proxy --server-url http://localhost:8080 --no-transcode-audio
|
|
66
|
-
|
|
67
|
-
Notes:
|
|
68
|
-
- --help prints this message and exits with code 0.
|
|
69
|
-
- Video transcode is available automatically for per-session fallback when requested by client API.
|
|
70
|
-
`;
|
|
71
|
-
|
|
72
|
-
if (process.argv.includes("help")) {
|
|
73
|
-
process.argv = [process.argv[0], process.argv[1], "--help"];
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
program
|
|
77
|
-
.name("torrent-proxy-client")
|
|
78
|
-
.description("Expose torrent files over HTTP stream endpoints for browser playback.")
|
|
79
|
-
.requiredOption("--server-url <url>", "Registry server base URL")
|
|
80
|
-
.option("--public-base-url <url>", "Direct URL advertised to browser clients")
|
|
81
|
-
.option("--host <host>", "Local bind host", "127.0.0.1")
|
|
82
|
-
.option("--port <port>", "Local HTTP port", "9090")
|
|
83
|
-
.option("--id <id>", "Stable client id")
|
|
84
|
-
.option("--name <name>", "Display name")
|
|
85
|
-
.option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
|
|
86
|
-
.option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
|
|
87
|
-
.option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
|
|
88
|
-
.option(
|
|
89
|
-
"--usrsctp-state",
|
|
90
|
-
"Read usrsctp's association state with gdb when a wedge is declared. OFF by default: gdb attaches to THIS process and stops every thread of it while it works."
|
|
91
|
-
)
|
|
92
|
-
.option("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
|
|
93
|
-
.option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default 512MB)")
|
|
94
|
-
.option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
|
|
95
|
-
.option(
|
|
96
|
-
"--state-dir <path>",
|
|
97
|
-
"Where to keep what this host has measured about itself (default: beside the installed proxy)"
|
|
98
|
-
)
|
|
99
|
-
.option(
|
|
100
|
-
"--log-file <path>",
|
|
101
|
-
"Also write the log to this file, so a crash or an update does not take it with them"
|
|
102
|
-
)
|
|
103
|
-
.option(
|
|
104
|
-
"--segment-format <format>",
|
|
105
|
-
`HLS output container: ${SEGMENT_FORMAT_IDS.join(" | ")}`,
|
|
106
|
-
DEFAULT_SEGMENT_FORMAT_ID
|
|
107
|
-
)
|
|
108
|
-
.option("--token <token>", "Registration token", "")
|
|
109
|
-
.addHelpText("after", HELP_EXAMPLES);
|
|
110
|
-
|
|
111
|
-
program.parse(process.argv);
|
|
112
|
-
const options = program.opts();
|
|
113
|
-
|
|
114
|
-
const localPort = Number(options.port);
|
|
115
|
-
if (!Number.isInteger(localPort) || localPort <= 0 || localPort > 65535) {
|
|
116
|
-
logger.error("Invalid --port value.");
|
|
117
|
-
process.exit(1);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const serverUrl = String(options.serverUrl).replace(/\/+$/, "");
|
|
121
|
-
const bindHost = String(options.host);
|
|
122
|
-
const explicitBaseUrl = options.publicBaseUrl
|
|
123
|
-
? String(options.publicBaseUrl).replace(/\/+$/, "")
|
|
124
|
-
: "";
|
|
125
|
-
const clientId = options.id ? String(options.id) : crypto.randomUUID();
|
|
126
|
-
const clientName = options.name ? String(options.name) : `proxy-${clientId.slice(0, 8)}`;
|
|
127
|
-
const token = String(options.token ?? "");
|
|
128
|
-
const transcodeAudio = options.transcodeAudio !== false;
|
|
129
|
-
const portMappingEnabled = options.portMapping !== false;
|
|
130
|
-
// Optional disk cap. undefined → the pool computes its own default; a valid
|
|
131
|
-
// non-negative number (0 disables) → passed through.
|
|
132
|
-
const maxDiskBytes =
|
|
133
|
-
options.maxDiskBytes !== undefined && Number.isFinite(Number(options.maxDiskBytes)) && Number(options.maxDiskBytes) >= 0
|
|
134
|
-
? Number(options.maxDiskBytes)
|
|
135
|
-
: undefined;
|
|
136
|
-
// Per-torrent memory budget for resident pieces. Pieces past it spill to disk
|
|
137
|
-
// rather than being lost, so a small value costs read latency, never data.
|
|
138
|
-
const memoryBytes =
|
|
139
|
-
options.memoryBytes !== undefined && Number.isFinite(Number(options.memoryBytes)) && Number(options.memoryBytes) > 0
|
|
140
|
-
? Number(options.memoryBytes)
|
|
141
|
-
: undefined;
|
|
142
|
-
const bundledFfmpegBin = typeof ffmpegStatic === "string" ? ffmpegStatic : "";
|
|
143
|
-
const ffmpegBin = options.ffmpegBin ? String(options.ffmpegBin) : bundledFfmpegBin || "ffmpeg";
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Verify that the ffmpeg binary is reachable and exits cleanly.
|
|
147
|
-
* Throws with a descriptive message when the check fails.
|
|
148
|
-
*
|
|
149
|
-
* @returns {void}
|
|
150
|
-
*/
|
|
151
|
-
function assertFfmpegAvailability() {
|
|
152
|
-
const probe = spawnSync(ffmpegBin, ["-version"], {
|
|
153
|
-
stdio: "ignore",
|
|
154
|
-
windowsHide: true,
|
|
155
|
-
timeout: 5000
|
|
156
|
-
});
|
|
157
|
-
if (probe.error) {
|
|
158
|
-
const message = probe.error instanceof Error ? probe.error.message : String(probe.error);
|
|
159
|
-
throw new Error(`Audio transcode is enabled, but ffmpeg is unavailable (${ffmpegBin}): ${message}`);
|
|
160
|
-
}
|
|
161
|
-
if (typeof probe.status === "number" && probe.status !== 0) {
|
|
162
|
-
throw new Error(
|
|
163
|
-
`Audio transcode is enabled, but ffmpeg check failed (${ffmpegBin}, exit code ${probe.status}).`
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/** @type {boolean} */
|
|
169
|
-
let registrationInProgress = false;
|
|
170
|
-
|
|
171
|
-
/** @type {import("fastify").FastifyInstance | null} */
|
|
172
|
-
let app = null;
|
|
173
|
-
|
|
174
|
-
/** @type {number} */
|
|
175
|
-
let actualPort = localPort;
|
|
176
|
-
|
|
177
|
-
/** @type {boolean} */
|
|
178
|
-
let shutdownInProgress = false;
|
|
179
|
-
|
|
180
|
-
/** @type {ReturnType<typeof createTunnelClient> | null} */
|
|
181
|
-
let tunnelClient = null;
|
|
182
|
-
|
|
183
|
-
/** @type {ReturnType<typeof createPortMapper> | null} */
|
|
184
|
-
let portMapper = null;
|
|
185
|
-
|
|
186
|
-
/** @type {ReturnType<typeof createPortMapper> | null} UDP mapping for the WebRTC port. */
|
|
187
|
-
let udpPortMapper = null;
|
|
188
|
-
|
|
189
|
-
/** @type {ReturnType<typeof createWebRtcManager> | null} */
|
|
190
|
-
let webRtcManager = null;
|
|
191
|
-
/**
|
|
192
|
-
* The packet witness, so shutdown can stop the ring it owns.
|
|
193
|
-
*
|
|
194
|
-
* @type {ReturnType<typeof createPacketWitness> | null}
|
|
195
|
-
*/
|
|
196
|
-
let packetWitness = null;
|
|
197
|
-
|
|
198
|
-
/** @type {ReturnType<typeof createUsrsctpStateReader> | null} */
|
|
199
|
-
let usrsctpStateReader = null;
|
|
200
|
-
|
|
201
|
-
/** @type {ReturnType<typeof createDataChannelHandler> | null} */
|
|
202
|
-
let dataChannelHandler = null;
|
|
203
|
-
|
|
204
|
-
/** @type {import("../services/nat-classifier.js").NatClassification | null} Latest NAT classification (for WebRTC port prediction). */
|
|
205
|
-
let natInfo = null;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Register this proxy with the registry server.
|
|
210
|
-
* Silently skips if a registration is already in flight.
|
|
211
|
-
*
|
|
212
|
-
* @returns {Promise<void>}
|
|
213
|
-
*/
|
|
214
|
-
async function registerClientSafe() {
|
|
215
|
-
if (registrationInProgress) {
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
registrationInProgress = true;
|
|
219
|
-
try {
|
|
220
|
-
const result = await registerClient({
|
|
221
|
-
serverUrl,
|
|
222
|
-
id: clientId,
|
|
223
|
-
name: clientName,
|
|
224
|
-
baseUrl: explicitBaseUrl || `http://${bindHost}:${actualPort}`,
|
|
225
|
-
token
|
|
226
|
-
});
|
|
227
|
-
logger.success(`Registered as "${result.client?.name}" (${result.client?.id?.slice(0, 8)})`);
|
|
228
|
-
} finally {
|
|
229
|
-
registrationInProgress = false;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/**
|
|
234
|
-
* Register this proxy with the registry server, retrying indefinitely
|
|
235
|
-
* with exponential backoff until it succeeds. This allows the proxy to
|
|
236
|
-
* survive temporary server outages (restarts, deployments) without crashing.
|
|
237
|
-
*
|
|
238
|
-
* @returns {Promise<void>}
|
|
239
|
-
*/
|
|
240
|
-
async function registerWithRetry() {
|
|
241
|
-
const MAX_DELAY_MS = 60_000;
|
|
242
|
-
let delayMs = 2_000;
|
|
243
|
-
let attempt = 0;
|
|
244
|
-
while (true) {
|
|
245
|
-
attempt++;
|
|
246
|
-
try {
|
|
247
|
-
await registerClientSafe();
|
|
248
|
-
return;
|
|
249
|
-
} catch (error) {
|
|
250
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
251
|
-
logger.warn(`Registration attempt ${attempt} failed: ${message}. Retrying in ${delayMs / 1000}s…`);
|
|
252
|
-
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
253
|
-
delayMs = Math.min(delayMs * 2, MAX_DELAY_MS);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Gracefully shut down the tunnel, heartbeat timer, and HTTP server.
|
|
260
|
-
*
|
|
261
|
-
* @param {string} signal - Signal name (e.g. "SIGINT").
|
|
262
|
-
* @returns {Promise<void>}
|
|
263
|
-
*/
|
|
264
|
-
async function shutdown(signal) {
|
|
265
|
-
if (shutdownInProgress) {
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
shutdownInProgress = true;
|
|
269
|
-
if (tunnelClient) {
|
|
270
|
-
tunnelClient.disconnect();
|
|
271
|
-
tunnelClient = null;
|
|
272
|
-
}
|
|
273
|
-
logger.warn(`Received ${signal}, shutting down...`);
|
|
274
|
-
try {
|
|
275
|
-
// Remove the router port mappings before exiting (lease expiry is the
|
|
276
|
-
// backstop if this is skipped on a hard kill).
|
|
277
|
-
if (portMapper) {
|
|
278
|
-
await portMapper.stop();
|
|
279
|
-
portMapper = null;
|
|
280
|
-
}
|
|
281
|
-
if (udpPortMapper) {
|
|
282
|
-
await udpPortMapper.stop();
|
|
283
|
-
udpPortMapper = null;
|
|
284
|
-
}
|
|
285
|
-
// Close WebRTC sessions and release the shared UDP mux listener socket.
|
|
286
|
-
if (webRtcManager) {
|
|
287
|
-
try { webRtcManager.dispose(); } catch { /* ignore */ }
|
|
288
|
-
webRtcManager = null;
|
|
289
|
-
}
|
|
290
|
-
// Stop the packet ring and remove its scratch files. `process.exit` below
|
|
291
|
-
// does not take child processes with it, so without this a proxy restarted
|
|
292
|
-
// outside a container leaves one tcpdump running per restart.
|
|
293
|
-
if (packetWitness) {
|
|
294
|
-
try { await packetWitness.dispose(); } catch { /* ignore */ }
|
|
295
|
-
packetWitness = null;
|
|
296
|
-
}
|
|
297
|
-
if (app) {
|
|
298
|
-
await app.close();
|
|
299
|
-
}
|
|
300
|
-
process.exit(0);
|
|
301
|
-
} catch (error) {
|
|
302
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
303
|
-
logger.error(`Shutdown failed: ${message}`);
|
|
304
|
-
process.exit(1);
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
try {
|
|
309
|
-
logToFile(options.logFile);
|
|
310
|
-
if (transcodeAudio) {
|
|
311
|
-
assertFfmpegAvailability();
|
|
312
|
-
}
|
|
313
|
-
const started = await startProxyServer({
|
|
314
|
-
host: bindHost,
|
|
315
|
-
port: localPort,
|
|
316
|
-
transcodeAudio,
|
|
317
|
-
ffmpegBin,
|
|
318
|
-
maxDiskBytes,
|
|
319
|
-
memoryBytes,
|
|
320
|
-
segmentFormat: options.segmentFormat,
|
|
321
|
-
stateDir: options.stateDir,
|
|
322
|
-
deliverySink: options.deliverySink === true,
|
|
323
|
-
// Late-bound the same way `webRtcManager` is below: the torrent pool is
|
|
324
|
-
// built inside `startProxyServer`, before `dataChannelHandler` — the
|
|
325
|
-
// thing that actually owns a channel to push down — exists.
|
|
326
|
-
onSubtitleCues: (event) => dataChannelHandler?.publishSubtitleCues(event)
|
|
327
|
-
});
|
|
328
|
-
app = started.app;
|
|
329
|
-
actualPort = started.port;
|
|
330
|
-
const sourceRegistry = started.sourceRegistry;
|
|
331
|
-
const directBaseUrl = explicitBaseUrl || `http://${bindHost}:${actualPort}`;
|
|
332
|
-
|
|
333
|
-
// A native fault writes the whole address space out — 4.18 GB each on the
|
|
334
|
-
// field host, and four of them nearly filled a 235 GB disk. Keep the newest
|
|
335
|
-
// two, which are the evidence for the fault still open, and drop the rest.
|
|
336
|
-
void pruneCoreDumps(options.stateDir);
|
|
337
|
-
// Same policy for the packet captures the send-queue witness writes.
|
|
338
|
-
packetWitness = createPacketWitness({
|
|
339
|
-
log: (message) => logger.info(message),
|
|
340
|
-
dir: options.stateDir || "",
|
|
341
|
-
port: actualPort
|
|
342
|
-
});
|
|
343
|
-
// A process that was KILLED mid-session leaves the ring's last seconds
|
|
344
|
-
// behind, and those seconds contain whatever ended it. Keep them under a name
|
|
345
|
-
// the pruner recognises BEFORE anything starts a new ring over them.
|
|
346
|
-
void adoptOrphanRingFiles(packetWitness.dir).then(() => pruneWitnessCaptures(packetWitness.dir));
|
|
347
|
-
|
|
348
|
-
// Reads usrsctp's own association state via gdb the moment a wedge is
|
|
349
|
-
// declared (roadmap item 11) — no source rebuild, the module ships
|
|
350
|
-
// unstripped. A host without gdb just never gets a reading, the same way a
|
|
351
|
-
// host without tcpdump never gets a packet capture.
|
|
352
|
-
// OFF UNLESS ASKED FOR, and the reason is a field incident rather than
|
|
353
|
-
// caution. On 2026-09-05 a delivery probe declared a wedge that lasted half a
|
|
354
|
-
// second and cleared itself; the reading it triggered attached gdb to this
|
|
355
|
-
// process, which stopped all eighty of its threads. The log ended mid-second,
|
|
356
|
-
// /healthz stopped answering, and the viewer waited a minute and was told the
|
|
357
|
-
// proxy had sent no video. The fifteen-second guard could not fire — it is a
|
|
358
|
-
// timer inside the process gdb had stopped — and killing gdb left the main
|
|
359
|
-
// thread deadlocked for good, so only restarting the addon recovered it.
|
|
360
|
-
//
|
|
361
|
-
// A means of diagnosis may not stop the product. This one attaches to a live
|
|
362
|
-
// process and is triggered by a verdict with a known history of false
|
|
363
|
-
// positives, so it is a thing to switch on deliberately while watching, not
|
|
364
|
-
// something that arms itself.
|
|
365
|
-
usrsctpStateReader = options.usrsctpState === true
|
|
366
|
-
? createUsrsctpStateReader({ log: (message) => logger.info(message) })
|
|
367
|
-
: null;
|
|
368
|
-
|
|
369
|
-
// What this process holds, once a minute. The kernel killed the proxy on
|
|
370
|
-
// 2026-08-28 at 2.4 GB resident and the log had never said a word about
|
|
371
|
-
// memory, so the growth that ended in that kill has no shape in any record we
|
|
372
|
-
// keep. RSS is the figure the OOM killer reads, so RSS is the figure to say.
|
|
373
|
-
// The disk path is where pieces spill and torrent data lands. A budget for
|
|
374
|
-
// memory alone is half a budget: a store that behaves about RAM can still
|
|
375
|
-
// fill the card an addon host boots from, and neither limit was measured
|
|
376
|
-
// before (roadmap item 2).
|
|
377
|
-
startMemoryReport({
|
|
378
|
-
log: (message) => logger.info(message),
|
|
379
|
-
// The other half of the piece-buffer question. Shared memory lives until
|
|
380
|
-
// BOTH isolates let go, so the worker's own count answers only its side;
|
|
381
|
-
// read here, on the same line as the process figures the growth shows up
|
|
382
|
-
// in and at the same instant as them.
|
|
383
|
-
readExtra: () => {
|
|
384
|
-
const fragments = fragmentBufferCollection();
|
|
385
|
-
return fragments.seen === 0
|
|
386
|
-
? ""
|
|
387
|
-
: `piece buffers handed here ${fragments.seen} seen, ${fragments.collected} collected, ` +
|
|
388
|
-
`${fragments.seen - fragments.collected} still alive`;
|
|
389
|
-
},
|
|
390
|
-
// Once a minute cannot see what kills this process. Both out-of-memory
|
|
391
|
-
// kills of 2026-09-02 happened inside a single gap of the old cadence: the
|
|
392
|
-
// last line before the first said 602 MB and the kernel measured 1.72 GB
|
|
393
|
-
// 34 seconds later, and before the second it said 1171 MB and the kernel
|
|
394
|
-
// measured 1.86 GB nine seconds later. The figure is read every second
|
|
395
|
-
// now and written when it has moved by a step worth a line, so a rise of a
|
|
396
|
-
// gigabyte is a curve instead of one number and then a death. Every /proc
|
|
397
|
-
// read stayed behind the decision to write, so a quiet second costs one
|
|
398
|
-
// call to `process.memoryUsage()`.
|
|
399
|
-
intervalMs: 1_000,
|
|
400
|
-
quietMs: 60_000,
|
|
401
|
-
changeBytes: 25 * 1024 * 1024,
|
|
402
|
-
diskPath: os.tmpdir()
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @file CLI entry point for the torrent-tv proxy.
|
|
4
|
+
*
|
|
5
|
+
* Parses command-line arguments, starts the local HTTP server, opens a
|
|
6
|
+
* persistent WebSocket tunnel to the registry server, and registers this
|
|
7
|
+
* proxy. Liveness is tracked via the tunnel connection — the proxy re-registers
|
|
8
|
+
* automatically on reconnect so the server's in-memory store stays consistent.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// FIRST, and it must stay first: it sets how many blocking calls this process
|
|
12
|
+
// can have in flight, and a module's imports are evaluated before its body, so
|
|
13
|
+
// anything imported above it would get the default pool. See the file itself
|
|
14
|
+
// for the measurement that made it necessary.
|
|
15
|
+
import "../services/thread-pool.js";
|
|
16
|
+
import { Command } from "commander";
|
|
17
|
+
import crypto from "node:crypto";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { createRequire } from "node:module";
|
|
21
|
+
import ffmpegStatic from "ffmpeg-static";
|
|
22
|
+
import { startProxyServer } from "../server.js";
|
|
23
|
+
import { registerClient } from "../services/registry-api.js";
|
|
24
|
+
import { createTunnelClient } from "../services/tunnel-client.js";
|
|
25
|
+
import { createWebRtcManager } from "../services/webrtc-manager.js";
|
|
26
|
+
import { createDataChannelHandler } from "../services/data-channel-handler.js";
|
|
27
|
+
import { pruneCoreDumps } from "../services/core-dumps.js";
|
|
28
|
+
import { adoptOrphanRingFiles, createPacketWitness, pruneWitnessCaptures } from "../services/packet-witness.js";
|
|
29
|
+
import { createUsrsctpStateReader } from "../services/usrsctp-state.js";
|
|
30
|
+
import { startMemoryReport } from "../services/memory-report.js";
|
|
31
|
+
import { fragmentBufferCollection } from "../services/torrent-worker/client.js";
|
|
32
|
+
import { collectHealthMetrics } from "../services/health-collector.js";
|
|
33
|
+
import { createPortMapper } from "../services/port-mapper.js";
|
|
34
|
+
import { classifyNat } from "../services/nat-classifier.js";
|
|
35
|
+
import { DEFAULT_SEGMENT_FORMAT_ID, SEGMENT_FORMAT_IDS } from "../services/segment-formats/index.js";
|
|
36
|
+
import { logToFile, logger } from "../utils/logger.js";
|
|
37
|
+
|
|
38
|
+
const require = createRequire(import.meta.url);
|
|
39
|
+
const { version: PROXY_VERSION } = require("../package.json");
|
|
40
|
+
|
|
41
|
+
// Last-resort process guard. This proxy runs UNTRUSTED torrents through
|
|
42
|
+
// WebTorrent, which can throw ASYNCHRONOUSLY on a malformed source — e.g. a
|
|
43
|
+
// v2-only / hybrid magnet crashes `arr2hex(parsedTorrent.infoHash)` deep inside
|
|
44
|
+
// `Torrent._onTorrentId` (undefined v1 infohash), in a microtask that bypasses
|
|
45
|
+
// the client "error" event. Without this, one bad torrent takes down the whole
|
|
46
|
+
// node and every viewer on it, and the addon restarts in a crash loop. Log the
|
|
47
|
+
// full stack and keep serving: the offending session fails on its own; everyone
|
|
48
|
+
// else is unaffected. (The add path also pre-validates the infohash, so this is
|
|
49
|
+
// a backstop for anything not caught there.)
|
|
50
|
+
process.on("uncaughtException", (error) => {
|
|
51
|
+
logger.error(`uncaughtException (kept alive): ${error?.stack ?? error}`);
|
|
52
|
+
});
|
|
53
|
+
process.on("unhandledRejection", (reason) => {
|
|
54
|
+
logger.error(`unhandledRejection (kept alive): ${reason instanceof Error ? reason.stack : String(reason)}`);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const program = new Command();
|
|
58
|
+
|
|
59
|
+
const HELP_EXAMPLES = `
|
|
60
|
+
Examples:
|
|
61
|
+
torrent-tv-proxy --server-url http://localhost:8080
|
|
62
|
+
torrent-tv-proxy --server-url http://localhost:8080 --host 0.0.0.0 --port 9090
|
|
63
|
+
torrent-tv-proxy --server-url http://localhost:8080 --public-base-url https://proxy.example.com
|
|
64
|
+
torrent-tv-proxy --server-url http://localhost:8080 --ffmpeg-bin /usr/local/bin/ffmpeg
|
|
65
|
+
torrent-tv-proxy --server-url http://localhost:8080 --no-transcode-audio
|
|
66
|
+
|
|
67
|
+
Notes:
|
|
68
|
+
- --help prints this message and exits with code 0.
|
|
69
|
+
- Video transcode is available automatically for per-session fallback when requested by client API.
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
if (process.argv.includes("help")) {
|
|
73
|
+
process.argv = [process.argv[0], process.argv[1], "--help"];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
program
|
|
77
|
+
.name("torrent-proxy-client")
|
|
78
|
+
.description("Expose torrent files over HTTP stream endpoints for browser playback.")
|
|
79
|
+
.requiredOption("--server-url <url>", "Registry server base URL")
|
|
80
|
+
.option("--public-base-url <url>", "Direct URL advertised to browser clients")
|
|
81
|
+
.option("--host <host>", "Local bind host", "127.0.0.1")
|
|
82
|
+
.option("--port <port>", "Local HTTP port", "9090")
|
|
83
|
+
.option("--id <id>", "Stable client id")
|
|
84
|
+
.option("--name <name>", "Display name")
|
|
85
|
+
.option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
|
|
86
|
+
.option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
|
|
87
|
+
.option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
|
|
88
|
+
.option(
|
|
89
|
+
"--usrsctp-state",
|
|
90
|
+
"Read usrsctp's association state with gdb when a wedge is declared. OFF by default: gdb attaches to THIS process and stops every thread of it while it works."
|
|
91
|
+
)
|
|
92
|
+
.option("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
|
|
93
|
+
.option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default 512MB)")
|
|
94
|
+
.option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
|
|
95
|
+
.option(
|
|
96
|
+
"--state-dir <path>",
|
|
97
|
+
"Where to keep what this host has measured about itself (default: beside the installed proxy)"
|
|
98
|
+
)
|
|
99
|
+
.option(
|
|
100
|
+
"--log-file <path>",
|
|
101
|
+
"Also write the log to this file, so a crash or an update does not take it with them"
|
|
102
|
+
)
|
|
103
|
+
.option(
|
|
104
|
+
"--segment-format <format>",
|
|
105
|
+
`HLS output container: ${SEGMENT_FORMAT_IDS.join(" | ")}`,
|
|
106
|
+
DEFAULT_SEGMENT_FORMAT_ID
|
|
107
|
+
)
|
|
108
|
+
.option("--token <token>", "Registration token", "")
|
|
109
|
+
.addHelpText("after", HELP_EXAMPLES);
|
|
110
|
+
|
|
111
|
+
program.parse(process.argv);
|
|
112
|
+
const options = program.opts();
|
|
113
|
+
|
|
114
|
+
const localPort = Number(options.port);
|
|
115
|
+
if (!Number.isInteger(localPort) || localPort <= 0 || localPort > 65535) {
|
|
116
|
+
logger.error("Invalid --port value.");
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const serverUrl = String(options.serverUrl).replace(/\/+$/, "");
|
|
121
|
+
const bindHost = String(options.host);
|
|
122
|
+
const explicitBaseUrl = options.publicBaseUrl
|
|
123
|
+
? String(options.publicBaseUrl).replace(/\/+$/, "")
|
|
124
|
+
: "";
|
|
125
|
+
const clientId = options.id ? String(options.id) : crypto.randomUUID();
|
|
126
|
+
const clientName = options.name ? String(options.name) : `proxy-${clientId.slice(0, 8)}`;
|
|
127
|
+
const token = String(options.token ?? "");
|
|
128
|
+
const transcodeAudio = options.transcodeAudio !== false;
|
|
129
|
+
const portMappingEnabled = options.portMapping !== false;
|
|
130
|
+
// Optional disk cap. undefined → the pool computes its own default; a valid
|
|
131
|
+
// non-negative number (0 disables) → passed through.
|
|
132
|
+
const maxDiskBytes =
|
|
133
|
+
options.maxDiskBytes !== undefined && Number.isFinite(Number(options.maxDiskBytes)) && Number(options.maxDiskBytes) >= 0
|
|
134
|
+
? Number(options.maxDiskBytes)
|
|
135
|
+
: undefined;
|
|
136
|
+
// Per-torrent memory budget for resident pieces. Pieces past it spill to disk
|
|
137
|
+
// rather than being lost, so a small value costs read latency, never data.
|
|
138
|
+
const memoryBytes =
|
|
139
|
+
options.memoryBytes !== undefined && Number.isFinite(Number(options.memoryBytes)) && Number(options.memoryBytes) > 0
|
|
140
|
+
? Number(options.memoryBytes)
|
|
141
|
+
: undefined;
|
|
142
|
+
const bundledFfmpegBin = typeof ffmpegStatic === "string" ? ffmpegStatic : "";
|
|
143
|
+
const ffmpegBin = options.ffmpegBin ? String(options.ffmpegBin) : bundledFfmpegBin || "ffmpeg";
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Verify that the ffmpeg binary is reachable and exits cleanly.
|
|
147
|
+
* Throws with a descriptive message when the check fails.
|
|
148
|
+
*
|
|
149
|
+
* @returns {void}
|
|
150
|
+
*/
|
|
151
|
+
function assertFfmpegAvailability() {
|
|
152
|
+
const probe = spawnSync(ffmpegBin, ["-version"], {
|
|
153
|
+
stdio: "ignore",
|
|
154
|
+
windowsHide: true,
|
|
155
|
+
timeout: 5000
|
|
156
|
+
});
|
|
157
|
+
if (probe.error) {
|
|
158
|
+
const message = probe.error instanceof Error ? probe.error.message : String(probe.error);
|
|
159
|
+
throw new Error(`Audio transcode is enabled, but ffmpeg is unavailable (${ffmpegBin}): ${message}`);
|
|
160
|
+
}
|
|
161
|
+
if (typeof probe.status === "number" && probe.status !== 0) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Audio transcode is enabled, but ffmpeg check failed (${ffmpegBin}, exit code ${probe.status}).`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** @type {boolean} */
|
|
169
|
+
let registrationInProgress = false;
|
|
170
|
+
|
|
171
|
+
/** @type {import("fastify").FastifyInstance | null} */
|
|
172
|
+
let app = null;
|
|
173
|
+
|
|
174
|
+
/** @type {number} */
|
|
175
|
+
let actualPort = localPort;
|
|
176
|
+
|
|
177
|
+
/** @type {boolean} */
|
|
178
|
+
let shutdownInProgress = false;
|
|
179
|
+
|
|
180
|
+
/** @type {ReturnType<typeof createTunnelClient> | null} */
|
|
181
|
+
let tunnelClient = null;
|
|
182
|
+
|
|
183
|
+
/** @type {ReturnType<typeof createPortMapper> | null} */
|
|
184
|
+
let portMapper = null;
|
|
185
|
+
|
|
186
|
+
/** @type {ReturnType<typeof createPortMapper> | null} UDP mapping for the WebRTC port. */
|
|
187
|
+
let udpPortMapper = null;
|
|
188
|
+
|
|
189
|
+
/** @type {ReturnType<typeof createWebRtcManager> | null} */
|
|
190
|
+
let webRtcManager = null;
|
|
191
|
+
/**
|
|
192
|
+
* The packet witness, so shutdown can stop the ring it owns.
|
|
193
|
+
*
|
|
194
|
+
* @type {ReturnType<typeof createPacketWitness> | null}
|
|
195
|
+
*/
|
|
196
|
+
let packetWitness = null;
|
|
197
|
+
|
|
198
|
+
/** @type {ReturnType<typeof createUsrsctpStateReader> | null} */
|
|
199
|
+
let usrsctpStateReader = null;
|
|
200
|
+
|
|
201
|
+
/** @type {ReturnType<typeof createDataChannelHandler> | null} */
|
|
202
|
+
let dataChannelHandler = null;
|
|
203
|
+
|
|
204
|
+
/** @type {import("../services/nat-classifier.js").NatClassification | null} Latest NAT classification (for WebRTC port prediction). */
|
|
205
|
+
let natInfo = null;
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Register this proxy with the registry server.
|
|
210
|
+
* Silently skips if a registration is already in flight.
|
|
211
|
+
*
|
|
212
|
+
* @returns {Promise<void>}
|
|
213
|
+
*/
|
|
214
|
+
async function registerClientSafe() {
|
|
215
|
+
if (registrationInProgress) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
registrationInProgress = true;
|
|
219
|
+
try {
|
|
220
|
+
const result = await registerClient({
|
|
221
|
+
serverUrl,
|
|
222
|
+
id: clientId,
|
|
223
|
+
name: clientName,
|
|
224
|
+
baseUrl: explicitBaseUrl || `http://${bindHost}:${actualPort}`,
|
|
225
|
+
token
|
|
226
|
+
});
|
|
227
|
+
logger.success(`Registered as "${result.client?.name}" (${result.client?.id?.slice(0, 8)})`);
|
|
228
|
+
} finally {
|
|
229
|
+
registrationInProgress = false;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Register this proxy with the registry server, retrying indefinitely
|
|
235
|
+
* with exponential backoff until it succeeds. This allows the proxy to
|
|
236
|
+
* survive temporary server outages (restarts, deployments) without crashing.
|
|
237
|
+
*
|
|
238
|
+
* @returns {Promise<void>}
|
|
239
|
+
*/
|
|
240
|
+
async function registerWithRetry() {
|
|
241
|
+
const MAX_DELAY_MS = 60_000;
|
|
242
|
+
let delayMs = 2_000;
|
|
243
|
+
let attempt = 0;
|
|
244
|
+
while (true) {
|
|
245
|
+
attempt++;
|
|
246
|
+
try {
|
|
247
|
+
await registerClientSafe();
|
|
248
|
+
return;
|
|
249
|
+
} catch (error) {
|
|
250
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
251
|
+
logger.warn(`Registration attempt ${attempt} failed: ${message}. Retrying in ${delayMs / 1000}s…`);
|
|
252
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
253
|
+
delayMs = Math.min(delayMs * 2, MAX_DELAY_MS);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Gracefully shut down the tunnel, heartbeat timer, and HTTP server.
|
|
260
|
+
*
|
|
261
|
+
* @param {string} signal - Signal name (e.g. "SIGINT").
|
|
262
|
+
* @returns {Promise<void>}
|
|
263
|
+
*/
|
|
264
|
+
async function shutdown(signal) {
|
|
265
|
+
if (shutdownInProgress) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
shutdownInProgress = true;
|
|
269
|
+
if (tunnelClient) {
|
|
270
|
+
tunnelClient.disconnect();
|
|
271
|
+
tunnelClient = null;
|
|
272
|
+
}
|
|
273
|
+
logger.warn(`Received ${signal}, shutting down...`);
|
|
274
|
+
try {
|
|
275
|
+
// Remove the router port mappings before exiting (lease expiry is the
|
|
276
|
+
// backstop if this is skipped on a hard kill).
|
|
277
|
+
if (portMapper) {
|
|
278
|
+
await portMapper.stop();
|
|
279
|
+
portMapper = null;
|
|
280
|
+
}
|
|
281
|
+
if (udpPortMapper) {
|
|
282
|
+
await udpPortMapper.stop();
|
|
283
|
+
udpPortMapper = null;
|
|
284
|
+
}
|
|
285
|
+
// Close WebRTC sessions and release the shared UDP mux listener socket.
|
|
286
|
+
if (webRtcManager) {
|
|
287
|
+
try { webRtcManager.dispose(); } catch { /* ignore */ }
|
|
288
|
+
webRtcManager = null;
|
|
289
|
+
}
|
|
290
|
+
// Stop the packet ring and remove its scratch files. `process.exit` below
|
|
291
|
+
// does not take child processes with it, so without this a proxy restarted
|
|
292
|
+
// outside a container leaves one tcpdump running per restart.
|
|
293
|
+
if (packetWitness) {
|
|
294
|
+
try { await packetWitness.dispose(); } catch { /* ignore */ }
|
|
295
|
+
packetWitness = null;
|
|
296
|
+
}
|
|
297
|
+
if (app) {
|
|
298
|
+
await app.close();
|
|
299
|
+
}
|
|
300
|
+
process.exit(0);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
303
|
+
logger.error(`Shutdown failed: ${message}`);
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
logToFile(options.logFile);
|
|
310
|
+
if (transcodeAudio) {
|
|
311
|
+
assertFfmpegAvailability();
|
|
312
|
+
}
|
|
313
|
+
const started = await startProxyServer({
|
|
314
|
+
host: bindHost,
|
|
315
|
+
port: localPort,
|
|
316
|
+
transcodeAudio,
|
|
317
|
+
ffmpegBin,
|
|
318
|
+
maxDiskBytes,
|
|
319
|
+
memoryBytes,
|
|
320
|
+
segmentFormat: options.segmentFormat,
|
|
321
|
+
stateDir: options.stateDir,
|
|
322
|
+
deliverySink: options.deliverySink === true,
|
|
323
|
+
// Late-bound the same way `webRtcManager` is below: the torrent pool is
|
|
324
|
+
// built inside `startProxyServer`, before `dataChannelHandler` — the
|
|
325
|
+
// thing that actually owns a channel to push down — exists.
|
|
326
|
+
onSubtitleCues: (event) => dataChannelHandler?.publishSubtitleCues(event)
|
|
327
|
+
});
|
|
328
|
+
app = started.app;
|
|
329
|
+
actualPort = started.port;
|
|
330
|
+
const sourceRegistry = started.sourceRegistry;
|
|
331
|
+
const directBaseUrl = explicitBaseUrl || `http://${bindHost}:${actualPort}`;
|
|
332
|
+
|
|
333
|
+
// A native fault writes the whole address space out — 4.18 GB each on the
|
|
334
|
+
// field host, and four of them nearly filled a 235 GB disk. Keep the newest
|
|
335
|
+
// two, which are the evidence for the fault still open, and drop the rest.
|
|
336
|
+
void pruneCoreDumps(options.stateDir);
|
|
337
|
+
// Same policy for the packet captures the send-queue witness writes.
|
|
338
|
+
packetWitness = createPacketWitness({
|
|
339
|
+
log: (message) => logger.info(message),
|
|
340
|
+
dir: options.stateDir || "",
|
|
341
|
+
port: actualPort
|
|
342
|
+
});
|
|
343
|
+
// A process that was KILLED mid-session leaves the ring's last seconds
|
|
344
|
+
// behind, and those seconds contain whatever ended it. Keep them under a name
|
|
345
|
+
// the pruner recognises BEFORE anything starts a new ring over them.
|
|
346
|
+
void adoptOrphanRingFiles(packetWitness.dir).then(() => pruneWitnessCaptures(packetWitness.dir));
|
|
347
|
+
|
|
348
|
+
// Reads usrsctp's own association state via gdb the moment a wedge is
|
|
349
|
+
// declared (roadmap item 11) — no source rebuild, the module ships
|
|
350
|
+
// unstripped. A host without gdb just never gets a reading, the same way a
|
|
351
|
+
// host without tcpdump never gets a packet capture.
|
|
352
|
+
// OFF UNLESS ASKED FOR, and the reason is a field incident rather than
|
|
353
|
+
// caution. On 2026-09-05 a delivery probe declared a wedge that lasted half a
|
|
354
|
+
// second and cleared itself; the reading it triggered attached gdb to this
|
|
355
|
+
// process, which stopped all eighty of its threads. The log ended mid-second,
|
|
356
|
+
// /healthz stopped answering, and the viewer waited a minute and was told the
|
|
357
|
+
// proxy had sent no video. The fifteen-second guard could not fire — it is a
|
|
358
|
+
// timer inside the process gdb had stopped — and killing gdb left the main
|
|
359
|
+
// thread deadlocked for good, so only restarting the addon recovered it.
|
|
360
|
+
//
|
|
361
|
+
// A means of diagnosis may not stop the product. This one attaches to a live
|
|
362
|
+
// process and is triggered by a verdict with a known history of false
|
|
363
|
+
// positives, so it is a thing to switch on deliberately while watching, not
|
|
364
|
+
// something that arms itself.
|
|
365
|
+
usrsctpStateReader = options.usrsctpState === true
|
|
366
|
+
? createUsrsctpStateReader({ log: (message) => logger.info(message) })
|
|
367
|
+
: null;
|
|
368
|
+
|
|
369
|
+
// What this process holds, once a minute. The kernel killed the proxy on
|
|
370
|
+
// 2026-08-28 at 2.4 GB resident and the log had never said a word about
|
|
371
|
+
// memory, so the growth that ended in that kill has no shape in any record we
|
|
372
|
+
// keep. RSS is the figure the OOM killer reads, so RSS is the figure to say.
|
|
373
|
+
// The disk path is where pieces spill and torrent data lands. A budget for
|
|
374
|
+
// memory alone is half a budget: a store that behaves about RAM can still
|
|
375
|
+
// fill the card an addon host boots from, and neither limit was measured
|
|
376
|
+
// before (roadmap item 2).
|
|
377
|
+
startMemoryReport({
|
|
378
|
+
log: (message) => logger.info(message),
|
|
379
|
+
// The other half of the piece-buffer question. Shared memory lives until
|
|
380
|
+
// BOTH isolates let go, so the worker's own count answers only its side;
|
|
381
|
+
// read here, on the same line as the process figures the growth shows up
|
|
382
|
+
// in and at the same instant as them.
|
|
383
|
+
readExtra: () => {
|
|
384
|
+
const fragments = fragmentBufferCollection();
|
|
385
|
+
return fragments.seen === 0
|
|
386
|
+
? ""
|
|
387
|
+
: `piece buffers handed here ${fragments.seen} seen, ${fragments.collected} collected, ` +
|
|
388
|
+
`${fragments.seen - fragments.collected} still alive`;
|
|
389
|
+
},
|
|
390
|
+
// Once a minute cannot see what kills this process. Both out-of-memory
|
|
391
|
+
// kills of 2026-09-02 happened inside a single gap of the old cadence: the
|
|
392
|
+
// last line before the first said 602 MB and the kernel measured 1.72 GB
|
|
393
|
+
// 34 seconds later, and before the second it said 1171 MB and the kernel
|
|
394
|
+
// measured 1.86 GB nine seconds later. The figure is read every second
|
|
395
|
+
// now and written when it has moved by a step worth a line, so a rise of a
|
|
396
|
+
// gigabyte is a curve instead of one number and then a death. Every /proc
|
|
397
|
+
// read stayed behind the decision to write, so a quiet second costs one
|
|
398
|
+
// call to `process.memoryUsage()`.
|
|
399
|
+
intervalMs: 1_000,
|
|
400
|
+
quietMs: 60_000,
|
|
401
|
+
changeBytes: 25 * 1024 * 1024,
|
|
402
|
+
diskPath: os.tmpdir(),
|
|
403
|
+
// WHERE A SNAPSHOT SURVIVES THE PROCESS IT IS ABOUT, and how many are kept
|
|
404
|
+
// — the same answer the torrent worker already had. This scope had neither:
|
|
405
|
+
// snapshots went to the temporary directory, which the container recreates
|
|
406
|
+
// on every restart, and all of them were kept. Fifty-four were written on
|
|
407
|
+
// 2026-09-07 and every one was gone by the time the process that wrote them
|
|
408
|
+
// died of a heap it had never been possible to look at.
|
|
409
|
+
snapshotDir: options.stateDir || undefined,
|
|
410
|
+
snapshotFloorBytes: 400 * 1024 * 1024,
|
|
411
|
+
snapshotGrowthBytes: 400 * 1024 * 1024,
|
|
412
|
+
keepSnapshots: 3
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
logger.info(`Starting @torrent-tv/proxy v${PROXY_VERSION}`);
|
|
416
|
+
logger.info(`Local stream endpoint: http://${bindHost}:${actualPort}/stream`);
|
|
417
|
+
logger.info(`Advertised direct URL: ${directBaseUrl}`);
|
|
418
|
+
if (transcodeAudio) {
|
|
419
|
+
logger.info(`Optional HLS audio transcode is enabled (ffmpeg: ${ffmpegBin}).`);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// All WebRTC sessions are multiplexed onto this single UDP port (same number
|
|
423
|
+
// as the HTTP port, different protocol) via a persistent ICE UDP mux listener
|
|
424
|
+
// in the WebRTC manager. One port → one UPnP mapping → one reachable endpoint.
|
|
425
|
+
const webrtcUdpPort = actualPort;
|
|
426
|
+
|
|
427
|
+
// Try to open the local port on the home router (UPnP/NAT-PMP) so the proxy
|
|
428
|
+
// is reachable from the internet without manual port forwarding. Best-effort
|
|
429
|
+
// and fire-and-forget: failure is normal (router without UPnP) and must not
|
|
430
|
+
// delay tunnel connect / registration, so we do not await it.
|
|
431
|
+
if (portMappingEnabled) {
|
|
432
|
+
portMapper = createPortMapper({ port: actualPort, protocol: "TCP" });
|
|
433
|
+
void portMapper
|
|
434
|
+
.start()
|
|
435
|
+
.then(() => {
|
|
436
|
+
// Mapping may finish after the tunnel is already connected; report the
|
|
437
|
+
// endpoint now. If the tunnel is not open yet, onConnect re-sends it.
|
|
438
|
+
const endpoint = portMapper?.getMappedEndpoint();
|
|
439
|
+
if (endpoint) {
|
|
440
|
+
tunnelClient?.sendEndpoint(endpoint);
|
|
441
|
+
}
|
|
442
|
+
})
|
|
443
|
+
.catch((error) => {
|
|
444
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
445
|
+
logger.warn(`Port mapping failed to start: ${message}`);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// Also map the single WebRTC UDP port. Not reported to the server: the
|
|
449
|
+
// browser discovers the endpoint via ICE (srflx) candidates, not the TCP
|
|
450
|
+
// dial-back probe.
|
|
451
|
+
udpPortMapper = createPortMapper({
|
|
452
|
+
port: webrtcUdpPort,
|
|
453
|
+
protocol: "UDP",
|
|
454
|
+
description: "torrent-tv proxy (WebRTC)"
|
|
455
|
+
});
|
|
456
|
+
void udpPortMapper.start().catch((error) => {
|
|
457
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
458
|
+
logger.warn(`UDP port mapping failed to start: ${message}`);
|
|
459
|
+
});
|
|
460
|
+
} else {
|
|
461
|
+
logger.info("Automatic port mapping is disabled (--no-port-mapping).");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Classify the home NAT (diagnostic + decides whether WebRTC will need port
|
|
465
|
+
// prediction for remote viewers). Best-effort, fire-and-forget — STUN probes
|
|
466
|
+
// never block startup.
|
|
467
|
+
void classifyNat()
|
|
468
|
+
.then((nat) => {
|
|
469
|
+
// Stored for WebRTC port prediction (webRtcManager reads it per session).
|
|
470
|
+
natInfo = nat;
|
|
471
|
+
if (nat.klass === "endpoint-independent") {
|
|
472
|
+
logger.info(
|
|
473
|
+
`nat: endpoint-independent (cone) — external UDP port stable across STUN servers (${nat.externalIp}); fixed-port WebRTC mapping is sufficient, no port prediction needed`
|
|
474
|
+
);
|
|
475
|
+
} else if (nat.klass === "symmetric") {
|
|
476
|
+
logger.warn(
|
|
477
|
+
`nat: SYMMETRIC — external UDP port varies per destination (delta ${nat.portDelta}); WebRTC offers predicted ports (base+delta*k) — covers sequential/predictable symmetric NAT, not fully random`
|
|
478
|
+
);
|
|
479
|
+
} else {
|
|
480
|
+
logger.info("nat: classification inconclusive (STUN probes failed); continuing");
|
|
481
|
+
}
|
|
482
|
+
})
|
|
483
|
+
.catch((error) => {
|
|
484
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
485
|
+
logger.warn(`nat classification failed: ${message}`);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// Create tunnel + WebRTC manager.
|
|
489
|
+
// The tunnel forwards WebRTC signals between browser (via server) and this proxy.
|
|
490
|
+
// The WebRTC manager handles the actual peer connection and data channel.
|
|
491
|
+
//
|
|
492
|
+
// We use a late-binding ref so both objects can reference each other without
|
|
493
|
+
// running into the TDZ (tunnelClient is declared above; webRtcManager is the
|
|
494
|
+
// module-scoped `let` above so the closure in createTunnelClient — and the
|
|
495
|
+
// shutdown handler — can reach it after initialisation).
|
|
496
|
+
|
|
497
|
+
tunnelClient = createTunnelClient({
|
|
498
|
+
serverUrl,
|
|
499
|
+
proxyId: clientId,
|
|
500
|
+
token,
|
|
501
|
+
proxyPort: actualPort,
|
|
502
|
+
onSignal(sessionId, signal) {
|
|
503
|
+
webRtcManager?.handleSignal(sessionId, signal);
|
|
504
|
+
},
|
|
505
|
+
async onHealthRequest() {
|
|
506
|
+
// Which films this proxy holds travels with the health poll the browser
|
|
507
|
+
// already makes before it picks one: a viewer sent to a proxy that is
|
|
508
|
+
// downloading their film costs it the encode and nothing else, while the
|
|
509
|
+
// same viewer sent anywhere else starts the download from nothing. Asked
|
|
510
|
+
// of the worker, so a film this proxy let go of is not claimed.
|
|
511
|
+
let holds = [];
|
|
512
|
+
try {
|
|
513
|
+
holds = (await started?.torrentPool?.heldTorrents?.()) ?? [];
|
|
514
|
+
} catch {
|
|
515
|
+
// silent-ok: a proxy that cannot say what it holds is scored on its
|
|
516
|
+
// machine alone, which is what every proxy was scored on until now.
|
|
517
|
+
}
|
|
518
|
+
return { metrics: collectHealthMetrics(), holds };
|
|
519
|
+
},
|
|
520
|
+
// Whether this host could sustain a file it has only been told about. The
|
|
521
|
+
// same arithmetic the first offer uses, against this host's own startup
|
|
522
|
+
// benchmarks — no torrent, no bytes, no ffmpeg — so the browser can ask
|
|
523
|
+
// every proxy in the pool and be sent to one that will work instead of
|
|
524
|
+
// being shown an error on the one it happened to land on.
|
|
525
|
+
onCanServeRequest(mediaInfo) {
|
|
526
|
+
return started?.hlsSessionManager?.predictOfferedHeights?.(mediaInfo) ?? null;
|
|
527
|
+
},
|
|
528
|
+
onConnect() {
|
|
529
|
+
// Re-register on every tunnel connect/reconnect so the server's
|
|
530
|
+
// in-memory store stays consistent after server restarts.
|
|
531
|
+
void registerClientSafe().catch((error) => {
|
|
532
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
533
|
+
logger.error(`Re-registration after tunnel connect failed: ${message}`);
|
|
534
|
+
});
|
|
535
|
+
// Re-report the mapped endpoint on every (re)connect — the server's
|
|
536
|
+
// in-memory reachability state resets on restart, and the mapping may
|
|
537
|
+
// have completed before this connection existed.
|
|
538
|
+
const endpoint = portMapper?.getMappedEndpoint();
|
|
539
|
+
if (endpoint) {
|
|
540
|
+
tunnelClient?.sendEndpoint(endpoint);
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
onLog: (message) => logger.info(message)
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
dataChannelHandler = createDataChannelHandler({
|
|
547
|
+
proxyPort: actualPort,
|
|
548
|
+
onLog: (message) => logger.info(message),
|
|
549
|
+
// Resolves a browser's registry sourceKey to the torrent pool's own key
|
|
550
|
+
// (the content's infohash) so the subtitle push subscription and the
|
|
551
|
+
// pool's own publish agree on what a source is called. See server.js.
|
|
552
|
+
sourceRegistry,
|
|
553
|
+
// Lets a stuck send queue ask the transport what it is doing. Late-bound:
|
|
554
|
+
// the manager is created below, with this handler already in hand.
|
|
555
|
+
getTransportSnapshot: (sessionId) => webRtcManager?.getTransportSnapshot(sessionId) ?? null,
|
|
556
|
+
// Records the wire when a queue stays wedged — how the rare one-way
|
|
557
|
+
// transmit death (roadmap item 10, 2026-08-24) gets its evidence.
|
|
558
|
+
witness: packetWitness,
|
|
559
|
+
usrsctpState: usrsctpStateReader,
|
|
560
|
+
// Presence, from the one thing that knows it. Late-bound for the same
|
|
561
|
+
// reason as the transport snapshot above: the manager is built inside
|
|
562
|
+
// `startProxyServer`, with this handler already in hand.
|
|
563
|
+
onViewerPresent: (consumerId) => {
|
|
564
|
+
started?.hlsSessionManager?.viewers?.seen?.(consumerId);
|
|
565
|
+
},
|
|
566
|
+
onViewerGone: (consumerId, because) => {
|
|
567
|
+
void started?.hlsSessionManager?.viewerHasGone?.(consumerId, because)
|
|
568
|
+
?.catch?.((error) => {
|
|
569
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
570
|
+
logger.warn(`could not let go of viewer ${consumerId}: ${message}`);
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
webRtcManager = createWebRtcManager({
|
|
576
|
+
// Single UDP port (UPnP-mapped above) shared by all sessions via a
|
|
577
|
+
// persistent ICE UDP mux listener, so the WebRTC path is reachable from the
|
|
578
|
+
// internet on one fixed port.
|
|
579
|
+
udpPort: webrtcUdpPort,
|
|
580
|
+
// Latest NAT classification — enables symmetric-NAT port prediction.
|
|
581
|
+
getNatInfo: () => natInfo,
|
|
582
|
+
sendSignal(sessionId, signal) {
|
|
583
|
+
tunnelClient?.sendSignal(sessionId, signal);
|
|
584
|
+
},
|
|
585
|
+
onDataChannel(sessionId, channel) {
|
|
586
|
+
dataChannelHandler.handleChannel(sessionId, channel);
|
|
587
|
+
},
|
|
588
|
+
onLog: (message) => logger.info(message)
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
tunnelClient.connect();
|
|
592
|
+
|
|
593
|
+
await registerWithRetry();
|
|
594
|
+
} catch (error) {
|
|
595
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
596
|
+
logger.error(message);
|
|
597
|
+
process.exit(1);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
process.on("SIGINT", () => {
|
|
601
|
+
void shutdown("SIGINT");
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
process.on("SIGTERM", () => {
|
|
605
|
+
void shutdown("SIGTERM");
|
|
606
|
+
});
|