@torrent-tv/proxy 2.95.7 → 2.95.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/bin/cli.js +2 -2
- package/package.json +1 -1
- package/services/storage/machine-memory.js +241 -12
- package/services/storage/memory-report.js +26 -16
- package/services/torrent/torrent-pool.js +6 -4
- package/services/transport/health-collector.js +13 -8
- package/services/viewer/DownloadMaps.js +16 -4
package/bin/cli.js
CHANGED
|
@@ -24,7 +24,7 @@ import { createTunnelClient } from "../services/transport/tunnel-client.js";
|
|
|
24
24
|
import { createWebRtcManager } from "../services/transport/webrtc-manager.js";
|
|
25
25
|
import { createDataChannelHandler } from "../services/transport/data-channel-handler.js";
|
|
26
26
|
import { dumpsToRemove, pruneCoreDumps } from "../services/storage/core-dumps.js";
|
|
27
|
-
import {
|
|
27
|
+
import { availableMemory } from "../services/storage/machine-memory.js";
|
|
28
28
|
import { Diagnostics } from "../services/storage/Diagnostics.js";
|
|
29
29
|
import { adoptOrphanRingFiles, createPacketWitness, pruneWitnessCaptures } from "../services/transport/packet-witness.js";
|
|
30
30
|
import { createUsrsctpStateReader } from "../services/transport/usrsctp-state.js";
|
|
@@ -586,7 +586,7 @@ try {
|
|
|
586
586
|
const encode = started?.outputParts?.admission?.headroom?.() ?? null;
|
|
587
587
|
return {
|
|
588
588
|
metrics: {
|
|
589
|
-
...collectHealthMetrics({
|
|
589
|
+
...collectHealthMetrics({ availableMemory }),
|
|
590
590
|
...(encode ? { encodeSpeedX: encode.encodeSpeedX, encodeOccupiedCostSec: encode.occupiedCostSec } : {})
|
|
591
591
|
},
|
|
592
592
|
holds
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @file How much of
|
|
2
|
+
* @file How much memory an allocation of this process could obtain, read once.
|
|
3
3
|
*
|
|
4
4
|
* It was written THREE TIMES, in three layers, with the same body each time:
|
|
5
5
|
* `transport/health-collector.js` for the score a proxy publishes to the registry,
|
|
@@ -22,35 +22,264 @@
|
|
|
22
22
|
* estimate of what an allocation could actually obtain. Where there is no
|
|
23
23
|
* `/proc` — not Linux, or it is unreadable — `freemem` is the best answer there
|
|
24
24
|
* is, and on those systems it is not misleading.
|
|
25
|
+
*
|
|
26
|
+
* **Why a container's limit as well.** `/proc/meminfo` describes the HOST. A
|
|
27
|
+
* proxy run under Docker's `--memory` or systemd's `MemoryMax=` is killed by
|
|
28
|
+
* the kernel at its cgroup's limit whatever the host has free: on 2026-10-07 a
|
|
29
|
+
* 512 MiB container was killed while this reading said 4437 MB
|
|
30
|
+
* (torrent-tv/meta#153). So the answer is the smaller of the host's figure and
|
|
31
|
+
* the room left under every memory limit on this process's cgroup path — its
|
|
32
|
+
* own and each ancestor's, because a slice limit binds every unit inside it.
|
|
33
|
+
* The proxy runs anywhere, so nothing here knows of any particular host: the
|
|
34
|
+
* cgroup is found from `/proc/self/cgroup`, and where nothing is readable the
|
|
35
|
+
* answer is the host's alone, as it always was.
|
|
36
|
+
*
|
|
37
|
+
* **The room under a limit is NOT `limit − usage`,** for the same reason the
|
|
38
|
+
* host's figure is not `freemem`: the cgroup is charged for the file pages its
|
|
39
|
+
* processes read and write, and the kernel takes those back before it kills
|
|
40
|
+
* anything. Measured in a 512 MiB container on the Home Assistant host: after a
|
|
41
|
+
* 600 MB file was written `memory.current` stood at 531 MB of 537 — 515 MB of
|
|
42
|
+
* it file cache, 0.12 MB anonymous — and a 450 MB allocation then succeeded
|
|
43
|
+
* with no OOM kill, the cache shrinking to 50 MB. `limit − usage` said 5 MB. So
|
|
44
|
+
* the reclaimable part is added back, and it is the part the kernel's own
|
|
45
|
+
* `MemAvailable` counts: the file LRU lists and reclaimable slab
|
|
46
|
+
* (`active_file`, `inactive_file`, `slab_reclaimable` in cgroup v2's
|
|
47
|
+
* `memory.stat`; v1 states the file lists hierarchically as `total_*`). The
|
|
48
|
+
* watermark share `MemAvailable` subtracts has no counterpart under a cgroup
|
|
49
|
+
* limit, which is hard, so nothing is subtracted for it.
|
|
25
50
|
*/
|
|
26
51
|
|
|
27
52
|
import { readFileSync } from "node:fs";
|
|
28
53
|
import os from "node:os";
|
|
29
54
|
|
|
30
55
|
/**
|
|
31
|
-
*
|
|
56
|
+
* Where each cgroup version keeps its memory controller, and under which names.
|
|
57
|
+
*
|
|
58
|
+
* Version 1 states the file lists of the cgroup alone as `active_file` and of
|
|
59
|
+
* it with its descendants as `total_active_file`; its usage includes the
|
|
60
|
+
* descendants, so the hierarchical figures are the ones that subtract from it.
|
|
61
|
+
*/
|
|
62
|
+
const HIERARCHIES = {
|
|
63
|
+
1: {
|
|
64
|
+
mount: "/sys/fs/cgroup/memory",
|
|
65
|
+
limit: "memory.limit_in_bytes",
|
|
66
|
+
usage: "memory.usage_in_bytes",
|
|
67
|
+
reclaimable: ["total_active_file", "total_inactive_file"]
|
|
68
|
+
},
|
|
69
|
+
2: {
|
|
70
|
+
mount: "/sys/fs/cgroup",
|
|
71
|
+
limit: "memory.max",
|
|
72
|
+
usage: "memory.current",
|
|
73
|
+
reclaimable: ["active_file", "inactive_file", "slab_reclaimable"]
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @typedef {Object} MemoryReading
|
|
79
|
+
* @property {number} bytes - What an allocation could obtain right now.
|
|
80
|
+
* @property {"cgroup v2" | "cgroup v1" | "MemAvailable" | "freemem"} source -
|
|
81
|
+
* Which reading decided `bytes`: a container's limit, the kernel's own
|
|
82
|
+
* estimate for the host, or free memory where that estimate was unreadable.
|
|
83
|
+
* @property {number | null} limitBytes - The smallest memory limit in force on
|
|
84
|
+
* this process's cgroup path, or null where there is none. Reported whether
|
|
85
|
+
* or not it decided, so a log says that a limit exists.
|
|
86
|
+
* @property {number} totalBytes - The most this process could ever hold: the
|
|
87
|
+
* limit where there is one, the host's memory otherwise.
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What an allocation of this process could obtain right now.
|
|
32
92
|
*
|
|
33
|
-
* @
|
|
34
|
-
*
|
|
35
|
-
*
|
|
93
|
+
* @param {Object} [options]
|
|
94
|
+
* @param {(path: string) => string} [options.readFile] - Reads a file as text,
|
|
95
|
+
* throwing where it is absent or unreadable; injected by the checks.
|
|
96
|
+
* @returns {MemoryReading}
|
|
36
97
|
*/
|
|
37
|
-
export function availableMemory() {
|
|
98
|
+
export function availableMemory({ readFile = (path) => readFileSync(path, "utf8") } = {}) {
|
|
99
|
+
const hostTotal = os.totalmem();
|
|
100
|
+
const host = hostAvailable(readFile);
|
|
101
|
+
const container = containerRoom(readFile, hostTotal);
|
|
102
|
+
if (container !== null && container.roomBytes < host.bytes) {
|
|
103
|
+
return {
|
|
104
|
+
bytes: container.roomBytes,
|
|
105
|
+
source: `cgroup v${container.version}`,
|
|
106
|
+
limitBytes: container.limitBytes,
|
|
107
|
+
totalBytes: container.limitBytes
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
bytes: host.bytes,
|
|
112
|
+
source: host.source,
|
|
113
|
+
limitBytes: container?.limitBytes ?? null,
|
|
114
|
+
totalBytes: container?.limitBytes ?? hostTotal
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What an allocation of this process could obtain right now, as a plain number.
|
|
120
|
+
*
|
|
121
|
+
* @returns {number}
|
|
122
|
+
*/
|
|
123
|
+
export function availableMemoryBytes() {
|
|
124
|
+
return availableMemory().bytes;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The host's own estimate, or its free memory where the estimate is unreadable.
|
|
129
|
+
*
|
|
130
|
+
* @param {(path: string) => string} readFile
|
|
131
|
+
* @returns {{ bytes: number, source: "MemAvailable" | "freemem" }}
|
|
132
|
+
*/
|
|
133
|
+
function hostAvailable(readFile) {
|
|
38
134
|
try {
|
|
39
|
-
const match = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(
|
|
135
|
+
const match = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(readFile("/proc/meminfo"));
|
|
40
136
|
if (match) {
|
|
41
|
-
return { bytes: Number(match[1]) * 1024,
|
|
137
|
+
return { bytes: Number(match[1]) * 1024, source: "MemAvailable" };
|
|
42
138
|
}
|
|
43
139
|
} catch {
|
|
44
140
|
// silent-ok: not Linux, or /proc is not readable.
|
|
45
141
|
}
|
|
46
|
-
return { bytes: os.freemem(),
|
|
142
|
+
return { bytes: os.freemem(), source: "freemem" };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The room left under every memory limit on this process's cgroup path.
|
|
147
|
+
*
|
|
148
|
+
* @param {(path: string) => string} readFile
|
|
149
|
+
* @param {number} hostTotal - The host's memory; a limit at or above it cannot
|
|
150
|
+
* be reached by memory use, which is how cgroup v1 says "no limit".
|
|
151
|
+
* @returns {{ version: 1 | 2, roomBytes: number, limitBytes: number } | null}
|
|
152
|
+
* Null where no limit is in force or nothing is readable.
|
|
153
|
+
*/
|
|
154
|
+
function containerRoom(readFile, hostTotal) {
|
|
155
|
+
let membership;
|
|
156
|
+
try {
|
|
157
|
+
membership = ownCgroup(readFile("/proc/self/cgroup"));
|
|
158
|
+
} catch {
|
|
159
|
+
return null; // silent-ok: not Linux, or /proc is not readable.
|
|
160
|
+
}
|
|
161
|
+
if (membership === null) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const hierarchy = HIERARCHIES[membership.version];
|
|
165
|
+
let found = null;
|
|
166
|
+
for (const directory of levelsOf(hierarchy.mount, membership.path)) {
|
|
167
|
+
const level = roomAt(readFile, hierarchy, directory, hostTotal);
|
|
168
|
+
if (level === null) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
found = {
|
|
172
|
+
version: membership.version,
|
|
173
|
+
roomBytes: Math.min(found?.roomBytes ?? Infinity, level.roomBytes),
|
|
174
|
+
limitBytes: Math.min(found?.limitBytes ?? Infinity, level.limitBytes)
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return found;
|
|
47
178
|
}
|
|
48
179
|
|
|
49
180
|
/**
|
|
50
|
-
*
|
|
181
|
+
* Which cgroup this process is in, for the memory controller.
|
|
51
182
|
*
|
|
183
|
+
* A host in hybrid mode lists both versions; the memory controller is then on
|
|
184
|
+
* version 1, named in the line that lists it.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} text - `/proc/self/cgroup`.
|
|
187
|
+
* @returns {{ version: 1 | 2, path: string } | null}
|
|
188
|
+
*/
|
|
189
|
+
function ownCgroup(text) {
|
|
190
|
+
for (const line of text.split("\n")) {
|
|
191
|
+
const match = /^\d+:([^:]*):(.*)$/.exec(line);
|
|
192
|
+
if (match && match[1].split(",").includes("memory")) {
|
|
193
|
+
return { version: 1, path: match[2] };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const unified = /^0::(.*)$/m.exec(text);
|
|
197
|
+
return unified ? { version: 2, path: unified[1] } : null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The cgroup's own directory and each ancestor's, up to the mount.
|
|
202
|
+
*
|
|
203
|
+
* The own directory may be absent: Docker on cgroup v1 names the host's path
|
|
204
|
+
* while mounting the container's own directory at the root. The mount is
|
|
205
|
+
* always on the list, so in that case it is what is read. A path with `..` is
|
|
206
|
+
* a cgroup outside this namespace's root, which nothing under the mount
|
|
207
|
+
* describes, so nothing is read for it and the host's figure stands.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} mount
|
|
210
|
+
* @param {string} path
|
|
211
|
+
* @returns {string[]}
|
|
212
|
+
*/
|
|
213
|
+
function levelsOf(mount, path) {
|
|
214
|
+
const parts = path.split("/").filter(Boolean);
|
|
215
|
+
if (parts.includes("..")) {
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
const levels = [];
|
|
219
|
+
for (let depth = parts.length; depth >= 0; depth -= 1) {
|
|
220
|
+
levels.push([mount, ...parts.slice(0, depth)].join("/"));
|
|
221
|
+
}
|
|
222
|
+
return levels;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The limit in one cgroup directory and the room left under it.
|
|
227
|
+
*
|
|
228
|
+
* @param {(path: string) => string} readFile
|
|
229
|
+
* @param {(typeof HIERARCHIES)[1]} hierarchy
|
|
230
|
+
* @param {string} directory
|
|
231
|
+
* @param {number} hostTotal
|
|
232
|
+
* @returns {{ roomBytes: number, limitBytes: number } | null} Null where this
|
|
233
|
+
* level states no limit — `max`, the version 1 sentinel — or is not there.
|
|
234
|
+
*/
|
|
235
|
+
function roomAt(readFile, hierarchy, directory, hostTotal) {
|
|
236
|
+
let limitBytes;
|
|
237
|
+
let usageBytes;
|
|
238
|
+
try {
|
|
239
|
+
limitBytes = bytesOf(readFile(`${directory}/${hierarchy.limit}`));
|
|
240
|
+
usageBytes = bytesOf(readFile(`${directory}/${hierarchy.usage}`));
|
|
241
|
+
} catch {
|
|
242
|
+
return null; // silent-ok: no such level, or no memory controller on it.
|
|
243
|
+
}
|
|
244
|
+
if (!Number.isFinite(limitBytes) || limitBytes >= hostTotal || !Number.isFinite(usageBytes)) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
let reclaimableBytes = 0;
|
|
248
|
+
try {
|
|
249
|
+
reclaimableBytes = statSum(readFile(`${directory}/memory.stat`), hierarchy.reclaimable);
|
|
250
|
+
} catch {
|
|
251
|
+
// silent-ok: counting nothing as reclaimable understates the room, which
|
|
252
|
+
// errs away from the kill this reading exists to avoid.
|
|
253
|
+
}
|
|
254
|
+
return { limitBytes, roomBytes: Math.max(0, limitBytes - usageBytes + reclaimableBytes) };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* A byte count written by the kernel, or NaN for anything else (`max`).
|
|
259
|
+
*
|
|
260
|
+
* @param {string} text
|
|
52
261
|
* @returns {number}
|
|
53
262
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
263
|
+
function bytesOf(text) {
|
|
264
|
+
const trimmed = text.trim();
|
|
265
|
+
return /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The sum of the named fields of a `memory.stat`.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} text
|
|
272
|
+
* @param {string[]} names
|
|
273
|
+
* @returns {number}
|
|
274
|
+
*/
|
|
275
|
+
function statSum(text, names) {
|
|
276
|
+
let sum = 0;
|
|
277
|
+
for (const line of text.split("\n")) {
|
|
278
|
+
const [name, value = ""] = line.split(" ");
|
|
279
|
+
if (names.includes(name)) {
|
|
280
|
+
const bytes = bytesOf(value);
|
|
281
|
+
sum += Number.isFinite(bytes) ? bytes : 0;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return sum;
|
|
56
285
|
}
|
|
@@ -168,13 +168,6 @@ export async function readMappingSummary() {
|
|
|
168
168
|
return null;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
/**
|
|
172
|
-
* Available memory, from the one place that reads it.
|
|
173
|
-
*
|
|
174
|
-
* @returns {{ bytes: number, measured: boolean }}
|
|
175
|
-
*/
|
|
176
|
-
export { availableMemory };
|
|
177
|
-
|
|
178
171
|
/**
|
|
179
172
|
* Anonymous memory this process holds, from the kernel's own rollup.
|
|
180
173
|
*
|
|
@@ -254,8 +247,8 @@ function megabytes(bytes) {
|
|
|
254
247
|
* @param {"process" | "thread"} [reading.scope]
|
|
255
248
|
* @param {string} [reading.label] - Which thread the isolate figures are of.
|
|
256
249
|
* @param {{ rss: number, heapUsed: number, heapTotal: number, external: number, arrayBuffers: number }} reading.process
|
|
257
|
-
* @param {
|
|
258
|
-
*
|
|
250
|
+
* @param {import("./machine-memory.js").MemoryReading} [reading.available] -
|
|
251
|
+
* What an allocation could obtain, and which reading decided it.
|
|
259
252
|
* @param {number | null} [reading.anonymousBytes]
|
|
260
253
|
* @param {ReturnType<typeof summariseMappings> | null} [reading.mappings]
|
|
261
254
|
* @param {number | null} [reading.diskFreeBytes]
|
|
@@ -272,8 +265,7 @@ export function describeMemory({
|
|
|
272
265
|
scope = "process",
|
|
273
266
|
label = "",
|
|
274
267
|
process: usage,
|
|
275
|
-
|
|
276
|
-
availableMeasured,
|
|
268
|
+
available,
|
|
277
269
|
anonymousBytes = null,
|
|
278
270
|
mappings = null,
|
|
279
271
|
diskFreeBytes = null,
|
|
@@ -312,13 +304,32 @@ export function describeMemory({
|
|
|
312
304
|
`memory: rss=${megabytes(usage.rss)} ${isolate}` +
|
|
313
305
|
`${anonymousBytes === null ? "" : ` anon=${megabytes(anonymousBytes)}`}${shape}; ` +
|
|
314
306
|
`${storesPart}; ` +
|
|
315
|
-
|
|
316
|
-
`${availableMeasured ? "" : " (estimated — /proc/meminfo could not be read)"}` +
|
|
307
|
+
describeAvailable(available) +
|
|
317
308
|
`${diskFreeBytes === null ? "" : `, ${megabytes(diskFreeBytes)} free on disk`}` +
|
|
318
309
|
tail
|
|
319
310
|
);
|
|
320
311
|
}
|
|
321
312
|
|
|
313
|
+
/**
|
|
314
|
+
* What an allocation could obtain, with the reading that decided it and the
|
|
315
|
+
* container's limit, so a log says whether a limit is in force.
|
|
316
|
+
*
|
|
317
|
+
* @param {import("./machine-memory.js").MemoryReading | undefined} available
|
|
318
|
+
* @returns {string}
|
|
319
|
+
*/
|
|
320
|
+
function describeAvailable(available) {
|
|
321
|
+
if (!available) {
|
|
322
|
+
return "available memory not read";
|
|
323
|
+
}
|
|
324
|
+
const decided = available.source === "freemem"
|
|
325
|
+
? "estimated from free memory — /proc/meminfo could not be read"
|
|
326
|
+
: `${available.source} decides`;
|
|
327
|
+
const limit = available.limitBytes === null
|
|
328
|
+
? "no container limit"
|
|
329
|
+
: `container limit ${megabytes(available.limitBytes)}`;
|
|
330
|
+
return `machine has ${megabytes(available.bytes)} available (${decided}; ${limit})`;
|
|
331
|
+
}
|
|
332
|
+
|
|
322
333
|
/**
|
|
323
334
|
* The figures whose movement earns a line, each under its own name.
|
|
324
335
|
*
|
|
@@ -540,14 +551,13 @@ export function startMemoryReport({
|
|
|
540
551
|
// mapping and a busy process has thousands; the rollup and
|
|
541
552
|
// `/proc/meminfo` are single lines but still walk page tables, and
|
|
542
553
|
// the reading now happens once a second rather than once a minute.
|
|
543
|
-
const
|
|
554
|
+
const available = availableMemory();
|
|
544
555
|
anonymousBytes = await readAnonymousMemory();
|
|
545
556
|
log(describeMemory({
|
|
546
557
|
scope,
|
|
547
558
|
label,
|
|
548
559
|
process: processMemory,
|
|
549
|
-
|
|
550
|
-
availableMeasured: measured,
|
|
560
|
+
available,
|
|
551
561
|
anonymousBytes,
|
|
552
562
|
mappings: await readMappingSummary(),
|
|
553
563
|
diskFreeBytes: diskPath ? await readDiskFree(diskPath) : null,
|
|
@@ -1826,17 +1826,19 @@ export class TorrentPool {
|
|
|
1826
1826
|
|
|
1827
1827
|
/**
|
|
1828
1828
|
* Schedule removal of a torrent (with its on-disk store) after
|
|
1829
|
-
* {@link TORRENT_IDLE_TTL_MS} of nothing being wanted of it.
|
|
1830
|
-
*
|
|
1829
|
+
* {@link TORRENT_IDLE_TTL_MS} of nothing being wanted of it. The clock runs
|
|
1830
|
+
* from when it stopped being wanted, so a timer already running is kept:
|
|
1831
|
+
* replacing it on every report of the same state extended the time without
|
|
1832
|
+
* end, and the withdrawal of one map's 4900 zones replaced it 3524 times in
|
|
1833
|
+
* four seconds (Home Assistant 2026-10-07, torrent-tv/meta#95).
|
|
1831
1834
|
*
|
|
1832
1835
|
* @param {import("webtorrent").Torrent} torrent
|
|
1833
1836
|
* @returns {void}
|
|
1834
1837
|
*/
|
|
1835
1838
|
#scheduleIdleRemoval(torrent) {
|
|
1836
|
-
if (!torrent) {
|
|
1839
|
+
if (!torrent || this.#idleTimers.has(torrent)) {
|
|
1837
1840
|
return;
|
|
1838
1841
|
}
|
|
1839
|
-
this.#cancelIdleRemoval(torrent);
|
|
1840
1842
|
const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
|
|
1841
1843
|
const infoHashShort = String(torrent.infoHash ?? "?").slice(0, 8);
|
|
1842
1844
|
logger.info(`torrent-pool: scheduling idle removal for "${name}" [${infoHashShort}] in ${TORRENT_IDLE_TTL_MS / 1000}s`);
|
|
@@ -16,16 +16,19 @@ import os from "node:os";
|
|
|
16
16
|
* Suitable as input to `Math.max(0, 1 - Math.min(1, cpuLoad))` for a
|
|
17
17
|
* normalised "CPU availability" score.
|
|
18
18
|
*
|
|
19
|
-
* `memFree` — fraction of
|
|
20
|
-
* (0–1)
|
|
21
|
-
*
|
|
19
|
+
* `memFree` — fraction of the memory this process could ever hold that could
|
|
20
|
+
* still be given out (0–1): of a container's limit where one is in force, of
|
|
21
|
+
* the host's RAM otherwise — divided by the host's RAM, a 512 MiB container
|
|
22
|
+
* with half its limit free read as nearly full. The reading is the storage
|
|
23
|
+
* component's (`storage/machine-memory.js` says why that is not the same as
|
|
24
|
+
* free memory), handed in.
|
|
22
25
|
*
|
|
23
26
|
* `uptime` — process uptime in whole seconds (useful for preferring
|
|
24
27
|
* already-warmed proxies over freshly started ones).
|
|
25
28
|
*
|
|
26
29
|
* @typedef {Object} HealthMetrics
|
|
27
30
|
* @property {number} cpuLoad - 1-min load avg / cpu-count. 0 = idle, 1 = saturated, >1 = overloaded.
|
|
28
|
-
* @property {number} memFree - Memory an allocation could obtain, as a fraction of
|
|
31
|
+
* @property {number} memFree - Memory an allocation could obtain, as a fraction of the most this process could hold (0–1).
|
|
29
32
|
* @property {number} uptime - Process uptime in seconds.
|
|
30
33
|
*/
|
|
31
34
|
|
|
@@ -36,14 +39,16 @@ import os from "node:os";
|
|
|
36
39
|
* diff noise when serialising to JSON across the tunnel.
|
|
37
40
|
*
|
|
38
41
|
* @param {object} params
|
|
39
|
-
* @param {() => number} params.
|
|
40
|
-
*
|
|
42
|
+
* @param {() => { bytes: number, totalBytes: number }} params.availableMemory -
|
|
43
|
+
* What an allocation could obtain right now and the most this process could
|
|
44
|
+
* ever hold; the storage component's reading.
|
|
41
45
|
* @returns {HealthMetrics}
|
|
42
46
|
*/
|
|
43
|
-
export function collectHealthMetrics({
|
|
47
|
+
export function collectHealthMetrics({ availableMemory }) {
|
|
44
48
|
const cpuCount = os.cpus().length || 1;
|
|
45
49
|
const cpuLoad = os.loadavg()[0] / cpuCount;
|
|
46
|
-
const
|
|
50
|
+
const { bytes, totalBytes } = availableMemory();
|
|
51
|
+
const memFree = bytes / totalBytes;
|
|
47
52
|
|
|
48
53
|
return {
|
|
49
54
|
cpuLoad: Math.round(cpuLoad * 1000) / 1000,
|
|
@@ -19,13 +19,25 @@ export class DownloadMaps {
|
|
|
19
19
|
file.sourceZones = zones.map(zone => Number.isFinite(zone.deadlineAt) || !Number.isFinite(zone.withinSeconds)
|
|
20
20
|
? zone : { ...zone, deadlineAt: now + Math.max(0, zone.withinSeconds) * 1000 });
|
|
21
21
|
let changed = false;
|
|
22
|
+
// Until the new zones are resolved to bytes, a resolved zone stays while
|
|
23
|
+
// the film it covers is still wanted. A viewer moving on re-cuts every zone
|
|
24
|
+
// without making its film unwanted; dropping the zones that lost their
|
|
25
|
+
// exact interval published an empty map every few seconds of playback,
|
|
26
|
+
// which let the swarm go and emptied the encoder's resolved input (Home
|
|
27
|
+
// Assistant 2026-10-07, torrent-tv/meta#95). A zone no new zone overlaps —
|
|
28
|
+
// a seek away — is withdrawn at once.
|
|
22
29
|
const retained = file.zones.flatMap(zone => {
|
|
23
30
|
const interval = zone.downloadInterval ?? zone;
|
|
24
31
|
const current = file.sourceZones.find(candidate => candidate.from === interval.from && candidate.to === interval.to);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
if (!current) {
|
|
33
|
+
const stillWanted = file.sourceZones.some(candidate => candidate.from < interval.to && interval.from < candidate.to);
|
|
34
|
+
if (!stillWanted) changed = true;
|
|
35
|
+
return stillWanted ? [zone] : [];
|
|
36
|
+
}
|
|
37
|
+
const deadlineAt = Number.isFinite(current.deadlineAt)
|
|
38
|
+
? current.deadlineAt + (Math.max(0, zone.from - current.from) - (zone.leadSeconds ?? 0)) * 1000 : current.deadlineAt;
|
|
39
|
+
if (Object.keys(current).some(key => key !== "from" && key !== "to" && !Object.is(zone[key], key === "deadlineAt" ? deadlineAt : current[key]))) changed = true;
|
|
40
|
+
return [{ ...zone, ...current, from: zone.from, to: zone.to, deadlineAt }];
|
|
29
41
|
});
|
|
30
42
|
this.#setZones(file, retained);
|
|
31
43
|
for (const [statement, metadata] of file.metadata) {
|