@torrent-tv/proxy 2.15.3 → 2.16.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 +4 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +49 -0
- package/services/host-load.js +154 -0
- package/test/host-load.test.js +68 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
## 2.16.0
|
|
2
|
+
|
|
3
|
+
- **New**: While an encoder runs, one line every five seconds says what the MACHINE is doing: the share of it ffmpeg is getting, the share everything else is taking, the share spent waiting on a disk, the CPU's current clock and its temperature. The budget predicts a rung from benchmarks taken at startup on an idle box, and on 2026-08-15 it predicted 1.83x for a rung that then ran at 0.90-0.999x with nothing else encoding — and no log anywhere could say which of the candidate reasons it was. Now the reading exists: an encoder starved of cores, a machine that has dropped its clock or grown hot, and work around the encode that nobody counted all look different in this line. Linux-only and best effort — a host without `/proc` writes nothing and nothing else changes.
|
|
4
|
+
|
|
1
5
|
## 2.15.3
|
|
2
6
|
|
|
3
7
|
- **Fix**: A magnet whose swarm never answered no longer poisons the film for good. It leaves a torrent with the right infohash and no file list, and WebTorrent then refuses the same film opened from a `.torrent` as a duplicate — so the answer to every later attempt came from the entry that knows nothing: `Proxy playback plan request failed (404): File index was not found in torrent`, reproduced in a browser 2026-08-15, and no reload could clear it because the useless entry outlives them all. A source that carries the metadata now replaces one that lacks it.
|
package/package.json
CHANGED
|
@@ -18,6 +18,7 @@ import { spawn } from "node:child_process";
|
|
|
18
18
|
import { createRequire } from "node:module";
|
|
19
19
|
import { logger } from "../utils/logger.js";
|
|
20
20
|
import { readKeyframeIndex } from "./container-index/index.js";
|
|
21
|
+
import { readMachineState, sampleHost, shareOfMachine } from "./host-load.js";
|
|
21
22
|
|
|
22
23
|
/** Own package version, stamped onto session-start log lines. */
|
|
23
24
|
const PROXY_VERSION = createRequire(import.meta.url)("../package.json").version;
|
|
@@ -1193,6 +1194,8 @@ export class HlsSessionManager {
|
|
|
1193
1194
|
* @type {Map<string, { costSec: number, version: number }>}
|
|
1194
1195
|
*/
|
|
1195
1196
|
#observedDecodeCost = new Map();
|
|
1197
|
+
/** The previous reading of the machine, to compare the next one against. */
|
|
1198
|
+
#hostLoadSample = null;
|
|
1196
1199
|
|
|
1197
1200
|
/**
|
|
1198
1201
|
* @param {HlsSessionManagerOptions} options
|
|
@@ -2641,7 +2644,53 @@ export class HlsSessionManager {
|
|
|
2641
2644
|
logger.info(`transcode ${session.id} encoder resumed — ${reason} "${session.fileName}"`);
|
|
2642
2645
|
}
|
|
2643
2646
|
|
|
2647
|
+
/**
|
|
2648
|
+
* One line per interval about the MACHINE, while an encoder is running on it.
|
|
2649
|
+
*
|
|
2650
|
+
* The budget predicts a rung from benchmarks taken at startup on an idle box,
|
|
2651
|
+
* and on 2026-08-15 it predicted 1.83x for a rung that ran at 0.90-0.999x
|
|
2652
|
+
* with nothing else encoding. Every candidate explanation is measurable — the
|
|
2653
|
+
* encoder not getting the cores, the machine having dropped its clock or
|
|
2654
|
+
* grown hot, the work around the encode costing more than anyone counted —
|
|
2655
|
+
* and none of them was being measured, so the gap could only be argued about.
|
|
2656
|
+
*
|
|
2657
|
+
* Written only while something is encoding, and only when a reading is
|
|
2658
|
+
* available: on a host without `/proc` this says nothing at all.
|
|
2659
|
+
*/
|
|
2660
|
+
async #reportHostLoad() {
|
|
2661
|
+
const encoding = [...this.sessionsById.values()].filter(
|
|
2662
|
+
(session) => session?.ffmpeg != null && !hasChildExited(session.ffmpeg) && session.state !== "disposed"
|
|
2663
|
+
);
|
|
2664
|
+
if (encoding.length === 0) {
|
|
2665
|
+
this.#hostLoadSample = null;
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
// The busiest single encoder is the one worth naming; the system share
|
|
2669
|
+
// below covers everything else the box is doing.
|
|
2670
|
+
const watched = encoding[0];
|
|
2671
|
+
const sample = await sampleHost(watched.ffmpeg?.pid ?? null);
|
|
2672
|
+
const previous = this.#hostLoadSample;
|
|
2673
|
+
this.#hostLoadSample = sample;
|
|
2674
|
+
if (previous === null) {
|
|
2675
|
+
return; // the first reading is only something to compare against
|
|
2676
|
+
}
|
|
2677
|
+
const share = shareOfMachine(previous, sample);
|
|
2678
|
+
if (share === null) {
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
const machine = await readMachineState();
|
|
2682
|
+
const asPercent = (value) => (value === null ? "n/a" : `${Math.round(value * 100)}%`);
|
|
2683
|
+
logger.info(
|
|
2684
|
+
`host-load: ffmpeg=${asPercent(share.processShare)} system=${asPercent(share.systemShare)} ` +
|
|
2685
|
+
`iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
|
|
2686
|
+
`temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
|
|
2687
|
+
`encoders=${encoding.length} speed=${watched.progress?.speed ?? "n/a"} ` +
|
|
2688
|
+
`over=${share.elapsedSec.toFixed(1)}s`
|
|
2689
|
+
);
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2644
2692
|
async #enforceRealtimeBudget() {
|
|
2693
|
+
void this.#reportHostLoad();
|
|
2645
2694
|
if (this.videoEncoder?.kind !== "software") {
|
|
2646
2695
|
return;
|
|
2647
2696
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What the machine is doing while an encoder runs on it.
|
|
3
|
+
*
|
|
4
|
+
* The budget predicts a rung from two benchmarks taken at startup on an idle
|
|
5
|
+
* machine. On 2026-08-15 it predicted 1.83x for a rung that then ran at
|
|
6
|
+
* 0.90-0.999x with nothing else encoding, and nothing in any log said why. The
|
|
7
|
+
* candidates are all measurable and none of them was being measured:
|
|
8
|
+
*
|
|
9
|
+
* - the encoder is not getting the cores (something else is taking them, or
|
|
10
|
+
* it is waiting on input);
|
|
11
|
+
* - the machine is no longer the machine that was benchmarked, because it has
|
|
12
|
+
* dropped its clock or grown hot;
|
|
13
|
+
* - the work around the encode — hashing pieces, serving segments — costs
|
|
14
|
+
* more than anyone counted.
|
|
15
|
+
*
|
|
16
|
+
* Everything here is Linux-specific and best effort: a host without these files
|
|
17
|
+
* reports nulls and nothing above it changes. The proxy stays
|
|
18
|
+
* deployment-agnostic (`../CLAUDE.md`).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFile } from "node:fs/promises";
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Clock ticks per second, the unit `/proc/<pid>/stat` counts CPU time in.
|
|
26
|
+
* `getconf CLK_TCK` is 100 on every Linux this runs on; spawning a process to
|
|
27
|
+
* ask would cost more than the measurement.
|
|
28
|
+
*/
|
|
29
|
+
const CLOCK_TICKS_PER_SECOND = 100;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {object} CpuTotals
|
|
33
|
+
* @property {number} busySeconds - Everything but idle.
|
|
34
|
+
* @property {number} idleSeconds
|
|
35
|
+
* @property {number} iowaitSeconds - Idle because it is waiting for a disk.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The system's CPU time since boot, from `/proc/stat`.
|
|
40
|
+
*
|
|
41
|
+
* @returns {Promise<CpuTotals | null>}
|
|
42
|
+
*/
|
|
43
|
+
export async function readSystemCpu() {
|
|
44
|
+
try {
|
|
45
|
+
const text = await readFile("/proc/stat", "utf8");
|
|
46
|
+
const line = text.split("\n", 1)[0];
|
|
47
|
+
if (!line.startsWith("cpu ")) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const fields = line.trim().split(/\s+/).slice(1).map(Number);
|
|
51
|
+
if (fields.length < 5 || fields.some((value) => !Number.isFinite(value))) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const [user, nice, system, idle, iowait] = fields;
|
|
55
|
+
const busyTicks = user + nice + system + fields.slice(5).reduce((sum, value) => sum + value, 0);
|
|
56
|
+
return {
|
|
57
|
+
busySeconds: busyTicks / CLOCK_TICKS_PER_SECOND,
|
|
58
|
+
idleSeconds: idle / CLOCK_TICKS_PER_SECOND,
|
|
59
|
+
iowaitSeconds: iowait / CLOCK_TICKS_PER_SECOND
|
|
60
|
+
};
|
|
61
|
+
} catch {
|
|
62
|
+
return null; // not Linux, or /proc is not mounted
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The CPU time one process has used, from `/proc/<pid>/stat`.
|
|
68
|
+
*
|
|
69
|
+
* The comm field can itself contain spaces and brackets, so the fields are
|
|
70
|
+
* counted from the LAST `)` rather than by splitting the whole line — the usual
|
|
71
|
+
* trap with this file.
|
|
72
|
+
*
|
|
73
|
+
* @param {number} pid
|
|
74
|
+
* @returns {Promise<number | null>} Seconds of CPU, or null.
|
|
75
|
+
*/
|
|
76
|
+
export async function readProcessCpuSeconds(pid) {
|
|
77
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const text = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
82
|
+
const afterComm = text.slice(text.lastIndexOf(")") + 2).trim().split(/\s+/);
|
|
83
|
+
// After the comm and state fields, utime is index 11 and stime index 12.
|
|
84
|
+
const utime = Number(afterComm[11]);
|
|
85
|
+
const stime = Number(afterComm[12]);
|
|
86
|
+
if (!Number.isFinite(utime) || !Number.isFinite(stime)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return (utime + stime) / CLOCK_TICKS_PER_SECOND;
|
|
90
|
+
} catch {
|
|
91
|
+
return null; // the process is gone, or this is not Linux
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Is this still the machine the benchmarks were taken on: its clock and its
|
|
97
|
+
* temperature.
|
|
98
|
+
*
|
|
99
|
+
* @returns {Promise<{ megahertz: number | null, celsius: number | null }>}
|
|
100
|
+
*/
|
|
101
|
+
export async function readMachineState() {
|
|
102
|
+
const [frequency, temperature] = await Promise.all([
|
|
103
|
+
readFile("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq", "utf8").catch(() => null),
|
|
104
|
+
readFile("/sys/class/thermal/thermal_zone0/temp", "utf8").catch(() => null)
|
|
105
|
+
]);
|
|
106
|
+
const kilohertz = frequency === null ? Number.NaN : Number(frequency.trim());
|
|
107
|
+
const milliCelsius = temperature === null ? Number.NaN : Number(temperature.trim());
|
|
108
|
+
return {
|
|
109
|
+
megahertz: Number.isFinite(kilohertz) ? Math.round(kilohertz / 1000) : null,
|
|
110
|
+
celsius: Number.isFinite(milliCelsius) ? Math.round(milliCelsius / 100) / 10 : null
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Turn two readings into the shares of ONE second of wall clock that each part
|
|
116
|
+
* of the machine spent working. A share is per whole machine: 1.0 means every
|
|
117
|
+
* core was busy for the whole interval.
|
|
118
|
+
*
|
|
119
|
+
* @param {{ takenAt: number, processCpuSeconds: number | null, system: CpuTotals | null }} before
|
|
120
|
+
* @param {{ takenAt: number, processCpuSeconds: number | null, system: CpuTotals | null }} after
|
|
121
|
+
* @param {number} [cores=os.cpus().length]
|
|
122
|
+
* @returns {{ elapsedSec: number, processShare: number | null, systemShare: number | null, iowaitShare: number | null } | null}
|
|
123
|
+
*/
|
|
124
|
+
export function shareOfMachine(before, after, cores = os.cpus().length) {
|
|
125
|
+
const elapsedSec = (after.takenAt - before.takenAt) / 1000;
|
|
126
|
+
if (!(elapsedSec > 0) || !(cores > 0)) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const machineSeconds = elapsedSec * cores;
|
|
130
|
+
const processShare = before.processCpuSeconds !== null && after.processCpuSeconds !== null
|
|
131
|
+
? (after.processCpuSeconds - before.processCpuSeconds) / machineSeconds
|
|
132
|
+
: null;
|
|
133
|
+
const systemShare = before.system !== null && after.system !== null
|
|
134
|
+
? (after.system.busySeconds - before.system.busySeconds) / machineSeconds
|
|
135
|
+
: null;
|
|
136
|
+
const iowaitShare = before.system !== null && after.system !== null
|
|
137
|
+
? (after.system.iowaitSeconds - before.system.iowaitSeconds) / machineSeconds
|
|
138
|
+
: null;
|
|
139
|
+
return { elapsedSec, processShare, systemShare, iowaitShare };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* One reading of everything above, to be compared against the next.
|
|
144
|
+
*
|
|
145
|
+
* @param {number | null} pid - The encoder's process, when one is running.
|
|
146
|
+
* @returns {Promise<{ takenAt: number, processCpuSeconds: number | null, system: CpuTotals | null }>}
|
|
147
|
+
*/
|
|
148
|
+
export async function sampleHost(pid) {
|
|
149
|
+
const [processCpuSeconds, system] = await Promise.all([
|
|
150
|
+
pid === null ? Promise.resolve(null) : readProcessCpuSeconds(pid),
|
|
151
|
+
readSystemCpu()
|
|
152
|
+
]);
|
|
153
|
+
return { takenAt: Date.now(), processCpuSeconds, system };
|
|
154
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The arithmetic that turns two readings of the machine into shares.
|
|
3
|
+
*
|
|
4
|
+
* The readings themselves are files this host may or may not have; what is
|
|
5
|
+
* tested here is what is DERIVED from them, because that is where a wrong
|
|
6
|
+
* number would quietly become a wrong conclusion about why an encoder is slow.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
|
|
12
|
+
import { readProcessCpuSeconds, readSystemCpu, sampleHost, shareOfMachine } from "../services/host-load.js";
|
|
13
|
+
|
|
14
|
+
test("a process using one core of four for a second reports a quarter of the machine", () => {
|
|
15
|
+
const before = { takenAt: 1_000, processCpuSeconds: 10, system: null };
|
|
16
|
+
const after = { takenAt: 2_000, processCpuSeconds: 11, system: null };
|
|
17
|
+
const share = shareOfMachine(before, after, 4);
|
|
18
|
+
assert.equal(share?.elapsedSec, 1);
|
|
19
|
+
assert.equal(share?.processShare, 0.25);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("a process using every core reports the whole machine", () => {
|
|
23
|
+
const share = shareOfMachine(
|
|
24
|
+
{ takenAt: 0, processCpuSeconds: 0, system: null },
|
|
25
|
+
{ takenAt: 2_000, processCpuSeconds: 8, system: null },
|
|
26
|
+
4
|
|
27
|
+
);
|
|
28
|
+
assert.equal(share?.processShare, 1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("waiting for a disk is counted apart from working", () => {
|
|
32
|
+
const before = { takenAt: 0, processCpuSeconds: null, system: { busySeconds: 100, idleSeconds: 900, iowaitSeconds: 10 } };
|
|
33
|
+
const after = { takenAt: 1_000, processCpuSeconds: null, system: { busySeconds: 101, idleSeconds: 902, iowaitSeconds: 11 } };
|
|
34
|
+
const share = shareOfMachine(before, after, 4);
|
|
35
|
+
assert.equal(share?.systemShare, 0.25);
|
|
36
|
+
assert.equal(share?.iowaitShare, 0.25);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("two readings taken at the same instant say nothing rather than dividing by zero", () => {
|
|
40
|
+
assert.equal(shareOfMachine({ takenAt: 5, processCpuSeconds: 1, system: null }, { takenAt: 5, processCpuSeconds: 2, system: null }, 4), null);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("a missing reading leaves that share unknown, not zero", () => {
|
|
44
|
+
const share = shareOfMachine(
|
|
45
|
+
{ takenAt: 0, processCpuSeconds: null, system: null },
|
|
46
|
+
{ takenAt: 1_000, processCpuSeconds: 1, system: null },
|
|
47
|
+
4
|
|
48
|
+
);
|
|
49
|
+
assert.equal(share?.processShare, null);
|
|
50
|
+
assert.equal(share?.systemShare, null);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a process that does not exist reports nothing at all", async () => {
|
|
54
|
+
assert.equal(await readProcessCpuSeconds(0), null);
|
|
55
|
+
assert.equal(await readProcessCpuSeconds(-1), null);
|
|
56
|
+
// A pid far above any real one on this machine.
|
|
57
|
+
assert.equal(await readProcessCpuSeconds(4_000_000), null);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("on a host without /proc the readings are null and the sampler still answers", async () => {
|
|
61
|
+
// This runs on Linux in CI and on Windows here; both must be safe. What is
|
|
62
|
+
// asserted is the SHAPE — a reading is either a number or null, never a throw.
|
|
63
|
+
const system = await readSystemCpu();
|
|
64
|
+
assert.ok(system === null || typeof system.busySeconds === "number");
|
|
65
|
+
const sample = await sampleHost(null);
|
|
66
|
+
assert.equal(typeof sample.takenAt, "number");
|
|
67
|
+
assert.equal(sample.processCpuSeconds, null);
|
|
68
|
+
});
|