@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.
@@ -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
- import { Viewer, viewersOf } from "./Viewer.js";
22
-
23
- export class Viewers {
24
- /**
25
- * One object per named viewer. An unnamed one is not in here: a viewer that
26
- * cannot say who it is is not the same viewer as another that cannot, so it
27
- * belongs to the session that met it and to no one else.
28
- *
29
- * @type {Map<string, Viewer>}
30
- */
31
- #byId = new Map();
32
-
33
- /**
34
- * This viewer, watching this output.
35
- *
36
- * Both directions of the relation are written here. Asking for a viewer of an
37
- * output IS the statement that they are watching it: every caller either
38
- * records where they are, what they chose, or what is being prepared for
39
- * them, and each of those is only true of somebody watching.
40
- *
41
- * @param {object} output - A session.
42
- * @param {string} consumerId
43
- * @returns {Viewer}
44
- */
45
- of(output, consumerId) {
46
- const viewers = viewersOf(output);
47
- const known = viewers.get(consumerId);
48
- if (known) {
49
- known.outputs.add(output.id);
50
- return known;
51
- }
52
- const viewer = consumerId
53
- ? this.#byId.get(consumerId) ?? new Viewer(consumerId)
54
- : new Viewer("");
55
- if (consumerId) {
56
- this.#byId.set(consumerId, viewer);
57
- }
58
- viewers.set(consumerId, viewer);
59
- viewer.outputs.add(output.id);
60
- return viewer;
61
- }
62
-
63
- /**
64
- * The viewer with this id, or null when nobody by that name is watching
65
- * anything. Never makes one.
66
- *
67
- * @param {string} consumerId
68
- * @returns {Viewer | null}
69
- */
70
- get(consumerId) {
71
- return this.#byId.get(consumerId) ?? null;
72
- }
73
-
74
- /**
75
- * The outputs this viewer is watching, as ids, copied so that leaving them
76
- * can be walked without mutating what is being walked.
77
- *
78
- * @param {object} anyOutput - A session they are known to, for an unnamed
79
- * viewer whose record lives on that session alone.
80
- * @param {string} consumerId
81
- * @returns {string[]}
82
- */
83
- watching(anyOutput, consumerId) {
84
- const viewer = consumerId
85
- ? this.#byId.get(consumerId)
86
- : viewersOf(anyOutput).get("");
87
- return viewer ? [...viewer.outputs] : [];
88
- }
89
-
90
- /**
91
- * This viewer is no longer watching this output.
92
- *
93
- * Both directions again, and the viewer itself is forgotten once it is
94
- * watching nothing — otherwise the registry would be a map that only grows,
95
- * which is the shape of half the memory faults recorded in this repository.
96
- *
97
- * @param {object} output - A session.
98
- * @param {string} consumerId
99
- * @returns {boolean} Whether they were watching it.
100
- */
101
- leaves(output, consumerId) {
102
- const viewers = viewersOf(output);
103
- const viewer = viewers.get(consumerId);
104
- if (!viewer) {
105
- return false;
106
- }
107
- viewers.delete(consumerId);
108
- viewer.outputs.delete(output.id);
109
- if (consumerId && viewer.outputs.size === 0) {
110
- this.#byId.delete(consumerId);
111
- }
112
- return true;
113
- }
114
-
115
- /**
116
- * How many named viewers are watching anything. For the log line and for a
117
- * check that the registry does not grow.
118
- *
119
- * @returns {number}
120
- */
121
- get size() {
122
- return this.#byId.size;
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
- seekFailureTarget: -1,
70
- seekFailureCount: 0,
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
+ });
@@ -176,7 +176,12 @@ test("every encoder stops when nobody is watching the output", () => {
176
176
  );
177
177
  });
178
178
 
179
- test("a run making material nobody asked for is stopped", () => {
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(actions.some((action) => action.type === "stop" && action.run === runA));
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
+ });