@torrent-tv/proxy 2.58.1 → 2.58.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/bin/cli.js +7 -0
- package/package.json +2 -2
- package/services/memory-report.js +166 -0
- package/services/piece-store/shared-piece-store.js +791 -717
- package/services/torrent-worker/worker.js +5 -0
- package/test/memory-budget.test.js +69 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## 2.58.3
|
|
2
|
+
|
|
3
|
+
- **New**: the proxy says what it is holding, once a minute — resident memory, heap, external and array buffers, the torrent stores in BYTES, and what the machine has left. It was killed on 2026-08-28 by the kernel's own out-of-memory killer at 2.4 GB resident (`exit code 137`, no core dump, `Out of memory: Killed process ... anon-rss: 2422628kB`) and the log had never recorded a single figure about memory. There was one final reading, taken by the kernel, and no series leading to it.
|
|
4
|
+
- **Fix**: the torrent stores share ONE budget instead of each taking its own. It was per torrent, so two torrents meant two of it, and nothing anywhere asked what the process as a whole was holding. On the film the proxy died under, one store had taken the full 504 MB.
|
|
5
|
+
- **Fix**: that budget is a share of `MemAvailable` rather than of `os.freemem()`. On Linux the second counts only the pages free at that instant, while the kernel deliberately keeps that number low by filling the rest with reclaimable cache — so the share it produced had little to do with what an allocation could actually obtain. The kernel publishes the estimate; we read it.
|
|
6
|
+
- **New**: the piece-store line reports megabytes beside its piece count. The count alone says nothing without the piece size, and the piece size differs per torrent: on that film, "63" meant 504 MB.
|
|
7
|
+
|
|
8
|
+
## 2.58.2
|
|
9
|
+
|
|
10
|
+
- **Fix**: `utp-native` moves to 2.5.3-ttv.8, which removes the whole crash family rather than another instance of it. Nine deaths in a fortnight had one shape — libuv holding a pointer into memory that had gone — because the structs carrying `uv_udp_t`, `uv_timer_t` and `uv_udp_send_t` were allocated by JavaScript as `Buffer.alloc(sizeof(...))`, putting them under the garbage collector while libuv's rule is that they must live until the close or completion callback has run. Every earlier fix reconciled the two owners with a rule and the next release found another way through. The module now allocates and frees that memory itself, at the point libuv has provably finished; JavaScript holds only a token, and freeing points the token at nothing so a late call does nothing instead of faulting. On the environment's own teardown nothing is freed at all — a deliberate leak while the process ends beats touching napi as it goes. Checked on the target: 77 tests pass, and 60 create/serve/destroy cycles leave memory flat.
|
|
11
|
+
|
|
1
12
|
## 2.58.1
|
|
2
13
|
|
|
3
14
|
- **Fix**: `utp-native` moves to 2.5.3-ttv.7, which removes a defect ttv.6 itself introduced. That release registered an environment cleanup hook per uTP context and never removed it on the ordinary close path, so once a context finished closing and its buffer was collected the hook stayed registered against freed memory. At teardown it then ran there and called `uv_close` on handles that no longer existed, putting a dead handle into libuv's closing machinery; the fault surfaced later and elsewhere, when a healthy handle was unlinked and its neighbour in the loop's handle queue turned out to be that dead one. Read from `core.WorkerThread.81.1787857798` down to the faulting instruction — `QUEUE_REMOVE`'s second store with an unmapped operand — and by walking the loop's handle queue until a node could not be read (`research/worker-crash-cleanup-hook-2026-08-27.md`).
|
package/bin/cli.js
CHANGED
|
@@ -25,6 +25,7 @@ import { createWebRtcManager } from "../services/webrtc-manager.js";
|
|
|
25
25
|
import { createDataChannelHandler } from "../services/data-channel-handler.js";
|
|
26
26
|
import { pruneCoreDumps } from "../services/core-dumps.js";
|
|
27
27
|
import { adoptOrphanRingFiles, createPacketWitness, pruneWitnessCaptures } from "../services/packet-witness.js";
|
|
28
|
+
import { startMemoryReport } from "../services/memory-report.js";
|
|
28
29
|
import { collectHealthMetrics } from "../services/health-collector.js";
|
|
29
30
|
import { createPortMapper } from "../services/port-mapper.js";
|
|
30
31
|
import { classifyNat } from "../services/nat-classifier.js";
|
|
@@ -334,6 +335,12 @@ try {
|
|
|
334
335
|
// the pruner recognises BEFORE anything starts a new ring over them.
|
|
335
336
|
void adoptOrphanRingFiles(packetWitness.dir).then(() => pruneWitnessCaptures(packetWitness.dir));
|
|
336
337
|
|
|
338
|
+
// What this process holds, once a minute. The kernel killed the proxy on
|
|
339
|
+
// 2026-08-28 at 2.4 GB resident and the log had never said a word about
|
|
340
|
+
// memory, so the growth that ended in that kill has no shape in any record we
|
|
341
|
+
// keep. RSS is the figure the OOM killer reads, so RSS is the figure to say.
|
|
342
|
+
startMemoryReport({ log: (message) => logger.info(message) });
|
|
343
|
+
|
|
337
344
|
logger.info(`Starting @torrent-tv/proxy v${PROXY_VERSION}`);
|
|
338
345
|
logger.info(`Local stream endpoint: http://${bindHost}:${actualPort}/stream`);
|
|
339
346
|
logger.info(`Advertised direct URL: ${directBaseUrl}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@torrent-tv/proxy",
|
|
3
|
-
"version": "2.58.
|
|
3
|
+
"version": "2.58.3",
|
|
4
4
|
"description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"publishConfig": {
|
|
@@ -45,6 +45,6 @@
|
|
|
45
45
|
"@biomejs/biome": "^2.5.7"
|
|
46
46
|
},
|
|
47
47
|
"overrides": {
|
|
48
|
-
"utp-native": "npm:@torrent-tv/utp-native@2.5.3-ttv.
|
|
48
|
+
"utp-native": "npm:@torrent-tv/utp-native@2.5.3-ttv.8"
|
|
49
49
|
}
|
|
50
50
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What this process is holding, said out loud on a regular cadence.
|
|
3
|
+
*
|
|
4
|
+
* Written 2026-08-28, after the kernel killed the proxy and the log could not
|
|
5
|
+
* say why. The supervisor recorded `exit code 137` — SIGKILL, so no core dump —
|
|
6
|
+
* and the kernel ring buffer held the whole of what was known:
|
|
7
|
+
*
|
|
8
|
+
* Out of memory: Killed process 3036113 (MainThread)
|
|
9
|
+
* anon-rss: 2422628kB total-vm: 20856720kB oom_score_adj: 200
|
|
10
|
+
*
|
|
11
|
+
* Two point four gigabytes, on a host with under two free, and the addon is the
|
|
12
|
+
* first thing the kernel picks because Home Assistant gives addons a positive
|
|
13
|
+
* `oom_score_adj`. What the proxy had been logging all along was its share of a
|
|
14
|
+
* CPU. Nothing anywhere said how much memory it held, so the growth that ended
|
|
15
|
+
* in that line has no shape: one final reading taken by the kernel, and no
|
|
16
|
+
* series leading to it.
|
|
17
|
+
*
|
|
18
|
+
* This is that series. It costs one line a minute and reads only counters the
|
|
19
|
+
* runtime already maintains.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFile } from "node:fs/promises";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
|
|
25
|
+
/** How often the reading is taken and written. */
|
|
26
|
+
export const MEMORY_REPORT_INTERVAL_MS = 60_000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What the process is holding, from the runtime's own counters.
|
|
30
|
+
*
|
|
31
|
+
* `rss` is what the kernel counts against us and therefore what the OOM killer
|
|
32
|
+
* reads. The rest says where it went: the JavaScript heap, and everything held
|
|
33
|
+
* outside it — which for this proxy is where the interesting growth lives,
|
|
34
|
+
* since torrent pieces sit in a `SharedArrayBuffer` and segment bodies pass
|
|
35
|
+
* through buffers.
|
|
36
|
+
*
|
|
37
|
+
* @returns {{ rss: number, heapUsed: number, heapTotal: number, external: number, arrayBuffers: number }}
|
|
38
|
+
*/
|
|
39
|
+
export function readProcessMemory() {
|
|
40
|
+
const usage = process.memoryUsage();
|
|
41
|
+
return {
|
|
42
|
+
rss: usage.rss,
|
|
43
|
+
heapUsed: usage.heapUsed,
|
|
44
|
+
heapTotal: usage.heapTotal,
|
|
45
|
+
external: usage.external,
|
|
46
|
+
arrayBuffers: usage.arrayBuffers ?? 0
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How much memory the machine could still give out, in bytes.
|
|
52
|
+
*
|
|
53
|
+
* `os.freemem()` is the wrong quantity on Linux and the difference is not
|
|
54
|
+
* academic: it counts only pages that are free RIGHT NOW, while the kernel
|
|
55
|
+
* deliberately keeps that number low by filling the rest with reclaimable page
|
|
56
|
+
* cache. `MemAvailable` is the kernel's own estimate of what a new allocation
|
|
57
|
+
* could actually obtain, cache included. Reading the estimate the kernel
|
|
58
|
+
* publishes beats recomputing a worse one.
|
|
59
|
+
*
|
|
60
|
+
* @returns {Promise<number | null>} Bytes, or null where /proc is not there.
|
|
61
|
+
*/
|
|
62
|
+
export async function readAvailableMemory() {
|
|
63
|
+
try {
|
|
64
|
+
const text = await readFile("/proc/meminfo", "utf8");
|
|
65
|
+
const match = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(text);
|
|
66
|
+
if (match) {
|
|
67
|
+
return Number(match[1]) * 1024;
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// silent-ok: not Linux, or /proc is not mounted. The fallback below is a
|
|
71
|
+
// worse answer, and saying so is the point of returning it separately.
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Available memory, falling back to what the runtime can offer.
|
|
78
|
+
*
|
|
79
|
+
* @returns {Promise<{ bytes: number, measured: boolean }>}
|
|
80
|
+
*/
|
|
81
|
+
export async function availableMemory() {
|
|
82
|
+
const fromKernel = await readAvailableMemory();
|
|
83
|
+
if (fromKernel !== null) {
|
|
84
|
+
return { bytes: fromKernel, measured: true };
|
|
85
|
+
}
|
|
86
|
+
return { bytes: os.freemem(), measured: false };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Render a size the way a person reads one.
|
|
91
|
+
*
|
|
92
|
+
* @param {number} bytes
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
function megabytes(bytes) {
|
|
96
|
+
return `${Math.round(bytes / (1024 * 1024))}MB`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One line saying what the process holds and what the machine has left.
|
|
101
|
+
*
|
|
102
|
+
* Pure, so the wording and the arithmetic can be pinned without a running
|
|
103
|
+
* process. Store figures are given in BYTES rather than in pieces: the piece
|
|
104
|
+
* count is meaningless without the piece size, and the piece size differs per
|
|
105
|
+
* torrent — on the film this proxy died under, 63 pieces meant 504 MB.
|
|
106
|
+
*
|
|
107
|
+
* @param {Object} reading
|
|
108
|
+
* @param {{ rss: number, heapUsed: number, heapTotal: number, external: number, arrayBuffers: number }} reading.process
|
|
109
|
+
* @param {number} reading.availableBytes
|
|
110
|
+
* @param {boolean} reading.availableMeasured
|
|
111
|
+
* @param {{ name: string, residentBytes: number, budgetBytes: number }[]} [reading.stores]
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
export function describeMemory({ process: usage, availableBytes, availableMeasured, stores = [] }) {
|
|
115
|
+
const storeResident = stores.reduce((total, store) => total + (store.residentBytes || 0), 0);
|
|
116
|
+
const storeBudget = stores.reduce((total, store) => total + (store.budgetBytes || 0), 0);
|
|
117
|
+
const storesPart = stores.length === 0
|
|
118
|
+
? "no torrent stores"
|
|
119
|
+
: `${stores.length} torrent store(s) holding ${megabytes(storeResident)} ` +
|
|
120
|
+
`of ${megabytes(storeBudget)} allowed`;
|
|
121
|
+
return (
|
|
122
|
+
`memory: rss=${megabytes(usage.rss)} heap=${megabytes(usage.heapUsed)}/${megabytes(usage.heapTotal)} ` +
|
|
123
|
+
`external=${megabytes(usage.external)} arrayBuffers=${megabytes(usage.arrayBuffers)}; ` +
|
|
124
|
+
`${storesPart}; ` +
|
|
125
|
+
`machine has ${megabytes(availableBytes)} available` +
|
|
126
|
+
`${availableMeasured ? "" : " (estimated — /proc/meminfo could not be read)"}`
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Report memory on a timer until stopped.
|
|
132
|
+
*
|
|
133
|
+
* @param {Object} options
|
|
134
|
+
* @param {(message: string) => void} options.log
|
|
135
|
+
* @param {() => { name: string, residentBytes: number, budgetBytes: number }[]} [options.readStores]
|
|
136
|
+
* @param {number} [options.intervalMs]
|
|
137
|
+
* @returns {{ stop: () => void }}
|
|
138
|
+
*/
|
|
139
|
+
export function startMemoryReport({ log, readStores, intervalMs = MEMORY_REPORT_INTERVAL_MS }) {
|
|
140
|
+
const tick = async () => {
|
|
141
|
+
try {
|
|
142
|
+
const { bytes, measured } = await availableMemory();
|
|
143
|
+
let stores = [];
|
|
144
|
+
try {
|
|
145
|
+
stores = typeof readStores === "function" ? readStores() ?? [] : [];
|
|
146
|
+
} catch {
|
|
147
|
+
// silent-ok: a store list that cannot be read must not stop the reading
|
|
148
|
+
// that matters, which is the process's own.
|
|
149
|
+
}
|
|
150
|
+
log(describeMemory({
|
|
151
|
+
process: readProcessMemory(),
|
|
152
|
+
availableBytes: bytes,
|
|
153
|
+
availableMeasured: measured,
|
|
154
|
+
stores
|
|
155
|
+
}));
|
|
156
|
+
} catch {
|
|
157
|
+
// silent-ok: a reading that fails is not worth ending the series over.
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
void tick();
|
|
161
|
+
const timer = setInterval(() => { void tick(); }, intervalMs);
|
|
162
|
+
if (typeof timer.unref === "function") {
|
|
163
|
+
timer.unref();
|
|
164
|
+
}
|
|
165
|
+
return { stop: () => clearInterval(timer) };
|
|
166
|
+
}
|