@torrent-tv/proxy 2.79.0 → 2.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,7 @@
1
+ ## 2.80.0
2
+
3
+ - **Fix**: The reading of usrsctp's own state is OFF unless `--usrsctp-state` is given, and its deadline is counted by a separate process. On 2026-09-05 a delivery probe declared a wedge that lasted half a second and cleared itself; the reading it triggered attached gdb to this process, which stops every one of its eighty threads for as long as it is attached. The log ended mid-second, `/healthz` stopped answering, and the viewer waited a minute and was told the proxy had sent no video. The fifteen-second guard could not fire — it was a timer inside the process gdb had stopped — and killing gdb left the main thread deadlocked for good, so only restarting the addon recovered it. A means of diagnosis may not stop the product: this one attaches to a live process and is triggered by a verdict with a known history of false positives, so it is switched on deliberately while somebody is watching, and `timeout` counts its seconds from outside.
4
+
1
5
  ## 2.79.0
2
6
 
3
7
  - **Fix**: Starting an encoder no longer stops another one. The line that did it looked for a live run whose own start was not below the new one's and killed it — a rule left from when a session held exactly one run and "the previous one" meant "the only one". Once a session could hold several, it began killing runs the plan had decided to keep: 294 stops for that reason in eight minutes of field 2026-09-05, against four starts a viewer had asked for. Who is stopped is decided in one place now, and starting is not it.
package/bin/cli.js CHANGED
@@ -85,6 +85,10 @@ program
85
85
  .option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
86
86
  .option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
87
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
+ )
88
92
  .option("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
89
93
  .option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default 512MB)")
90
94
  .option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
@@ -345,7 +349,22 @@ try {
345
349
  // declared (roadmap item 11) — no source rebuild, the module ships
346
350
  // unstripped. A host without gdb just never gets a reading, the same way a
347
351
  // host without tcpdump never gets a packet capture.
348
- usrsctpStateReader = createUsrsctpStateReader({ log: (message) => logger.info(message) });
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;
349
368
 
350
369
  // What this process holds, once a minute. The kernel killed the proxy on
351
370
  // 2026-08-28 at 2.4 GB resident and the log had never said a word about
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.79.0",
3
+ "version": "2.80.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -17,9 +17,14 @@
17
17
  * only as long as someone remembers to copy it back after a container is
18
18
  * recreated.
19
19
  *
20
- * `gdb` attaching with ptrace pauses every thread of the process for the
21
- * duration of the read one `getsockopt` call, measured at a fraction of a
22
- * second in the manual procedure this automates.
20
+ * **THIS STOPS THE PRODUCT WHILE IT WORKS, so it is off unless asked for.**
21
+ * `gdb` attaches with ptrace and pauses every thread of this process for as
22
+ * long as it is attached. The manual procedure this automates took a fraction
23
+ * of a second; on 2026-09-05 the automatic one held the proxy for four minutes
24
+ * and the viewer was told the proxy had sent no video. Two things follow, and
25
+ * both are in the code below: the deadline is counted by a separate process,
26
+ * because a timer inside a stopped process never fires; and `--usrsctp-state`
27
+ * has to be given for any of this to happen at all.
23
28
  */
24
29
 
25
30
  import { spawn } from "node:child_process";
@@ -91,9 +96,29 @@ export function createUsrsctpStateReader({
91
96
  resolve();
92
97
  };
93
98
  try {
99
+ // THE DEADLINE IS SET OUTSIDE THE PROCESS THAT IS BEING STOPPED.
100
+ //
101
+ // gdb attaches with ptrace and stops every thread of this process
102
+ // while it works — including whichever one would have counted the
103
+ // seconds. The guard below used to be a `setTimeout` here, and on
104
+ // 2026-09-05 it did not fire: gdb held the proxy for four minutes,
105
+ // the log ended mid-second and the viewer was told the proxy had
106
+ // sent no video. `timeout` is a separate process and keeps counting
107
+ // whatever happens to this one.
94
108
  child = spawnProcess(
95
- "gdb",
96
- ["-q", "-batch", "-p", String(pid), "-x", scriptPath],
109
+ "timeout",
110
+ [
111
+ "-s",
112
+ "KILL",
113
+ String(Math.ceil(GDB_TIMEOUT_MS / 1000)),
114
+ "gdb",
115
+ "-q",
116
+ "-batch",
117
+ "-p",
118
+ String(pid),
119
+ "-x",
120
+ scriptPath
121
+ ],
97
122
  { stdio: ["ignore", "pipe", "pipe"] }
98
123
  );
99
124
  } catch (error) {
@@ -112,13 +137,17 @@ export function createUsrsctpStateReader({
112
137
  finish();
113
138
  });
114
139
  child.on("close", finish);
140
+ // A second guard, for the case where `timeout` itself is not on the
141
+ // host: it can only fire while this process is running, which is
142
+ // exactly what cannot be relied on here, so it is a backstop and not
143
+ // the deadline.
115
144
  const killer = setTimeout(() => {
116
145
  try {
117
146
  child.kill("SIGKILL");
118
147
  } catch {
119
148
  // already gone
120
149
  }
121
- }, GDB_TIMEOUT_MS);
150
+ }, GDB_TIMEOUT_MS * 2);
122
151
  if (typeof killer.unref === "function") {
123
152
  killer.unref();
124
153
  }
@@ -62,7 +62,7 @@ async function waitFor(check) {
62
62
  }
63
63
  }
64
64
 
65
- test("gdb is invoked attached to this process with the bundled script", async () => {
65
+ test("the deadline is counted outside the process gdb stops", async () => {
66
66
  const { spawnProcess, calls } = makeSpawn("state=8 rwnd=95890\n");
67
67
  const lines = [];
68
68
  const reader = createUsrsctpStateReader({
@@ -74,7 +74,23 @@ test("gdb is invoked attached to this process with the bundled script", async ()
74
74
  assert.equal(started, true);
75
75
  await waitFor(() => lines.length > 0);
76
76
  assert.equal(calls.length, 1);
77
- assert.deepEqual(calls[0].args, ["-q", "-batch", "-p", "4242", "-x", SCTPSTATE_SCRIPT_PATH]);
77
+ // `timeout` is a separate process, so it keeps counting while gdb holds every
78
+ // thread of this one. A `setTimeout` here cannot fire — measured in the field
79
+ // on 2026-09-05, where gdb held the proxy for four minutes and the guard set
80
+ // for fifteen seconds never ran.
81
+ assert.equal(calls[0].command, "timeout");
82
+ assert.deepEqual(calls[0].args, [
83
+ "-s",
84
+ "KILL",
85
+ "15",
86
+ "gdb",
87
+ "-q",
88
+ "-batch",
89
+ "-p",
90
+ "4242",
91
+ "-x",
92
+ SCTPSTATE_SCRIPT_PATH
93
+ ]);
78
94
  assert.match(lines[0], /state=8 rwnd=95890/);
79
95
  assert.match(lines[0], /test wedge/);
80
96
  });