@torrent-tv/proxy 2.77.0 → 2.79.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,272 +1,309 @@
1
- /**
2
- * @file How many encoders there should be on one output, and where each of them
3
- * belongs — decided from numbers alone.
4
- *
5
- * The decision is separated from carrying it out on purpose. Every rule below
6
- * was previously a condition somewhere inside an eleven-thousand-line file,
7
- * reachable only by starting a real ffmpeg, and each of them was written for
8
- * one viewer:
9
- *
10
- * - a run was placed at the position of whoever asked, and never at the first
11
- * thing missing, so a viewer moving into a stretch already on disk restarted
12
- * an encoder to make it a second time;
13
- * - a run had no end at all — neither `-to` nor `-t` appeared anywhere — so it
14
- * ran until something killed it, and two runs on one output could not exist
15
- * without writing over each other;
16
- * - nothing stopped a run that had caught up with material somebody else had
17
- * already made.
18
- *
19
- * The rule this file exists to express, stated by the user 2026-09-04:
20
- *
21
- * > Viewers are always independent and always reuse what can be reused. The
22
- * > number of encoders is however many are needed; how many are needed follows
23
- * > from which sets of output parameters are wanted and where the viewers stand
24
- * > inside each. Segments produced by ANY encoder are available to ANY viewer,
25
- * > and which viewer asked never enters the question.
26
- *
27
- * So no name of a viewer reaches this file. It is given what is wanted, what
28
- * exists, what is being made, and what the machine can afford.
29
- *
30
- * **What a viewer wants decides WHERE a run starts and WHETHER it is needed,
31
- * never where it stops.** A run's end comes from the coverage: it runs until it
32
- * meets material somebody else has made or is making, or until the end of the
33
- * film. Bounding it by the window instead was tried and is wrong, because a
34
- * window travels forward as the viewer plays: a second viewer whose cushion
35
- * reached two segments past the first run's end was given an encoder of their
36
- * own to make those two, while the run already there had nowhere left to go.
37
- * How far ahead of the viewers a run may get is a different question with its
38
- * own answer — the look-ahead, which suspends a run rather than bounding it.
39
- */
40
-
41
- import { endOfRun } from "./EncodeRun.js";
42
-
43
- /**
44
- * One encoder that is running now.
45
- *
46
- * @typedef {object} LiveRun
47
- * @property {string} id
48
- * @property {number} from - The first number it was given.
49
- * @property {number} to - The last number it was given, inclusive.
50
- * @property {number} head - The next number it will produce. Its position.
51
- * @property {number} speedX - Measured encode speed against realtime, from
52
- * ffmpeg's own progress. Zero or less means nothing has measured it yet, and
53
- * then no comparison involving its speed can be made.
54
- */
55
-
56
- /**
57
- * What a viewer is waiting for. Which viewer is deliberately absent.
58
- *
59
- * @typedef {object} WantedSpan
60
- * @property {number} from
61
- * @property {number} to
62
- */
63
-
64
- /**
65
- * @typedef {{ type: "start", from: number, to: number, because: string }
66
- * | { type: "move", run: object, from: number, to: number, because: string }
67
- * | { type: "stop", run: object, because: string }
68
- * | { type: "keep", run: object, from: number, to: number }} PlanAction
69
- */
70
-
71
- /**
72
- * Whether a stretch and a window touch at all.
73
- *
74
- * @param {number} fromA
75
- * @param {number} toA
76
- * @param {number} fromB
77
- * @param {number} toB
78
- * @returns {boolean}
79
- */
80
- function overlaps(fromA, toA, fromB, toB) {
81
- return fromA <= toB && fromB <= toA;
82
- }
83
-
84
- /**
85
- * Decide what to do with the encoders on one output.
86
- *
87
- * @param {object} params
88
- * @param {import("./CoverageMap.js").CoverageMap} params.coverage - What has
89
- * been made and what is being made.
90
- * @param {WantedSpan[]} params.windows - What viewers are waiting for, one
91
- * window each. Empty means nobody is watching this output.
92
- * @param {LiveRun[]} params.runs - The encoders running on it now.
93
- * @param {number} params.maxRuns - How many encoders this machine can afford on
94
- * this output. Comes from the same arithmetic that decides the quality offer;
95
- * it is measured per host and never chosen here.
96
- * @param {number} params.segmentSeconds - How much film one segment holds.
97
- * @param {number} params.restartCostSec - What it costs to stop an encoder and
98
- * start it somewhere else: process start plus opening the input. Measured —
99
- * 0.12 s on the addon host, 0.5-0.6 s on a desktop.
100
- * @returns {PlanAction[]} Stops first, then moves, then starts, so that a plan
101
- * carried out in order never holds two encoders where it means to hold one.
102
- */
103
- export function planEncoders({
104
- coverage,
105
- windows,
106
- runs,
107
- maxRuns,
108
- segmentSeconds,
109
- restartCostSec
110
- }) {
111
- /** @type {PlanAction[]} */
112
- const stops = [];
113
- /** @type {PlanAction[]} */
114
- const moves = [];
115
- /** @type {PlanAction[]} */
116
- const starts = [];
117
- /** @type {PlanAction[]} */
118
- const keeps = [];
119
-
120
- const wanted = Array.isArray(windows) ? windows : [];
121
- const live = Array.isArray(runs) ? runs : [];
122
-
123
- // Nobody is watching this output: every encoder on it is making segments for
124
- // no one. This is the case a look-ahead cannot answer, because look-ahead
125
- // asks how far AHEAD of a viewer a run is and there is no viewer.
126
- if (wanted.length === 0) {
127
- for (const run of live) {
128
- stops.push({ type: "stop", run, because: "nobody is watching this output" });
129
- }
130
- return stops;
131
- }
132
-
133
- // How far a search for a gap needs to look: past the furthest thing anybody
134
- // is waiting for there is nothing to decide about.
135
- const demandTo = Math.max(...wanted.map((span) => span.to));
136
-
137
- /** Runs that survive this pass. @type {Set<object>} */
138
- const surviving = new Set();
139
-
140
- for (const run of live) {
141
- // 1. Is anybody waiting for what this run was given? A run whose stretch
142
- // touches no window is making material nobody has asked for.
143
- const stillWanted = wanted.some((span) => overlaps(run.from, endOfRun(run), span.from, span.to));
144
- if (!stillWanted) {
145
- stops.push({ type: "stop", run, because: "nothing it was given is wanted" });
146
- continue;
147
- }
148
-
149
- // 2. Has it arrived at material that already exists, or that another run is
150
- // making? Its own claim does not count against it.
151
- const coveredAhead = coverage.coveredRunFrom(run.head, run);
152
- if (coveredAhead === 0) {
153
- surviving.add(run);
154
- keeps.push({ type: "keep", run, from: run.head, to: run.to });
155
- continue;
156
- }
157
-
158
- // Where it would go instead: the first thing nobody has and nobody is
159
- // making, at or after where it stands.
160
- const gap = coverage.firstGapFrom(run.head, demandTo, run);
161
- if (gap === null) {
162
- stops.push({
163
- type: "stop",
164
- run,
165
- because: "everything wanted ahead of it is already made or being made"
166
- });
167
- continue;
168
- }
169
-
170
- // Driving through costs its own encode time for material that exists.
171
- // Moving costs one restart. Both are measured; neither is chosen here.
172
- //
173
- // A run whose speed nothing has measured yet cannot be compared, and then
174
- // moving is the answer rather than a default: driving through is work that
175
- // is certainly wasted, while the restart is a known and small cost.
176
- const driveSec = run.speedX > 0 ? (coveredAhead * segmentSeconds) / run.speedX : null;
177
- if (driveSec !== null && driveSec <= restartCostSec) {
178
- surviving.add(run);
179
- keeps.push({ type: "keep", run, from: run.head, to: run.to });
180
- continue;
181
- }
182
-
183
- const free = coverage.freeRunFrom(gap, run);
184
- surviving.add(run);
185
- moves.push({
186
- type: "move",
187
- run,
188
- from: gap,
189
- to: endOfStretch(gap, free),
190
- because: driveSec === null
191
- ? `${coveredAhead} segment(s) ahead are already covered and its speed is not measured`
192
- : `driving through ${coveredAhead} covered segment(s) costs ${driveSec.toFixed(2)}s ` +
193
- `against ${restartCostSec.toFixed(2)}s to move`
194
- });
195
- }
196
-
197
- // 3. Gaps somebody is waiting for that nobody is making. Taken in the order
198
- // the viewers meet them the lowest first because that is the one a
199
- // viewer is stopped at, and the budget may not stretch to all of them.
200
- const gapsWanted = [];
201
- for (const span of wanted) {
202
- const gap = coverage.firstGapFrom(span.from, span.to);
203
- if (gap !== null) {
204
- gapsWanted.push(gap);
205
- }
206
- }
207
- const alreadyPlanned = new Set(moves.map((action) => /** @type {{from:number}} */ (action).from));
208
- const budget = Math.max(0, maxRuns - surviving.size);
209
- for (const gap of [...new Set(gapsWanted)].sort((left, right) => left - right)) {
210
- if (starts.length >= budget) {
211
- break;
212
- }
213
- if (alreadyPlanned.has(gap)) {
214
- continue;
215
- }
216
- const free = coverage.freeRunFrom(gap);
217
- if (free === 0) {
218
- continue;
219
- }
220
- alreadyPlanned.add(gap);
221
- starts.push({
222
- type: "start",
223
- from: gap,
224
- to: endOfStretch(gap, free),
225
- because: `#${gap} is wanted and nobody is making it`
226
- });
227
- }
228
-
229
- return [...stops, ...moves, ...starts, ...keeps];
230
- }
231
-
232
- /**
233
- * The last number of a stretch that begins at `from` and is `length` long.
234
- *
235
- * `-1` when the length is not finite, which is this layer's word for a run with
236
- * no end: the film's length is not known, so there is nothing to stop it at, and
237
- * a number invented here would be an end nobody measured.
238
- *
239
- * @param {number} from
240
- * @param {number} length
241
- * @returns {number}
242
- */
243
- function endOfStretch(from, length) {
244
- return Number.isFinite(length) ? from + Math.max(1, length) - 1 : -1;
245
- }
246
-
247
- /**
248
- * The lowest number a viewer is waiting for that is not ready — what the plan
249
- * is judged by.
250
- *
251
- * Not used to decide anything: it is the figure a log line carries, so that a
252
- * plan that keeps producing while a viewer waits is visible rather than
253
- * inferred.
254
- *
255
- * @param {import("./CoverageMap.js").CoverageMap} coverage
256
- * @param {WantedSpan[]} windows
257
- * @returns {number | null}
258
- */
259
- export function firstUnmetWant(coverage, windows) {
260
- let lowest = null;
261
- for (const span of windows ?? []) {
262
- for (let at = span.from; at <= span.to; at += 1) {
263
- if (!coverage.isReady(at)) {
264
- if (lowest === null || at < lowest) {
265
- lowest = at;
266
- }
267
- break;
268
- }
269
- }
270
- }
271
- return lowest;
272
- }
1
+ /**
2
+ * @file How many encoders there should be on one output, and where each of them
3
+ * belongs — decided from numbers alone.
4
+ *
5
+ * The decision is separated from carrying it out on purpose. Every rule below
6
+ * was previously a condition somewhere inside an eleven-thousand-line file,
7
+ * reachable only by starting a real ffmpeg, and each of them was written for
8
+ * one viewer:
9
+ *
10
+ * - a run was placed at the position of whoever asked, and never at the first
11
+ * thing missing, so a viewer moving into a stretch already on disk restarted
12
+ * an encoder to make it a second time;
13
+ * - a run had no end at all — neither `-to` nor `-t` appeared anywhere — so it
14
+ * ran until something killed it, and two runs on one output could not exist
15
+ * without writing over each other;
16
+ * - nothing stopped a run that had caught up with material somebody else had
17
+ * already made.
18
+ *
19
+ * The rule this file exists to express, stated by the user 2026-09-04:
20
+ *
21
+ * > Viewers are always independent and always reuse what can be reused. The
22
+ * > number of encoders is however many are needed; how many are needed follows
23
+ * > from which sets of output parameters are wanted and where the viewers stand
24
+ * > inside each. Segments produced by ANY encoder are available to ANY viewer,
25
+ * > and which viewer asked never enters the question.
26
+ *
27
+ * So no name of a viewer reaches this file. It is given what is wanted, what
28
+ * exists, what is being made, and what the machine can afford.
29
+ *
30
+ * **A viewer decides the ORDER the map is walked in and, through the budget, how
31
+ * many processes walk it. Nothing else.** Stated by the user 2026-09-05, and it
32
+ * is the rule the rest of this file now follows: while a file is being encoded
33
+ * it is encoded WHOLE, in the order the map dictates. Who wants which segment
34
+ * decides which gap is closed first, never whether a run may go on living.
35
+ *
36
+ * **A run is therefore never stopped for standing outside a viewer's window.**
37
+ * It used to be, and the two decisions that produced that were in direct
38
+ * contradictionmeasured in the field 2026-09-05 on a viewer watching an
39
+ * episode:
40
+ *
41
+ * 1. this file commanded a start inside the window, at #46;
42
+ * 2. `planRunInterval` in the session manager moved the start to #78, because
43
+ * it counted a suspended run's claim as reaching `head + look-ahead`;
44
+ * 3. this file then saw a run at #78 against a window of [27, 57], found no
45
+ * overlap, and killed it as "nothing it was given is wanted";
46
+ * 4. neither coverage nor demand had changed, so the same start was commanded
47
+ * again 350-700ms per cycle, dozens of times, no segment ever produced,
48
+ * the viewer's picture stopped for 125 seconds.
49
+ *
50
+ * Both of those other authorities are gone (roadmap item 76, step 5). What is
51
+ * left is this file, and the only reasons it stops a run are: nobody is
52
+ * watching the output at all; the machine affords fewer processes; or there is
53
+ * nothing left unmade anywhere in the track.
54
+ *
55
+ * **A run's end comes from the coverage**, never from a window: it runs until
56
+ * it meets material somebody else has made or is making, or until the end of
57
+ * the film.
58
+ */
59
+
60
+ /**
61
+ * One encoder that is running now.
62
+ *
63
+ * @typedef {object} LiveRun
64
+ * @property {string} id
65
+ * @property {number} from - The first number it was given.
66
+ * @property {number} to - The last number it was given, inclusive.
67
+ * @property {number} head - The next number it will produce. Its position.
68
+ * @property {number} speedX - Measured encode speed against realtime, from
69
+ * ffmpeg's own progress. Zero or less means nothing has measured it yet, and
70
+ * then no comparison involving its speed can be made.
71
+ */
72
+
73
+ /**
74
+ * What a viewer is waiting for. Which viewer is deliberately absent.
75
+ *
76
+ * @typedef {object} WantedSpan
77
+ * @property {number} from
78
+ * @property {number} to
79
+ */
80
+
81
+ /**
82
+ * @typedef {{ type: "start", from: number, to: number, because: string }
83
+ * | { type: "move", run: object, from: number, to: number, because: string }
84
+ * | { type: "stop", run: object, because: string }
85
+ * | { type: "keep", run: object, from: number, to: number }} PlanAction
86
+ */
87
+
88
+ /**
89
+ * Decide what to do with the encoders on one output.
90
+ *
91
+ * @param {object} params
92
+ * @param {import("./CoverageMap.js").CoverageMap} params.coverage - What has
93
+ * been made and what is being made.
94
+ * @param {WantedSpan[]} params.windows - What viewers are waiting for, one
95
+ * window each. Empty means nobody is watching this output.
96
+ * @param {LiveRun[]} params.runs - The encoders running on it now.
97
+ * @param {number} params.maxRuns - How many encoders this machine can afford on
98
+ * this output. Comes from the same arithmetic that decides the quality offer;
99
+ * it is measured per host and never chosen here.
100
+ * @param {number} params.segmentSeconds - How much film one segment holds.
101
+ * @param {number} params.restartCostSec - What it costs to stop an encoder and
102
+ * start it somewhere else: process start plus opening the input. Measured —
103
+ * 0.12 s on the addon host, 0.5-0.6 s on a desktop.
104
+ * @returns {PlanAction[]} Stops first, then moves, then starts, so that a plan
105
+ * carried out in order never holds two encoders where it means to hold one.
106
+ */
107
+ export function planEncoders({
108
+ coverage,
109
+ windows,
110
+ runs,
111
+ maxRuns,
112
+ segmentSeconds,
113
+ restartCostSec,
114
+ killCostSec = 0,
115
+ firstByteWaitSec = 0,
116
+ refetchSecPerFilmSecond = 0
117
+ }) {
118
+ /** @type {PlanAction[]} */
119
+ const stops = [];
120
+ /** @type {PlanAction[]} */
121
+ const moves = [];
122
+ /** @type {PlanAction[]} */
123
+ const starts = [];
124
+ /** @type {PlanAction[]} */
125
+ const keeps = [];
126
+
127
+ const wanted = Array.isArray(windows) ? windows : [];
128
+ const live = Array.isArray(runs) ? runs : [];
129
+
130
+ // Nobody is watching this output: every encoder on it is making segments for
131
+ // no one. This is the case a look-ahead cannot answer, because look-ahead
132
+ // asks how far AHEAD of a viewer a run is and there is no viewer.
133
+ if (wanted.length === 0) {
134
+ for (const run of live) {
135
+ stops.push({ type: "stop", run, because: "nobody is watching this output" });
136
+ }
137
+ return stops;
138
+ }
139
+
140
+ // How far a search for a gap needs to look: past the furthest thing anybody
141
+ // is waiting for there is nothing to decide about.
142
+ const demandTo = Math.max(...wanted.map((span) => span.to));
143
+
144
+ /** Runs that survive this pass. @type {Set<object>} */
145
+ const surviving = new Set();
146
+
147
+ for (const run of live) {
148
+ // 1. A RUN IS NEVER STOPPED FOR STANDING OUTSIDE A WINDOW. While a file is
149
+ // being encoded it is encoded whole; a viewer decides the ORDER the work
150
+ // is taken in and, through the budget, how many processes take it.
151
+ //
152
+ // This used to stop a run whose stretch touched no window, and that
153
+ // decision contradicted the one below it: the run was placed by a search
154
+ // the retention test did not accept, so it was killed on the pass after
155
+ // it started and started again in the same place — 350-700ms per cycle in
156
+ // the field on 2026-09-05, no segment ever produced, the viewer's picture
157
+ // stopped for 125 seconds.
158
+ //
159
+ // 2. Has it arrived at material that already exists, or that another run is
160
+ // making? Its own claim does not count against it.
161
+ const coveredAhead = coverage.coveredRunFrom(run.head, run);
162
+ if (coveredAhead === 0) {
163
+ surviving.add(run);
164
+ keeps.push({ type: "keep", run, from: run.head, to: run.to });
165
+ continue;
166
+ }
167
+
168
+ // Where it would go instead: the first thing nobody has and nobody is
169
+ // making, at or after where it stands.
170
+ const gap = coverage.firstGapFrom(run.head, demandTo, run);
171
+ if (gap === null) {
172
+ stops.push({
173
+ type: "stop",
174
+ run,
175
+ because: "everything wanted ahead of it is already made or being made"
176
+ });
177
+ continue;
178
+ }
179
+
180
+ // WHICH IS CHEAPER, AND BOTH SIDES COUNTED WHOLE.
181
+ //
182
+ // Driving through material that exists costs this run's own encode time for
183
+ // it, and costs the swarm the same bytes a second time — the film has to be
184
+ // fetched again to be encoded again.
185
+ //
186
+ // Moving costs the death of this run, the start of another, and the wait
187
+ // for the first bytes at the new position. The last of those is the largest
188
+ // in the field and the one nothing measures yet; while it is unmeasured it
189
+ // counts as zero, which makes moving look cheaper than it is.
190
+ //
191
+ // A run whose speed nothing has measured yet cannot be compared at all, and
192
+ // then it is KEPT. Moving costs a known amount for an unknown gain, and a
193
+ // fresh run has produced nothing, so taking its work away is certainly a
194
+ // loss. This used to answer the other way, and every just-started run was
195
+ // moved the moment anything ahead of it was covered — which, once the whole
196
+ // film ahead had been made, was always.
197
+ // Both sides have to be known for the comparison to mean anything. The
198
+ // encoder's own speed is one; what the swarm charges to fetch the same
199
+ // bytes again is the other, and where nothing has measured it the sum is
200
+ // not a cost but a fragment of one. Answering from a fragment biases the
201
+ // decision one way towards moving, since the missing term is on the
202
+ // driving side — so an unknown term is a reason to keep, exactly as an
203
+ // unmeasured speed is.
204
+ const known = run.speedX > 0 && refetchSecPerFilmSecond > 0;
205
+ const refetchSec = coveredAhead * segmentSeconds * refetchSecPerFilmSecond;
206
+ const driveSec = known ? (coveredAhead * segmentSeconds) / run.speedX + refetchSec : null;
207
+ const moveSec = restartCostSec + killCostSec + firstByteWaitSec;
208
+ if (driveSec === null || driveSec <= moveSec) {
209
+ surviving.add(run);
210
+ keeps.push({ type: "keep", run, from: run.head, to: run.to });
211
+ continue;
212
+ }
213
+
214
+ const free = coverage.freeRunFrom(gap, run);
215
+ surviving.add(run);
216
+ moves.push({
217
+ type: "move",
218
+ run,
219
+ from: gap,
220
+ to: endOfStretch(gap, free),
221
+ because:
222
+ `driving through ${coveredAhead} covered segment(s) costs ${driveSec.toFixed(2)}s ` +
223
+ `(encode ${((coveredAhead * segmentSeconds) / run.speedX).toFixed(2)}s + ` +
224
+ `refetch ${refetchSec.toFixed(2)}s) against ${moveSec.toFixed(2)}s to move ` +
225
+ `(kill ${killCostSec.toFixed(2)}s + start ${restartCostSec.toFixed(2)}s + ` +
226
+ `first bytes ${firstByteWaitSec.toFixed(2)}s)`
227
+ });
228
+ }
229
+
230
+ // 3. Gaps somebody is waiting for that nobody is making, IN THE ORDER THE
231
+ // DEMAND MAP PUTS THEM: most urgent zone first, and within one zone the
232
+ // lowest number, because that is where a viewer is stopped. The budget
233
+ // rarely stretches to every gap, so which one is taken first is the whole
234
+ // of what a viewer's presence decides.
235
+ const gapsWanted = [];
236
+ for (const span of [...wanted].sort(
237
+ (left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.from - right.from
238
+ )) {
239
+ const gap = coverage.firstGapFrom(span.from, span.to);
240
+ if (gap !== null) {
241
+ gapsWanted.push(gap);
242
+ }
243
+ }
244
+ const alreadyPlanned = new Set(moves.map((action) => /** @type {{from:number}} */ (action).from));
245
+ const budget = Math.max(0, maxRuns - surviving.size);
246
+ for (const gap of [...new Set(gapsWanted)]) {
247
+ if (starts.length >= budget) {
248
+ break;
249
+ }
250
+ if (alreadyPlanned.has(gap)) {
251
+ continue;
252
+ }
253
+ const free = coverage.freeRunFrom(gap);
254
+ if (free === 0) {
255
+ continue;
256
+ }
257
+ alreadyPlanned.add(gap);
258
+ starts.push({
259
+ type: "start",
260
+ from: gap,
261
+ to: endOfStretch(gap, free),
262
+ because: `#${gap} is wanted and nobody is making it`
263
+ });
264
+ }
265
+
266
+ return [...stops, ...moves, ...starts, ...keeps];
267
+ }
268
+
269
+ /**
270
+ * The last number of a stretch that begins at `from` and is `length` long.
271
+ *
272
+ * `-1` when the length is not finite, which is this layer's word for a run with
273
+ * no end: the film's length is not known, so there is nothing to stop it at, and
274
+ * a number invented here would be an end nobody measured.
275
+ *
276
+ * @param {number} from
277
+ * @param {number} length
278
+ * @returns {number}
279
+ */
280
+ function endOfStretch(from, length) {
281
+ return Number.isFinite(length) ? from + Math.max(1, length) - 1 : -1;
282
+ }
283
+
284
+ /**
285
+ * The lowest number a viewer is waiting for that is not ready — what the plan
286
+ * is judged by.
287
+ *
288
+ * Not used to decide anything: it is the figure a log line carries, so that a
289
+ * plan that keeps producing while a viewer waits is visible rather than
290
+ * inferred.
291
+ *
292
+ * @param {import("./CoverageMap.js").CoverageMap} coverage
293
+ * @param {WantedSpan[]} windows
294
+ * @returns {number | null}
295
+ */
296
+ export function firstUnmetWant(coverage, windows) {
297
+ let lowest = null;
298
+ for (const span of windows ?? []) {
299
+ for (let at = span.from; at <= span.to; at += 1) {
300
+ if (!coverage.isReady(at)) {
301
+ if (lowest === null || at < lowest) {
302
+ lowest = at;
303
+ }
304
+ break;
305
+ }
306
+ }
307
+ }
308
+ return lowest;
309
+ }