@torrent-tv/proxy 2.78.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 +23 -0
- package/bin/cli.js +20 -1
- package/biome.json +1 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/net-report/post.js +5 -0
- package/services/encode/EncodePlan.js +38 -12
- package/services/encode/EncodeRun.js +70 -0
- package/services/encode/SegmentStore.js +59 -0
- package/services/encode/open-piece.js +88 -0
- package/services/encode/run-budget.js +89 -0
- package/services/encode/run-command.js +15 -0
- package/services/encode/run-costs.js +94 -0
- package/services/hls-session-manager.js +99 -344
- package/services/orchestrators/EncodeOrchestrator.js +134 -17
- package/services/{encode/DemandMap.js → priority/PriorityMap.js} +99 -34
- package/services/usrsctp-state.js +35 -6
- package/services/viewer/Viewer.js +83 -0
- package/test/encode-orchestrator.test.js +36 -8
- package/test/encode-plan.test.js +67 -5
- package/test/one-authority.test.js +105 -0
- package/test/{demand-map.test.js → priority-map.test.js} +49 -12
- package/test/usrsctp-state.test.js +18 -2
package/test/encode-plan.test.js
CHANGED
|
@@ -14,7 +14,19 @@ import { endOfRun } from "../services/encode/EncodeRun.js";
|
|
|
14
14
|
import { firstUnmetWant, planEncoders } from "../services/encode/EncodePlan.js";
|
|
15
15
|
|
|
16
16
|
/** A host that can afford two encoders, four-second segments, a cheap restart. */
|
|
17
|
-
|
|
17
|
+
// A host that has measured itself: the start and the death from its own runs,
|
|
18
|
+
// and what the swarm charges to fetch a second of film again. All four terms
|
|
19
|
+
// have to be present for the drive-or-move comparison to mean anything, and a
|
|
20
|
+
// host missing any of them keeps its encoders instead — which is its own check
|
|
21
|
+
// below rather than the shape every other check is written against.
|
|
22
|
+
const HOST = {
|
|
23
|
+
maxRuns: 2,
|
|
24
|
+
segmentSeconds: 4,
|
|
25
|
+
restartCostSec: 0.12,
|
|
26
|
+
killCostSec: 0.5,
|
|
27
|
+
firstByteWaitSec: 1,
|
|
28
|
+
refetchSecPerFilmSecond: 0.25
|
|
29
|
+
};
|
|
18
30
|
|
|
19
31
|
/**
|
|
20
32
|
* @param {Partial<import("../services/encode/EncodePlan.js").LiveRun>} run
|
|
@@ -121,9 +133,13 @@ test("a covered stretch shorter than a restart is driven through instead", () =>
|
|
|
121
133
|
assert.ok(actions.some((action) => action.type === "keep" && action.run === runA));
|
|
122
134
|
});
|
|
123
135
|
|
|
124
|
-
test("a run whose speed nothing has measured is
|
|
125
|
-
//
|
|
126
|
-
//
|
|
136
|
+
test("a run whose speed nothing has measured is kept, not taken away", () => {
|
|
137
|
+
// Moving costs a known amount for an unknown gain, and a run nothing has
|
|
138
|
+
// measured has produced nothing yet — so taking its work away is certainly a
|
|
139
|
+
// loss and the comparison cannot be made. It used to answer the other way,
|
|
140
|
+
// and then every just-started run was moved the moment anything ahead of it
|
|
141
|
+
// was covered, which, once the film ahead had been made, was always: 684
|
|
142
|
+
// starts in 482 seconds in the field on 2026-09-05.
|
|
127
143
|
const coverage = new CoverageMap({ segmentCount: 100 });
|
|
128
144
|
const runA = run({ head: 10, speedX: 0 });
|
|
129
145
|
coverage.claim(runA, 0, 100);
|
|
@@ -134,9 +150,55 @@ test("a run whose speed nothing has measured is moved rather than left driving t
|
|
|
134
150
|
runs: [runA],
|
|
135
151
|
...HOST
|
|
136
152
|
});
|
|
153
|
+
assert.equal(actions.some((action) => action.type === "move"), false);
|
|
154
|
+
assert.ok(actions.some((action) => action.type === "keep" && action.run === runA));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("both sides of the move are counted, not just the encoder's own time", () => {
|
|
158
|
+
// Driving through costs this run's encode time AND the swarm the same bytes a
|
|
159
|
+
// second time; moving costs the death, the start and the wait for the first
|
|
160
|
+
// bytes. Here driving is dear enough to lose: 20 covered segments of 4 s at
|
|
161
|
+
// 1x is 80 s of encoding, against a move priced at 0.12 + 0.5 + 3 seconds.
|
|
162
|
+
const coverage = new CoverageMap({ segmentCount: 200 });
|
|
163
|
+
const runA = run({ head: 10, speedX: 1 });
|
|
164
|
+
coverage.claim(runA, 0, 200);
|
|
165
|
+
for (let at = 10; at < 30; at += 1) {
|
|
166
|
+
coverage.markReady(at);
|
|
167
|
+
}
|
|
168
|
+
const actions = planEncoders({
|
|
169
|
+
coverage,
|
|
170
|
+
windows: [{ from: 0, to: 190 }],
|
|
171
|
+
runs: [runA],
|
|
172
|
+
...HOST,
|
|
173
|
+
killCostSec: 0.5,
|
|
174
|
+
firstByteWaitSec: 3,
|
|
175
|
+
refetchSecPerFilmSecond: 0.25
|
|
176
|
+
});
|
|
137
177
|
const move = actions.find((action) => action.type === "move");
|
|
138
178
|
assert.ok(move);
|
|
139
|
-
assert.match(move.because, /
|
|
179
|
+
assert.match(move.because, /refetch 20\.00s/);
|
|
180
|
+
assert.match(move.because, /against 3\.62s to move/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a short covered stretch is driven through rather than paid a restart for", () => {
|
|
184
|
+
// One covered segment at 1x is 4 s of encoding plus 1 s of refetch, against a
|
|
185
|
+
// move priced at 0.12 + 0.5 + 30 seconds on a host where the first bytes are
|
|
186
|
+
// slow to come. The comparison, not a rule, decides it.
|
|
187
|
+
const coverage = new CoverageMap({ segmentCount: 200 });
|
|
188
|
+
const runA = run({ head: 10, speedX: 1 });
|
|
189
|
+
coverage.claim(runA, 0, 200);
|
|
190
|
+
coverage.markReady(10);
|
|
191
|
+
const actions = planEncoders({
|
|
192
|
+
coverage,
|
|
193
|
+
windows: [{ from: 0, to: 190 }],
|
|
194
|
+
runs: [runA],
|
|
195
|
+
...HOST,
|
|
196
|
+
killCostSec: 0.5,
|
|
197
|
+
firstByteWaitSec: 30,
|
|
198
|
+
refetchSecPerFilmSecond: 0.25
|
|
199
|
+
});
|
|
200
|
+
assert.equal(actions.some((action) => action.type === "move"), false);
|
|
201
|
+
assert.ok(actions.some((action) => action.type === "keep" && action.run === runA));
|
|
140
202
|
});
|
|
141
203
|
|
|
142
204
|
test("a run with nothing left to make ahead of it is stopped", () => {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file One place decides which encoders exist.
|
|
3
|
+
*
|
|
4
|
+
* Not a check of behaviour but of shape, and it is here because the shape is
|
|
5
|
+
* what failed. Three separate places used to decide where an encoder should
|
|
6
|
+
* work and whether it should go on living, and they disagreed on every pass:
|
|
7
|
+
* measured in the field on 2026-09-05, 684 starts and 660 stops in 482 seconds,
|
|
8
|
+
* of which 294 were one place killing what another had just decided to keep,
|
|
9
|
+
* while the viewer's own segment went unmade for 32.3 seconds.
|
|
10
|
+
*
|
|
11
|
+
* Every rule below is one that was broken then. A reader who needs to add a
|
|
12
|
+
* fourth place should read this file first and then not.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} relative
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
function source(relative) {
|
|
28
|
+
return readFileSync(path.join(HERE, "..", relative), "utf8");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Lines of code, without comments or blanks: a rule about what the code does
|
|
33
|
+
* must not be answered by what a comment says about it.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} text
|
|
36
|
+
* @returns {string[]}
|
|
37
|
+
*/
|
|
38
|
+
function statements(text) {
|
|
39
|
+
return text
|
|
40
|
+
.split("\n")
|
|
41
|
+
.map((line) => line.trim())
|
|
42
|
+
.filter((line) => line.length > 0 && !line.startsWith("//") && !line.startsWith("*") && !line.startsWith("/*"));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
test("an encoder is stopped for scheduling reasons in exactly one place", () => {
|
|
46
|
+
// The orchestrator decides; nobody else may. What is left in the session
|
|
47
|
+
// manager is teardown — the session is going away and its encoders with it —
|
|
48
|
+
// which is not a decision about which encoders should exist.
|
|
49
|
+
const orchestrator = statements(source("services/orchestrators/EncodeOrchestrator.js"));
|
|
50
|
+
const stopsInOrchestrator = orchestrator.filter((line) => line.includes("run.stop("));
|
|
51
|
+
assert.equal(stopsInOrchestrator.length, 1, "the orchestrator stops runs in one place");
|
|
52
|
+
|
|
53
|
+
const manager = statements(source("services/hls-session-manager.js"));
|
|
54
|
+
const stopsInManager = manager.filter((line) => line.includes(".stop("));
|
|
55
|
+
assert.equal(
|
|
56
|
+
stopsInManager.length,
|
|
57
|
+
2,
|
|
58
|
+
"the session manager stops runs only when a session is torn down: " +
|
|
59
|
+
stopsInManager.join(" / ")
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("nothing outside the encoding layer starts an encoder", () => {
|
|
64
|
+
// A run is built in one place. Two places building them is how a start came
|
|
65
|
+
// to kill what the plan had decided to keep — the killing lived in the
|
|
66
|
+
// building.
|
|
67
|
+
const manager = statements(source("services/hls-session-manager.js"));
|
|
68
|
+
const builds = manager.filter((line) => line.includes("new EncodeRun("));
|
|
69
|
+
assert.equal(builds.length, 1, "one place builds an encoder");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("starting an encoder stops nothing", () => {
|
|
73
|
+
// The rule that broke it: the start path looked for a live run whose own
|
|
74
|
+
// start was not below the new one's and killed it. It is not enough that the
|
|
75
|
+
// line is gone — the words it was written with must not come back.
|
|
76
|
+
const manager = source("services/hls-session-manager.js");
|
|
77
|
+
assert.equal(
|
|
78
|
+
manager.includes("previousRun"),
|
|
79
|
+
false,
|
|
80
|
+
"there is no such thing as the previous run: a session holds several"
|
|
81
|
+
);
|
|
82
|
+
assert.equal(manager.includes("a new run is taking its place"), false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a seek moves the viewer and nothing else", () => {
|
|
86
|
+
// It used to do eleven things and write the position into five places. What
|
|
87
|
+
// follows from a viewer moving is the map's business, and the orchestrators
|
|
88
|
+
// read the map.
|
|
89
|
+
const manager = source("services/hls-session-manager.js");
|
|
90
|
+
const seek = manager.slice(
|
|
91
|
+
manager.indexOf("requestSeek(sessionId, positionSeconds"),
|
|
92
|
+
manager.indexOf("requestSeek(sessionId, positionSeconds") + 2000
|
|
93
|
+
);
|
|
94
|
+
const body = seek.slice(0, seek.indexOf("\n }\n"));
|
|
95
|
+
assert.equal(body.includes("#startEncodeRun"), false, "a seek starts no encoder");
|
|
96
|
+
assert.equal(body.includes("setTimeout"), false, "and waits for nothing");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("how far an encoder may work is answered once", () => {
|
|
100
|
+
// The plan computes the stretch and it reaches ffmpeg. A second computation
|
|
101
|
+
// somewhere else is what made the first one pointless: it was passed and then
|
|
102
|
+
// dropped by a parameter list that did not name it.
|
|
103
|
+
const orchestrator = source("services/orchestrators/EncodeOrchestrator.js");
|
|
104
|
+
assert.ok(orchestrator.includes("makeRun({ address, from, to })"), "the stretch is handed over");
|
|
105
|
+
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import test from "node:test";
|
|
10
10
|
import assert from "node:assert/strict";
|
|
11
|
-
import { mapForViewer, mergeMaps, inWorkingOrder } from "../services/
|
|
11
|
+
import { mapForViewer, mergeMaps, inWorkingOrder } from "../services/priority/PriorityMap.js";
|
|
12
12
|
|
|
13
13
|
test("a viewer's own position is the most urgent thing there is", () => {
|
|
14
14
|
const map = mapForViewer({
|
|
@@ -25,23 +25,60 @@ test("a viewer's own position is the most urgent thing there is", () => {
|
|
|
25
25
|
}
|
|
26
26
|
});
|
|
27
27
|
|
|
28
|
-
test("a machine that cannot keep up
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
const
|
|
28
|
+
test("a machine that cannot keep up needs more encoders, not a longer first zone", () => {
|
|
29
|
+
// An encoder starting where the viewer stands stays ahead of them for
|
|
30
|
+
// `held x s / (1 - s)`, so the SLOWER the machine the shorter that is — and
|
|
31
|
+
// the film is held by more encoders rather than by one working further.
|
|
32
|
+
const slow = mapForViewer({
|
|
33
|
+
atSeconds: 0,
|
|
34
|
+
durationSeconds: 1000,
|
|
35
|
+
allowanceSeconds: 10,
|
|
36
|
+
encodeSpeedX: 0.25
|
|
37
|
+
});
|
|
38
|
+
const quicker = mapForViewer({
|
|
39
|
+
atSeconds: 0,
|
|
40
|
+
durationSeconds: 1000,
|
|
41
|
+
allowanceSeconds: 10,
|
|
42
|
+
encodeSpeedX: 0.5
|
|
43
|
+
});
|
|
33
44
|
|
|
34
|
-
assert.equal(slow[0].to, 775, "100 + 900 x 0.75");
|
|
35
45
|
assert.ok(
|
|
36
|
-
slow[0].to - slow[0].from
|
|
37
|
-
"the slower
|
|
46
|
+
slow[0].to - slow[0].from < quicker[0].to - quicker[0].from,
|
|
47
|
+
"the slower machine holds the viewer for less"
|
|
38
48
|
);
|
|
49
|
+
assert.ok(slow.length > quicker.length, "so more encoders are needed to hold the film");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("the first zone is exactly what one encoder can hold", () => {
|
|
53
|
+
// `held x s / (1 - s)`: ten seconds held at half speed is ten seconds held,
|
|
54
|
+
// and the encoder is caught exactly there. A longer first zone would stall
|
|
55
|
+
// the viewer inside it; a shorter one would start the next encoder nearer,
|
|
56
|
+
// where its own bound is tighter.
|
|
57
|
+
const map = mapForViewer({
|
|
58
|
+
atSeconds: 0,
|
|
59
|
+
durationSeconds: 100,
|
|
60
|
+
allowanceSeconds: 10,
|
|
61
|
+
encodeSpeedX: 0.5
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
assert.equal(map[0].to, 10);
|
|
65
|
+
assert.equal(map[1].to, 30, "the next holds three times as long, because they arrive later");
|
|
66
|
+
assert.equal(map[2].to, 70);
|
|
39
67
|
});
|
|
40
68
|
|
|
41
|
-
test("
|
|
42
|
-
|
|
69
|
+
test("a viewer holding nothing is told the whole of it is urgent", () => {
|
|
70
|
+
// With nothing held, no partition holds them: the first encoder stays ahead
|
|
71
|
+
// for zero seconds. Slicing the film into equal slivers would pretend
|
|
72
|
+
// otherwise.
|
|
73
|
+
const map = mapForViewer({
|
|
74
|
+
atSeconds: 0,
|
|
75
|
+
durationSeconds: 1000,
|
|
76
|
+
allowanceSeconds: 0,
|
|
77
|
+
encodeSpeedX: 0.25
|
|
78
|
+
});
|
|
43
79
|
|
|
44
|
-
assert.equal(map
|
|
80
|
+
assert.equal(map.length, 1);
|
|
81
|
+
assert.equal(map[0].to, 1000);
|
|
45
82
|
});
|
|
46
83
|
|
|
47
84
|
test("the rest of the track is still wanted, and wanted last", () => {
|
|
@@ -62,7 +62,7 @@ async function waitFor(check) {
|
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
test("
|
|
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
|
-
|
|
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
|
});
|