@torrent-tv/proxy 2.58.2 → 2.59.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.
@@ -499,7 +499,12 @@ setInterval(() => {
499
499
  const reads = stats.fromMemory + stats.fromDisk;
500
500
  const fromMemoryShare = reads > 0 ? ((stats.fromMemory / reads) * 100).toFixed(1) : "—";
501
501
  log(
502
+ // In BYTES as well as in pieces. The count alone says nothing without the
503
+ // piece size, and the piece size differs per torrent: on the film the
504
+ // proxy was killed under, 2026-08-28, "63" meant 504 MB.
502
505
  `piece-store "${stats.name.slice(0, 40)}": resident=${stats.resident}/${stats.capacity} ` +
506
+ `(${Math.round((stats.residentBytes || 0) / 1048576)}MB of ` +
507
+ `${Math.round((stats.budgetBytes || 0) / 1048576)}MB allowed) ` +
503
508
  `pinned=${stats.pinned} spilled=${stats.spilled} reads=${reads} (${fromMemoryShare}% from memory) ` +
504
509
  `spills=${stats.spills} revivals=${stats.revivals}` +
505
510
  (stats.blockedByPins > 0 ? ` blocked-by-pins=${stats.blockedByPins}` : "")
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @file Read usrsctp's live association state, without a rebuild.
3
+ *
4
+ * Roadmap item 11. `node_datachannel.node` ships unstripped — it carries
5
+ * usrsctp's own local symbols, including `system_base_info` and
6
+ * `usrsctp_getsockopt` — so the association's real state (peer receive
7
+ * window, pending data, retransmission timeout, congestion window) can be
8
+ * read out of THIS live process with a short gdb attach. No source rebuild,
9
+ * no SCTP_DEBUG image, no waiting for a packet capture to be read by eye.
10
+ *
11
+ * The walk (hash the association table, call `usrsctp_getsockopt` with
12
+ * `SCTP_STATUS` through the running process) and the healthy baseline it was
13
+ * checked against are in
14
+ * `research/session-2026-08-27-28-freeze-onset-and-sessions.md`, section 1.
15
+ * The script itself is bundled at {@link SCTPSTATE_SCRIPT_PATH} rather than
16
+ * hand-placed on a host, so it ships with every release instead of surviving
17
+ * only as long as someone remembers to copy it back after a container is
18
+ * recreated.
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.
23
+ */
24
+
25
+ import { spawn } from "node:child_process";
26
+ import path from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ import { shouldStartCapture, WITNESS_COOLDOWN_MS } from "./packet-witness.js";
30
+
31
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
32
+
33
+ /** The bundled gdb script that performs the usrsctp state walk. */
34
+ export const SCTPSTATE_SCRIPT_PATH = path.join(HERE, "..", "assets", "diagnostics", "sctpstate.gdb");
35
+
36
+ /** How long gdb may run before it is killed. Generous: this is a rare, one-shot read. */
37
+ export const GDB_TIMEOUT_MS = 15_000;
38
+
39
+ /**
40
+ * Create the reader.
41
+ *
42
+ * Single-flight and cooldown-gated the same way the packet witness is
43
+ * ({@link shouldStartCapture}) — a wedge that stays certain for minutes must
44
+ * not spawn a fresh gdb attach on every tick, and two readings ten seconds
45
+ * apart tell the same story a hundred readings would.
46
+ *
47
+ * @param {Object} options
48
+ * @param {(message: string) => void} options.log
49
+ * @param {typeof spawn} [options.spawnProcess] - Seam for tests.
50
+ * @param {number} [options.pid] - Defaults to this process's own pid.
51
+ * @param {string} [options.scriptPath]
52
+ * @param {number} [options.cooldownMs]
53
+ * @returns {{ maybeRead: (reasonText: string) => boolean }}
54
+ */
55
+ export function createUsrsctpStateReader({
56
+ log,
57
+ spawnProcess = spawn,
58
+ pid = process.pid,
59
+ scriptPath = SCTPSTATE_SCRIPT_PATH,
60
+ cooldownMs = WITNESS_COOLDOWN_MS
61
+ }) {
62
+ /** @type {{ running: boolean, lastStartedAt: number }} */
63
+ const state = { running: false, lastStartedAt: 0 };
64
+
65
+ /**
66
+ * Read the association state now, if the gating rules allow it.
67
+ *
68
+ * @param {string} reasonText - What declared the wedge, for the log line.
69
+ * @returns {boolean} True when a read actually started.
70
+ */
71
+ const maybeRead = (reasonText) => {
72
+ if (!shouldStartCapture({ ...state, cooldownMs })) {
73
+ return false;
74
+ }
75
+ state.running = true;
76
+ state.lastStartedAt = Date.now();
77
+ const startedAt = state.lastStartedAt;
78
+ void (async () => {
79
+ let out = "";
80
+ let err = "";
81
+ try {
82
+ await new Promise((resolve) => {
83
+ let settled = false;
84
+ let child;
85
+ const finish = () => {
86
+ if (settled) {
87
+ return;
88
+ }
89
+ settled = true;
90
+ clearTimeout(killer);
91
+ resolve();
92
+ };
93
+ try {
94
+ child = spawnProcess(
95
+ "gdb",
96
+ ["-q", "-batch", "-p", String(pid), "-x", scriptPath],
97
+ { stdio: ["ignore", "pipe", "pipe"] }
98
+ );
99
+ } catch (error) {
100
+ err = `could not start gdb: ${error?.message ?? error}`;
101
+ resolve();
102
+ return;
103
+ }
104
+ child.stdout?.on("data", (chunk) => {
105
+ out += chunk.toString();
106
+ });
107
+ child.stderr?.on("data", (chunk) => {
108
+ err += chunk.toString();
109
+ });
110
+ child.on("error", (error) => {
111
+ err += `${err ? " " : ""}gdb error: ${error?.message ?? error}`;
112
+ finish();
113
+ });
114
+ child.on("close", finish);
115
+ const killer = setTimeout(() => {
116
+ try {
117
+ child.kill("SIGKILL");
118
+ } catch {
119
+ // already gone
120
+ }
121
+ }, GDB_TIMEOUT_MS);
122
+ if (typeof killer.unref === "function") {
123
+ killer.unref();
124
+ }
125
+ });
126
+ } finally {
127
+ const lines = out.trim().length > 0 ? out.trim().split("\n") : [];
128
+ if (lines.length > 0) {
129
+ log(`usrsctp state (${reasonText}): ${lines.join(" | ")}`);
130
+ } else {
131
+ log(`usrsctp state (${reasonText}): no reading — ${err.trim() || "gdb produced no output"}`);
132
+ }
133
+ state.running = false;
134
+ // Same spacing rule as the packet witness: honour the cooldown even
135
+ // when the read ended quickly.
136
+ const earliestNext = startedAt + cooldownMs;
137
+ if (state.lastStartedAt < earliestNext) {
138
+ state.lastStartedAt = earliestNext;
139
+ }
140
+ }
141
+ })();
142
+ return true;
143
+ };
144
+
145
+ return { maybeRead };
146
+ }
@@ -0,0 +1,69 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { describeMemory } from "../services/memory-report.js";
5
+ import {
6
+ budgetForNewStore,
7
+ totalStoreBudgetBytes
8
+ } from "../services/piece-store/shared-piece-store.js";
9
+
10
+ const MEGABYTE = 1024 * 1024;
11
+ const GIGABYTE = 1024 * MEGABYTE;
12
+
13
+ test("the whole of the torrent stores is bounded, not each one of them", () => {
14
+ // The failure this replaces: the budget was per torrent, so two torrents took
15
+ // two of it. Whatever the machine has, the total is one budget.
16
+ const available = 8 * GIGABYTE;
17
+ const alone = budgetForNewStore(available, 1);
18
+ const withThree = budgetForNewStore(available, 3);
19
+ assert.equal(alone, totalStoreBudgetBytes(available));
20
+ assert.ok(withThree < alone, "a third store must not be given a first store's share");
21
+ assert.ok(withThree * 3 <= totalStoreBudgetBytes(available) + 3);
22
+ });
23
+
24
+ test("the budget is a share of what the machine can give, capped", () => {
25
+ // A quarter of two gigabytes is under the ceiling and is what is taken.
26
+ assert.equal(totalStoreBudgetBytes(2 * GIGABYTE), 512 * MEGABYTE);
27
+ // A quarter of one gigabyte is 256 MB — below the ceiling, so not capped.
28
+ assert.equal(totalStoreBudgetBytes(GIGABYTE), 256 * MEGABYTE);
29
+ // And it never exceeds the ceiling however much is free.
30
+ assert.equal(totalStoreBudgetBytes(64 * GIGABYTE), 512 * MEGABYTE);
31
+ });
32
+
33
+ test("a machine with almost nothing left still gets a workable floor", () => {
34
+ // Refusing to serve is worse than exceeding the share, and the memory line
35
+ // says plainly what is held either way.
36
+ const tiny = budgetForNewStore(32 * MEGABYTE, 4);
37
+ assert.equal(tiny, 64 * MEGABYTE);
38
+ });
39
+
40
+ test("the memory line says bytes, and names what it could not measure", () => {
41
+ const usage = {
42
+ rss: 2422628 * 1024,
43
+ heapUsed: 180 * MEGABYTE,
44
+ heapTotal: 220 * MEGABYTE,
45
+ external: 900 * MEGABYTE,
46
+ arrayBuffers: 850 * MEGABYTE
47
+ };
48
+ const measured = describeMemory({
49
+ process: usage,
50
+ availableBytes: 1900 * MEGABYTE,
51
+ availableMeasured: true,
52
+ stores: [
53
+ { name: "a film", residentBytes: 504 * MEGABYTE, budgetBytes: 512 * MEGABYTE }
54
+ ]
55
+ });
56
+ // The figures the kernel's own kill line quoted, so the two can be compared.
57
+ assert.match(measured, /rss=2366MB/);
58
+ assert.match(measured, /1 torrent store\(s\) holding 504MB of 512MB allowed/);
59
+ assert.match(measured, /machine has 1900MB available$/);
60
+
61
+ const estimated = describeMemory({
62
+ process: usage,
63
+ availableBytes: 1900 * MEGABYTE,
64
+ availableMeasured: false,
65
+ stores: []
66
+ });
67
+ assert.match(estimated, /no torrent stores/);
68
+ assert.match(estimated, /estimated — \/proc\/meminfo could not be read/);
69
+ });
@@ -0,0 +1,60 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+
4
+ import { probeWedgeIsCertain, readProbeState, PROBE_INTERVAL_MS } from "../services/delivery-probe.js";
5
+
6
+ test("a seen-counter bounded lag is not a wedge, however long it lasts", () => {
7
+ // Session 4dcac61b, field log 2026-08-28: gap held at 6-7 probes for 95+
8
+ // seconds on a backgrounded tab, but `seen` kept climbing right along with
9
+ // `sent` — this connection's own history says gaps up to ~3.5 s (7 probes
10
+ // at 500 ms) are ordinary, so the same 3.5 s stuck must not read as certain.
11
+ const verdict = probeWedgeIsCertain({
12
+ stuckForMs: 3400,
13
+ longestHealthySeenGapMs: 3500
14
+ });
15
+ assert.equal(verdict.certain, false);
16
+ });
17
+
18
+ test("a seen-counter frozen past this connection's own worst legitimate gap is a wedge", () => {
19
+ // Session d85ae4f5, the same field log: `seen` frozen at one value for over
20
+ // a minute while `sent` climbed unbounded — this is the shape the detector
21
+ // exists to catch.
22
+ const verdict = probeWedgeIsCertain({
23
+ stuckForMs: 90_000,
24
+ longestHealthySeenGapMs: 3500
25
+ });
26
+ assert.equal(verdict.certain, true);
27
+ });
28
+
29
+ test("with no healthy history yet, one probe interval is still required", () => {
30
+ const verdict = probeWedgeIsCertain({
31
+ stuckForMs: PROBE_INTERVAL_MS - 1,
32
+ longestHealthySeenGapMs: 0
33
+ });
34
+ assert.equal(verdict.certain, false);
35
+ assert.equal(verdict.needMs, PROBE_INTERVAL_MS);
36
+ });
37
+
38
+ test("readProbeState calls association-stopped only when the unreliable channel agrees", () => {
39
+ // Ordered channels behind, but the unordered/no-retransmit one current:
40
+ // head-of-line blocking in one stream, not the association.
41
+ const streamStuck = readProbeState({
42
+ seq: 100,
43
+ seen: { proxy: 90, "proxy-control": 90, "proxy-fast": 99 },
44
+ labels: ["proxy", "proxy-control", "proxy-fast"],
45
+ echoes: 5,
46
+ echoAgeMs: 100,
47
+ allowed: { proxy: 2, "proxy-control": 2, "proxy-fast": 2 }
48
+ });
49
+ assert.equal(streamStuck.verdict, "stream-stuck");
50
+
51
+ const associationStopped = readProbeState({
52
+ seq: 100,
53
+ seen: { proxy: 90, "proxy-control": 90, "proxy-fast": 90 },
54
+ labels: ["proxy", "proxy-control", "proxy-fast"],
55
+ echoes: 5,
56
+ echoAgeMs: 100,
57
+ allowed: { proxy: 2, "proxy-control": 2, "proxy-fast": 2 }
58
+ });
59
+ assert.equal(associationStopped.verdict, "association-stopped");
60
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @file The usrsctp state reader's gating and command construction.
3
+ *
4
+ * Roadmap item 11: reads usrsctp's live association state via gdb the moment
5
+ * a wedge is declared. Everything here is the part that decides WHEN and WITH
6
+ * WHAT ARGUMENTS — the spawning itself is thin glue around these, the same
7
+ * shape as the packet witness (test/packet-witness.test.js).
8
+ */
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { EventEmitter } from "node:events";
13
+
14
+ import { createUsrsctpStateReader, SCTPSTATE_SCRIPT_PATH } from "../services/usrsctp-state.js";
15
+
16
+ class FakeChild extends EventEmitter {
17
+ constructor(command, args) {
18
+ super();
19
+ this.command = command;
20
+ this.args = args;
21
+ this.stdout = new EventEmitter();
22
+ this.stderr = new EventEmitter();
23
+ }
24
+
25
+ kill() {
26
+ // Not exercised by these tests — every fake run finishes on its own.
27
+ return true;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * @param {string} output - What the fake gdb writes to stdout before closing.
33
+ * @returns {{ spawnProcess: Function, calls: Array<{ command: string, args: string[] }> }}
34
+ */
35
+ function makeSpawn(output) {
36
+ const calls = [];
37
+ const spawnProcess = (command, args) => {
38
+ calls.push({ command, args });
39
+ const child = new FakeChild(command, args);
40
+ setImmediate(() => {
41
+ if (output) {
42
+ child.stdout.emit("data", Buffer.from(output));
43
+ }
44
+ child.emit("close", 0);
45
+ });
46
+ return child;
47
+ };
48
+ return { spawnProcess, calls };
49
+ }
50
+
51
+ /** Wait until `check` holds, rather than for a chosen interval. */
52
+ async function waitFor(check) {
53
+ const deadline = Date.now() + 5_000;
54
+ for (;;) {
55
+ if (check()) {
56
+ return;
57
+ }
58
+ if (Date.now() > deadline) {
59
+ throw new Error("timed out waiting for the reading to be logged");
60
+ }
61
+ await new Promise((resolve) => setTimeout(resolve, 5));
62
+ }
63
+ }
64
+
65
+ test("gdb is invoked attached to this process with the bundled script", async () => {
66
+ const { spawnProcess, calls } = makeSpawn("state=8 rwnd=95890\n");
67
+ const lines = [];
68
+ const reader = createUsrsctpStateReader({
69
+ log: (message) => lines.push(message),
70
+ spawnProcess,
71
+ pid: 4242
72
+ });
73
+ const started = reader.maybeRead("test wedge");
74
+ assert.equal(started, true);
75
+ await waitFor(() => lines.length > 0);
76
+ assert.equal(calls.length, 1);
77
+ assert.deepEqual(calls[0].args, ["-q", "-batch", "-p", "4242", "-x", SCTPSTATE_SCRIPT_PATH]);
78
+ assert.match(lines[0], /state=8 rwnd=95890/);
79
+ assert.match(lines[0], /test wedge/);
80
+ });
81
+
82
+ test("no output is reported as no reading, not silence", async () => {
83
+ const { spawnProcess } = makeSpawn("");
84
+ const lines = [];
85
+ const reader = createUsrsctpStateReader({ log: (message) => lines.push(message), spawnProcess, pid: 1 });
86
+ reader.maybeRead("empty case");
87
+ await waitFor(() => lines.length > 0);
88
+ assert.match(lines[0], /no reading/);
89
+ });
90
+
91
+ test("a second read is refused within the cooldown, like the packet witness", async () => {
92
+ const { spawnProcess, calls } = makeSpawn("state=8\n");
93
+ const lines = [];
94
+ const reader = createUsrsctpStateReader({
95
+ log: (message) => lines.push(message),
96
+ spawnProcess,
97
+ pid: 1,
98
+ cooldownMs: 60_000
99
+ });
100
+ assert.equal(reader.maybeRead("first"), true);
101
+ await waitFor(() => lines.length > 0);
102
+ assert.equal(reader.maybeRead("second, too soon"), false);
103
+ assert.equal(calls.length, 1);
104
+ });
105
+
106
+ test("a missing gdb is reported, not thrown", async () => {
107
+ const lines = [];
108
+ const spawnProcess = () => {
109
+ throw new Error("spawn gdb ENOENT");
110
+ };
111
+ const reader = createUsrsctpStateReader({ log: (message) => lines.push(message), spawnProcess, pid: 1 });
112
+ reader.maybeRead("no gdb on this host");
113
+ await waitFor(() => lines.length > 0);
114
+ assert.match(lines[0], /could not start gdb/);
115
+ });