@torrent-tv/proxy 2.76.6 → 2.78.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 +1558 -1538
- package/bin/cli.js +14 -1
- package/package.json +1 -1
- package/services/data-channel-handler.js +83 -3
- package/services/encode/DemandMap.js +187 -0
- package/services/encode/EncodePlan.js +283 -272
- package/services/encode/SegmentDemand.js +0 -0
- package/services/hls-session-manager.js +476 -179
- package/services/orchestrators/EncodeOrchestrator.js +13 -3
- package/services/viewer/Viewer.js +214 -145
- package/services/viewer/Viewers.js +213 -124
- package/test/behind-head-repair.test.js +2 -2
- package/test/demand-map.test.js +102 -0
- package/test/encode-plan.test.js +44 -2
- package/test/failed-start-breaker.test.js +151 -0
- package/test/orchestrator-wired.test.js +190 -164
- package/test/quality-variants.test.js +2 -2
- package/test/run-intervals.test.js +67 -303
- package/test/seek-target-not-superseded.test.js +2 -2
- package/test/sidecar-naming.test.js +120 -120
- package/test/stale-request-after-seek.test.js +2 -2
- package/test/two-viewers-one-picture.test.js +2 -2
- package/test/viewer-outputs.test.js +7 -7
- package/test/viewer-presence.test.js +119 -0
- package/test/viewer.test.js +27 -10
|
@@ -1,124 +1,213 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Every viewer this proxy has met, one object per person.
|
|
3
|
-
*
|
|
4
|
-
* A viewer used to be made per SESSION: the same person watching a picture, a
|
|
5
|
-
* quality step and a soundtrack was three objects, each with its own copy of
|
|
6
|
-
* what that person had chosen and where they were. Two of those copies were
|
|
7
|
-
* always wrong, and the field that says which outputs a person is watching was
|
|
8
|
-
* worse than wrong — being per session, each copy could only ever hold the id
|
|
9
|
-
* of the session that owned it, so it carried no information at all and the one
|
|
10
|
-
* place that read it could learn nothing from it.
|
|
11
|
-
*
|
|
12
|
-
* One person is one object here, keyed by the consumer id the browser sends.
|
|
13
|
-
* That id is minted once per film opened in the page, so one id is one person
|
|
14
|
-
* watching one film, and sharing the object cannot conflate two films.
|
|
15
|
-
*
|
|
16
|
-
* The relation "this person watches this output" is indexed both ways — the
|
|
17
|
-
* output holds its viewers, the viewer holds its outputs — because it is asked
|
|
18
|
-
* from both ends, and both indexes are written here and nowhere else.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @file Every viewer this proxy has met, one object per person.
|
|
3
|
+
*
|
|
4
|
+
* A viewer used to be made per SESSION: the same person watching a picture, a
|
|
5
|
+
* quality step and a soundtrack was three objects, each with its own copy of
|
|
6
|
+
* what that person had chosen and where they were. Two of those copies were
|
|
7
|
+
* always wrong, and the field that says which outputs a person is watching was
|
|
8
|
+
* worse than wrong — being per session, each copy could only ever hold the id
|
|
9
|
+
* of the session that owned it, so it carried no information at all and the one
|
|
10
|
+
* place that read it could learn nothing from it.
|
|
11
|
+
*
|
|
12
|
+
* One person is one object here, keyed by the consumer id the browser sends.
|
|
13
|
+
* That id is minted once per film opened in the page, so one id is one person
|
|
14
|
+
* watching one film, and sharing the object cannot conflate two films.
|
|
15
|
+
*
|
|
16
|
+
* The relation "this person watches this output" is indexed both ways — the
|
|
17
|
+
* output holds its viewers, the viewer holds its outputs — because it is asked
|
|
18
|
+
* from both ends, and both indexes are written here and nowhere else.
|
|
19
|
+
*
|
|
20
|
+
* **Every change here announces itself.** What encoders should exist is decided
|
|
21
|
+
* from what viewers want, so a viewer arriving, moving or leaving is a change
|
|
22
|
+
* to that decision's input. Until 2026-09-05 the decision was instead re-taken
|
|
23
|
+
* on a five-second timer, which meant a newly created output waited up to five
|
|
24
|
+
* seconds for anybody to notice it had a viewer at all — and the timer's period
|
|
25
|
+
* was also the period of a restart loop that ran for sixteen minutes. The
|
|
26
|
+
* registry does not know what to do about a change; it only says that one
|
|
27
|
+
* happened.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { Viewer, viewersOf } from "./Viewer.js";
|
|
31
|
+
|
|
32
|
+
export class Viewers {
|
|
33
|
+
/**
|
|
34
|
+
* One object per named viewer. An unnamed one is not in here: a viewer that
|
|
35
|
+
* cannot say who it is is not the same viewer as another that cannot, so it
|
|
36
|
+
* belongs to the session that met it and to no one else.
|
|
37
|
+
*
|
|
38
|
+
* @type {Map<string, Viewer>}
|
|
39
|
+
*/
|
|
40
|
+
#byId = new Map();
|
|
41
|
+
|
|
42
|
+
/** @type {() => void} */
|
|
43
|
+
#onChange;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {object} [params]
|
|
47
|
+
* @param {() => void} [params.onChange] - Called after the relation changes:
|
|
48
|
+
* a viewer joined an output, or left one. Says only that something moved.
|
|
49
|
+
*/
|
|
50
|
+
constructor({ onChange } = {}) {
|
|
51
|
+
this.#onChange = typeof onChange === "function" ? onChange : () => {};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* This viewer, watching this output.
|
|
56
|
+
*
|
|
57
|
+
* Both directions of the relation are written here. Asking for a viewer of an
|
|
58
|
+
* output IS the statement that they are watching it: every caller either
|
|
59
|
+
* records where they are, what they chose, or what is being prepared for
|
|
60
|
+
* them, and each of those is only true of somebody watching. It is also
|
|
61
|
+
* evidence that they are still there, so it refreshes presence.
|
|
62
|
+
*
|
|
63
|
+
* @param {object} output - A session.
|
|
64
|
+
* @param {string} consumerId
|
|
65
|
+
* @param {number} [now]
|
|
66
|
+
* @returns {Viewer}
|
|
67
|
+
*/
|
|
68
|
+
of(output, consumerId, now = Date.now()) {
|
|
69
|
+
const viewers = viewersOf(output);
|
|
70
|
+
const known = viewers.get(consumerId);
|
|
71
|
+
if (known) {
|
|
72
|
+
known.seen(now);
|
|
73
|
+
// Asking again is not a return from the dead, but it IS evidence, and a
|
|
74
|
+
// viewer marked gone whose id turns up again is a viewer who came back.
|
|
75
|
+
known.gone = false;
|
|
76
|
+
const wasWatching = known.outputs.has(output.id);
|
|
77
|
+
known.outputs.add(output.id);
|
|
78
|
+
if (!wasWatching) {
|
|
79
|
+
this.#onChange();
|
|
80
|
+
}
|
|
81
|
+
return known;
|
|
82
|
+
}
|
|
83
|
+
const viewer = consumerId
|
|
84
|
+
? this.#byId.get(consumerId) ?? new Viewer(consumerId, now)
|
|
85
|
+
: new Viewer("", now);
|
|
86
|
+
viewer.seen(now);
|
|
87
|
+
viewer.gone = false;
|
|
88
|
+
if (consumerId) {
|
|
89
|
+
this.#byId.set(consumerId, viewer);
|
|
90
|
+
}
|
|
91
|
+
viewers.set(consumerId, viewer);
|
|
92
|
+
viewer.outputs.add(output.id);
|
|
93
|
+
this.#onChange();
|
|
94
|
+
return viewer;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The viewer with this id, or null when nobody by that name is watching
|
|
99
|
+
* anything. Never makes one.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} consumerId
|
|
102
|
+
* @returns {Viewer | null}
|
|
103
|
+
*/
|
|
104
|
+
get(consumerId) {
|
|
105
|
+
return this.#byId.get(consumerId) ?? null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The outputs this viewer is watching, as ids, copied so that leaving them
|
|
110
|
+
* can be walked without mutating what is being walked.
|
|
111
|
+
*
|
|
112
|
+
* @param {object} anyOutput - A session they are known to, for an unnamed
|
|
113
|
+
* viewer whose record lives on that session alone.
|
|
114
|
+
* @param {string} consumerId
|
|
115
|
+
* @returns {string[]}
|
|
116
|
+
*/
|
|
117
|
+
watching(anyOutput, consumerId) {
|
|
118
|
+
const viewer = consumerId
|
|
119
|
+
? this.#byId.get(consumerId)
|
|
120
|
+
: viewersOf(anyOutput).get("");
|
|
121
|
+
return viewer ? [...viewer.outputs] : [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* This viewer is no longer watching this output.
|
|
126
|
+
*
|
|
127
|
+
* Both directions again, and the viewer itself is forgotten once it is
|
|
128
|
+
* watching nothing — otherwise the registry would be a map that only grows,
|
|
129
|
+
* which is the shape of half the memory faults recorded in this repository.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} output - A session.
|
|
132
|
+
* @param {string} consumerId
|
|
133
|
+
* @returns {boolean} Whether they were watching it.
|
|
134
|
+
*/
|
|
135
|
+
leaves(output, consumerId) {
|
|
136
|
+
const viewers = viewersOf(output);
|
|
137
|
+
const viewer = viewers.get(consumerId);
|
|
138
|
+
if (!viewer) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
viewers.delete(consumerId);
|
|
142
|
+
viewer.outputs.delete(output.id);
|
|
143
|
+
if (viewer.outputs.size === 0) {
|
|
144
|
+
// Watching nothing at all: this is a statement that they are gone, and
|
|
145
|
+
// not merely that this one output is no longer theirs.
|
|
146
|
+
viewer.gone = true;
|
|
147
|
+
if (consumerId) {
|
|
148
|
+
this.#byId.delete(consumerId);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
this.#onChange();
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Everything this viewer is watching, let go of at once, because their
|
|
157
|
+
* connection said they are gone.
|
|
158
|
+
*
|
|
159
|
+
* The transport knows a viewer has left before any output does, and it knows
|
|
160
|
+
* it about the PERSON rather than about one of the three outputs the browser
|
|
161
|
+
* happens to hold an id for. This is the door that fact comes through.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} consumerId
|
|
164
|
+
* @param {(outputId: string) => object | null} outputById - How to find an
|
|
165
|
+
* output by id. The registry holds ids, not sessions.
|
|
166
|
+
* @returns {string[]} The outputs they were watching.
|
|
167
|
+
*/
|
|
168
|
+
hasGone(consumerId, outputById) {
|
|
169
|
+
const viewer = consumerId ? this.#byId.get(consumerId) : null;
|
|
170
|
+
if (!viewer) {
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
const left = [...viewer.outputs];
|
|
174
|
+
for (const outputId of left) {
|
|
175
|
+
const output = typeof outputById === "function" ? outputById(outputId) : null;
|
|
176
|
+
if (output) {
|
|
177
|
+
viewersOf(output).delete(consumerId);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
viewer.outputs.clear();
|
|
181
|
+
viewer.gone = true;
|
|
182
|
+
this.#byId.delete(consumerId);
|
|
183
|
+
this.#onChange();
|
|
184
|
+
return left;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Note that this viewer has been heard from, wherever the evidence came from
|
|
189
|
+
* — a request, a link report, an echo of a delivery probe.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} consumerId
|
|
192
|
+
* @param {number} [now]
|
|
193
|
+
* @returns {boolean} Whether anybody by that name is known.
|
|
194
|
+
*/
|
|
195
|
+
seen(consumerId, now = Date.now()) {
|
|
196
|
+
const viewer = consumerId ? this.#byId.get(consumerId) : null;
|
|
197
|
+
if (!viewer) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
viewer.seen(now);
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* How many named viewers are watching anything. For the log line and for a
|
|
206
|
+
* check that the registry does not grow.
|
|
207
|
+
*
|
|
208
|
+
* @returns {number}
|
|
209
|
+
*/
|
|
210
|
+
get size() {
|
|
211
|
+
return this.#byId.size;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -66,8 +66,8 @@ async function managerWithRunAhead() {
|
|
|
66
66
|
useSyntheticPlaylist: true,
|
|
67
67
|
playlistText: "#EXTM3U\n",
|
|
68
68
|
lastRestartAt: 0,
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
failedStartAt: -1,
|
|
70
|
+
failedStartCount: 0,
|
|
71
71
|
seekSettleTimer: null,
|
|
72
72
|
seekTarget: null,
|
|
73
73
|
waitEpoch: 0,
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A map per viewer, merged into one, in the order the work is taken.
|
|
3
|
+
*
|
|
4
|
+
* In seconds of film throughout: it is the only unit every term of the
|
|
5
|
+
* arithmetic is already stated in, and the one two consumers with different
|
|
6
|
+
* states can both be translated from.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import test from "node:test";
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import { mapForViewer, mergeMaps, inWorkingOrder } from "../services/encode/DemandMap.js";
|
|
12
|
+
|
|
13
|
+
test("a viewer's own position is the most urgent thing there is", () => {
|
|
14
|
+
const map = mapForViewer({
|
|
15
|
+
atSeconds: 100,
|
|
16
|
+
durationSeconds: 1000,
|
|
17
|
+
allowanceSeconds: 8,
|
|
18
|
+
encodeSpeedX: 2
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
assert.equal(map[0].from, 100);
|
|
22
|
+
assert.equal(map[0].to, 108, "above realtime, only the measured allowance");
|
|
23
|
+
for (const zone of map.slice(1)) {
|
|
24
|
+
assert.ok(zone.priority < map[0].priority);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a machine that cannot keep up must have more ready before the viewer sets off", () => {
|
|
29
|
+
// 900s of film in front. At 0.25x the encoder loses three seconds of film per
|
|
30
|
+
// second played, so 675s must exist first, or the viewer stalls partway.
|
|
31
|
+
const slow = mapForViewer({ atSeconds: 100, durationSeconds: 1000, allowanceSeconds: 0, encodeSpeedX: 0.25 });
|
|
32
|
+
const fast = mapForViewer({ atSeconds: 100, durationSeconds: 1000, allowanceSeconds: 0, encodeSpeedX: 2 });
|
|
33
|
+
|
|
34
|
+
assert.equal(slow[0].to, 775, "100 + 900 x 0.75");
|
|
35
|
+
assert.ok(
|
|
36
|
+
slow[0].to - slow[0].from > fast[0].to - fast[0].from,
|
|
37
|
+
"the slower the machine, the more of the film must be made in advance"
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the allowance is added to the shortfall, not chosen instead of it", () => {
|
|
42
|
+
const map = mapForViewer({ atSeconds: 0, durationSeconds: 100, allowanceSeconds: 10, encodeSpeedX: 0.5 });
|
|
43
|
+
|
|
44
|
+
assert.equal(map[0].to, 60, "50s of shortfall plus 10s of measured allowance");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("the rest of the track is still wanted, and wanted last", () => {
|
|
48
|
+
const map = mapForViewer({ atSeconds: 0, durationSeconds: 1000, allowanceSeconds: 4, encodeSpeedX: 2 });
|
|
49
|
+
|
|
50
|
+
assert.equal(map[map.length - 1].to, 1000, "the map reaches the end of the film");
|
|
51
|
+
assert.ok(map[map.length - 1].priority > 0, "and the far end is still wanted");
|
|
52
|
+
let previousEnd = 0;
|
|
53
|
+
for (const zone of map) {
|
|
54
|
+
assert.equal(zone.from, previousEnd, "no gaps and no overlaps");
|
|
55
|
+
previousEnd = zone.to;
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("nothing is measured yet: the middle zone is left out rather than invented", () => {
|
|
60
|
+
const map = mapForViewer({ atSeconds: 0, durationSeconds: 100, allowanceSeconds: 4, encodeSpeedX: 0 });
|
|
61
|
+
|
|
62
|
+
assert.equal(map.length, 2, "what must be ready, and the rest");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("two viewers merge to the highest priority per second, with no overlaps", () => {
|
|
66
|
+
const first = mapForViewer({ atSeconds: 0, durationSeconds: 1000, allowanceSeconds: 8, encodeSpeedX: 2 });
|
|
67
|
+
const second = mapForViewer({ atSeconds: 500, durationSeconds: 1000, allowanceSeconds: 8, encodeSpeedX: 2 });
|
|
68
|
+
|
|
69
|
+
const merged = mergeMaps([first, second]);
|
|
70
|
+
|
|
71
|
+
let previousEnd = merged[0].from;
|
|
72
|
+
for (const zone of merged) {
|
|
73
|
+
assert.equal(zone.from, previousEnd, "no gaps and no overlaps");
|
|
74
|
+
previousEnd = zone.to;
|
|
75
|
+
}
|
|
76
|
+
const priorityAt = (at) => merged.find((zone) => at >= zone.from && at < zone.to)?.priority;
|
|
77
|
+
assert.equal(priorityAt(0), priorityAt(500), "both viewers' own positions are equally urgent");
|
|
78
|
+
assert.ok(priorityAt(500) > priorityAt(300), "a viewer at 500 outranks the far zone of the one at 0");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("the second viewer's position is not buried under the first viewer's far zone", () => {
|
|
82
|
+
// The case that used to leave a viewer opening the same film further in with
|
|
83
|
+
// no encoder at all, because the first run claimed everything in front of it.
|
|
84
|
+
const first = mapForViewer({ atSeconds: 0, durationSeconds: 4000, allowanceSeconds: 8, encodeSpeedX: 2 });
|
|
85
|
+
const second = mapForViewer({ atSeconds: 2000, durationSeconds: 4000, allowanceSeconds: 8, encodeSpeedX: 2 });
|
|
86
|
+
|
|
87
|
+
const order = inWorkingOrder(mergeMaps([first, second]));
|
|
88
|
+
const firstTwo = order.slice(0, 2).map((zone) => zone.from);
|
|
89
|
+
|
|
90
|
+
assert.ok(firstTwo.includes(0), "the first viewer's own position is taken first");
|
|
91
|
+
assert.ok(firstTwo.includes(2000), "so is the second viewer's, before anything less urgent");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("within one priority the earliest film goes first — that is where somebody is stopped", () => {
|
|
95
|
+
const order = inWorkingOrder([
|
|
96
|
+
{ from: 900, to: 1000, priority: 2 },
|
|
97
|
+
{ from: 100, to: 200, priority: 2 },
|
|
98
|
+
{ from: 0, to: 10, priority: 3 }
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
assert.deepEqual(order.map((zone) => zone.from), [0, 100, 900]);
|
|
102
|
+
});
|
package/test/encode-plan.test.js
CHANGED
|
@@ -176,7 +176,12 @@ test("every encoder stops when nobody is watching the output", () => {
|
|
|
176
176
|
);
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
-
test("a run
|
|
179
|
+
test("a run standing outside every window keeps working: the file is encoded whole", () => {
|
|
180
|
+
// The rule, stated by the user 2026-09-05: while a file is being encoded it
|
|
181
|
+
// is encoded whole, and a viewer decides the ORDER, not whether a run may
|
|
182
|
+
// live. Stopping a run for standing outside a window is what produced the
|
|
183
|
+
// field oscillation of that day — placed by one rule, killed by another,
|
|
184
|
+
// 350-700ms per cycle, nothing ever produced.
|
|
180
185
|
const coverage = new CoverageMap({ segmentCount: 1000 });
|
|
181
186
|
const runA = run({ from: 500, to: 600, head: 520 });
|
|
182
187
|
coverage.claim(runA, 500, 600);
|
|
@@ -186,7 +191,44 @@ test("a run making material nobody asked for is stopped", () => {
|
|
|
186
191
|
runs: [runA],
|
|
187
192
|
...HOST
|
|
188
193
|
});
|
|
189
|
-
assert.ok(
|
|
194
|
+
assert.ok(
|
|
195
|
+
!actions.some((action) => action.type === "stop" && action.run === runA),
|
|
196
|
+
"it is making film that will be wanted, and nothing else is making it"
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("the same plan run twice on an unchanged state gives the same answer", () => {
|
|
201
|
+
// What the oscillation actually was: two passes over one state disagreeing
|
|
202
|
+
// with each other. Nothing about the state changes between them here.
|
|
203
|
+
const coverage = new CoverageMap({ segmentCount: 1000 });
|
|
204
|
+
const runA = run({ from: 500, to: 600, head: 520 });
|
|
205
|
+
coverage.claim(runA, 500, 600);
|
|
206
|
+
const input = { coverage, windows: [{ from: 0, to: 40 }], runs: [runA], ...HOST };
|
|
207
|
+
|
|
208
|
+
const first = planEncoders(input).map((action) => action.type);
|
|
209
|
+
const second = planEncoders(input).map((action) => action.type);
|
|
210
|
+
|
|
211
|
+
assert.deepEqual(first, second);
|
|
212
|
+
assert.ok(!first.includes("stop"), "and neither pass kills what the other would start");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a viewer's most urgent zone is filled before a less urgent one", () => {
|
|
216
|
+
const coverage = new CoverageMap({ segmentCount: 1000 });
|
|
217
|
+
const actions = planEncoders({
|
|
218
|
+
coverage,
|
|
219
|
+
// The far zone is lower in number and lower in priority: the order must
|
|
220
|
+
// come from the priority, not from the number.
|
|
221
|
+
windows: [
|
|
222
|
+
{ from: 0, to: 100, priority: 1 },
|
|
223
|
+
{ from: 500, to: 530, priority: 3 }
|
|
224
|
+
],
|
|
225
|
+
runs: [],
|
|
226
|
+
...HOST,
|
|
227
|
+
maxRuns: 1
|
|
228
|
+
});
|
|
229
|
+
const started = actions.filter((action) => action.type === "start").map((action) => action.from);
|
|
230
|
+
|
|
231
|
+
assert.deepEqual(started, [500], "the one machine goes where somebody is stopped");
|
|
190
232
|
});
|
|
191
233
|
|
|
192
234
|
test("two viewers far apart get an encoder each, when the machine can hold two", () => {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file A start that cannot succeed must not be commanded for ever.
|
|
3
|
+
*
|
|
4
|
+
* Two defects met here on 2026-09-05, and only together did they produce an
|
|
5
|
+
* unbounded loop.
|
|
6
|
+
*
|
|
7
|
+
* The first is older than the second and had never once run: `#onRunEnded`
|
|
8
|
+
* removed the run from the session and THEN asked whether the run was still the
|
|
9
|
+
* session's, a question that always answers "no" after the removal. Everything
|
|
10
|
+
* below that point was unreachable — the fallback from a failed hardware
|
|
11
|
+
* encoder to software, the retry when the torrent data goes away, the limit on
|
|
12
|
+
* retrying a position that keeps failing, and the error line naming the ffmpeg
|
|
13
|
+
* command. Measured over both of the field host's log files: zero occurrences
|
|
14
|
+
* of that error line and zero of `fast failure at segment`, across every
|
|
15
|
+
* session that proxy had ever run.
|
|
16
|
+
*
|
|
17
|
+
* The second is that the limit only counted past segment #0, because it was
|
|
18
|
+
* written for seek restarts and a seek is never to the beginning — leaving the
|
|
19
|
+
* one position the plan commands first with no count at all.
|
|
20
|
+
*
|
|
21
|
+
* With what encoders should exist re-decided on a five-second timer, the two
|
|
22
|
+
* showed up as a restart every five seconds, for sixteen minutes, in the field.
|
|
23
|
+
* Re-decided the moment its inputs change, they show up as a loop as fast as
|
|
24
|
+
* spawning can fail: fifty passes of the plan before a probe stopped it.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import test from "node:test";
|
|
28
|
+
import assert from "node:assert/strict";
|
|
29
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
30
|
+
import os from "node:os";
|
|
31
|
+
import path from "node:path";
|
|
32
|
+
import { HlsSessionManager } from "../services/hls-session-manager.js";
|
|
33
|
+
import { SourceFile } from "../services/source/SourceFile.js";
|
|
34
|
+
import { ENCODE_EXIT } from "../services/encode/encode-exit.js";
|
|
35
|
+
|
|
36
|
+
const SESSION_ID = "cccccccc-0000-4000-8000-000000000001";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A session with one run in it, shaped as the manager expects.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} dirPath
|
|
42
|
+
* @returns {{ session: object, run: object }}
|
|
43
|
+
*/
|
|
44
|
+
function sessionWithARun(dirPath) {
|
|
45
|
+
const run = { from: 0, to: -1, argsDescribed: "ffmpeg …", state: "ENDED_FAILED" };
|
|
46
|
+
const session = {
|
|
47
|
+
id: SESSION_ID,
|
|
48
|
+
dirPath,
|
|
49
|
+
state: "live",
|
|
50
|
+
file: new SourceFile({ sourceKey: "source-1", fileIndex: 0, name: "video.mkv" }),
|
|
51
|
+
get inputFile() { return this.file; },
|
|
52
|
+
get audioFile() { return this.file; },
|
|
53
|
+
outputKey: "output-under-test",
|
|
54
|
+
consumers: new Set(["someone"]),
|
|
55
|
+
viewers: new Map(),
|
|
56
|
+
runs: new Set([run]),
|
|
57
|
+
transcodeVideo: false,
|
|
58
|
+
failedStartAt: -1,
|
|
59
|
+
failedStartCount: 0,
|
|
60
|
+
lastError: "",
|
|
61
|
+
lastRequestedSegment: 0,
|
|
62
|
+
progress: { updatedAt: 0, processedSeconds: 0, totalSeconds: 100, startPositionSeconds: 0 },
|
|
63
|
+
lastAccessedAt: Date.now()
|
|
64
|
+
};
|
|
65
|
+
return { session, run };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {object} run
|
|
70
|
+
* @param {number} from
|
|
71
|
+
* @returns {object}
|
|
72
|
+
*/
|
|
73
|
+
function failedFast(run, from) {
|
|
74
|
+
return {
|
|
75
|
+
address: "output-under-test",
|
|
76
|
+
run,
|
|
77
|
+
ending: ENCODE_EXIT.FAILED,
|
|
78
|
+
from,
|
|
79
|
+
to: -1,
|
|
80
|
+
livedMs: 20,
|
|
81
|
+
because: "the process could not be started: spawn ffmpeg ENOENT",
|
|
82
|
+
lastError: "the process could not be started: spawn ffmpeg ENOENT",
|
|
83
|
+
producedCount: 0
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
test("a run that ended is still recognised as the session's own", (t) => {
|
|
88
|
+
const dirPath = mkdtempSync(path.join(os.tmpdir(), "breaker-"));
|
|
89
|
+
t.after(() => rmSync(dirPath, { recursive: true, force: true }));
|
|
90
|
+
const manager = new HlsSessionManager({
|
|
91
|
+
enabled: true,
|
|
92
|
+
ffmpegBin: "ffmpeg",
|
|
93
|
+
localBindHost: "127.0.0.1",
|
|
94
|
+
localPort: 9090
|
|
95
|
+
});
|
|
96
|
+
const { session, run } = sessionWithARun(dirPath);
|
|
97
|
+
manager.sessionsById.set(SESSION_ID, session);
|
|
98
|
+
|
|
99
|
+
manager.noteRunEnded(session, run, failedFast(run, 0));
|
|
100
|
+
|
|
101
|
+
// If the identity were read after the removal, nothing here would have been
|
|
102
|
+
// written: the handler would have returned at its second line.
|
|
103
|
+
assert.equal(session.failedStartCount, 1, "the failure was counted");
|
|
104
|
+
assert.equal(session.failedStartAt, 0, "at the position it happened");
|
|
105
|
+
assert.ok(session.lastError.length > 0, "and the session knows what went wrong");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("the count runs at segment 0, which is where a first start happens", (t) => {
|
|
109
|
+
const dirPath = mkdtempSync(path.join(os.tmpdir(), "breaker-"));
|
|
110
|
+
t.after(() => rmSync(dirPath, { recursive: true, force: true }));
|
|
111
|
+
const manager = new HlsSessionManager({
|
|
112
|
+
enabled: true,
|
|
113
|
+
ffmpegBin: "ffmpeg",
|
|
114
|
+
localBindHost: "127.0.0.1",
|
|
115
|
+
localPort: 9090
|
|
116
|
+
});
|
|
117
|
+
const { session } = sessionWithARun(dirPath);
|
|
118
|
+
manager.sessionsById.set(SESSION_ID, session);
|
|
119
|
+
|
|
120
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
121
|
+
const run = { from: 0, to: -1, argsDescribed: "ffmpeg …" };
|
|
122
|
+
session.runs.add(run);
|
|
123
|
+
manager.noteRunEnded(session, run, failedFast(run, 0));
|
|
124
|
+
assert.equal(session.failedStartCount, attempt);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("real work resets the count, so a transient failure is not permanent", (t) => {
|
|
129
|
+
const dirPath = mkdtempSync(path.join(os.tmpdir(), "breaker-"));
|
|
130
|
+
t.after(() => rmSync(dirPath, { recursive: true, force: true }));
|
|
131
|
+
const manager = new HlsSessionManager({
|
|
132
|
+
enabled: true,
|
|
133
|
+
ffmpegBin: "ffmpeg",
|
|
134
|
+
localBindHost: "127.0.0.1",
|
|
135
|
+
localPort: 9090
|
|
136
|
+
});
|
|
137
|
+
const { session } = sessionWithARun(dirPath);
|
|
138
|
+
manager.sessionsById.set(SESSION_ID, session);
|
|
139
|
+
|
|
140
|
+
const quick = { from: 0, to: -1 };
|
|
141
|
+
session.runs.add(quick);
|
|
142
|
+
manager.noteRunEnded(session, quick, failedFast(quick, 0));
|
|
143
|
+
assert.equal(session.failedStartCount, 1);
|
|
144
|
+
|
|
145
|
+
const lived = { from: 0, to: -1 };
|
|
146
|
+
session.runs.add(lived);
|
|
147
|
+
manager.noteRunEnded(session, lived, { ...failedFast(lived, 0), livedMs: 30_000 });
|
|
148
|
+
|
|
149
|
+
assert.equal(session.failedStartCount, 0, "a run that did real work is not a failing start");
|
|
150
|
+
assert.equal(session.failedStartAt, -1);
|
|
151
|
+
});
|