@torrent-tv/proxy 2.79.0 → 2.80.1

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/bin/cli.js CHANGED
@@ -85,6 +85,10 @@ program
85
85
  .option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
86
86
  .option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
87
87
  .option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
88
+ .option(
89
+ "--usrsctp-state",
90
+ "Read usrsctp's association state with gdb when a wedge is declared. OFF by default: gdb attaches to THIS process and stops every thread of it while it works."
91
+ )
88
92
  .option("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
89
93
  .option("--memory-bytes <bytes>", "Per-torrent budget for pieces kept in memory before spilling to disk (default 512MB)")
90
94
  .option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
@@ -345,7 +349,22 @@ try {
345
349
  // declared (roadmap item 11) — no source rebuild, the module ships
346
350
  // unstripped. A host without gdb just never gets a reading, the same way a
347
351
  // host without tcpdump never gets a packet capture.
348
- usrsctpStateReader = createUsrsctpStateReader({ log: (message) => logger.info(message) });
352
+ // OFF UNLESS ASKED FOR, and the reason is a field incident rather than
353
+ // caution. On 2026-09-05 a delivery probe declared a wedge that lasted half a
354
+ // second and cleared itself; the reading it triggered attached gdb to this
355
+ // process, which stopped all eighty of its threads. The log ended mid-second,
356
+ // /healthz stopped answering, and the viewer waited a minute and was told the
357
+ // proxy had sent no video. The fifteen-second guard could not fire — it is a
358
+ // timer inside the process gdb had stopped — and killing gdb left the main
359
+ // thread deadlocked for good, so only restarting the addon recovered it.
360
+ //
361
+ // A means of diagnosis may not stop the product. This one attaches to a live
362
+ // process and is triggered by a verdict with a known history of false
363
+ // positives, so it is a thing to switch on deliberately while watching, not
364
+ // something that arms itself.
365
+ usrsctpStateReader = options.usrsctpState === true
366
+ ? createUsrsctpStateReader({ log: (message) => logger.info(message) })
367
+ : null;
349
368
 
350
369
  // What this process holds, once a minute. The kernel killed the proxy on
351
370
  // 2026-08-28 at 2.4 GB resident and the log had never said a word about
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.79.0",
3
+ "version": "2.80.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
package/server.js CHANGED
@@ -172,6 +172,22 @@ export async function startProxyServer({
172
172
  }
173
173
  return torrentPool.getTorrentTotals();
174
174
  },
175
+ // The priority map, on its way to the downloading. It lives in another
176
+ // thread, so the map crosses the worker channel; what it does with it —
177
+ // seconds into bytes, what to ask the swarm for, what to keep in memory —
178
+ // is its own business.
179
+ setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
180
+ const record = sourceRegistry.get(sourceKey);
181
+ if (!record) {
182
+ return;
183
+ }
184
+ try {
185
+ await torrentPool.setPriorityMap({ sourceKey, fileIndex, durationSeconds, zones });
186
+ } catch {
187
+ // Best effort: the map is republished on the next change, and the
188
+ // downloading goes on serving reads meanwhile.
189
+ }
190
+ },
175
191
  getSourceStats: async (sourceKey, fileIndex) => {
176
192
  const record = sourceRegistry.get(sourceKey);
177
193
  if (!record) {
@@ -84,6 +84,26 @@ export class DemandRegister {
84
84
  return this.windows().filter((window) => window.urgency === urgency);
85
85
  }
86
86
 
87
+ /**
88
+ * How urgently one byte of one file is wanted, by whoever wants it most.
89
+ *
90
+ * The priority map states its zones here like anybody else, so this answers
91
+ * for a byte nobody is reading yet: it is how a wait is attributed to a level
92
+ * without the reader keeping a forecast of its own.
93
+ *
94
+ * @param {number} fileIndex
95
+ * @param {number} byteOffset
96
+ * @returns {number | null} Null when nothing covers that byte.
97
+ */
98
+ urgencyAt(fileIndex, byteOffset) {
99
+ // `windows()` is sorted most urgent first, so the first cover is the answer.
100
+ const found = this.windows().find((window) =>
101
+ window.fileIndex === fileIndex &&
102
+ byteOffset >= window.byteStart &&
103
+ byteOffset <= window.byteEnd);
104
+ return found ? found.urgency : null;
105
+ }
106
+
87
107
  /**
88
108
  * The union of what is wanted of one file at one level, in byte ranges.
89
109
  *
@@ -224,7 +224,10 @@ export class SwarmSelection {
224
224
  if (!range) {
225
225
  continue;
226
226
  }
227
- store.protectRange(window.claimant, range.from, range.to);
227
+ // The level goes with the range: it is what eviction compares when
228
+ // everything resident is wanted by somebody, and dropping it here is
229
+ // what left the store choosing by recency alone.
230
+ store.protectRange(window.claimant, range.from, range.to, window.urgency);
228
231
  holding.add(window.claimant);
229
232
  }
230
233
  }
@@ -232,37 +232,51 @@ export function planEncoders({
232
232
  // lowest number, because that is where a viewer is stopped. The budget
233
233
  // rarely stretches to every gap, so which one is taken first is the whole
234
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
-
235
+ const budget = Math.max(0, maxRuns - surviving.size);
236
+ const alreadyPlanned = new Set(moves.map((action) => /** @type {{from:number}} */ (action).from));
237
+ for (const from of placeEncoders({
238
+ coverage,
239
+ windows: wanted,
240
+ howMany: budget,
241
+ speedX: live.reduce((best, run) => Math.max(best, run.speedX || 0), 0)
242
+ })) {
243
+ if (alreadyPlanned.has(from)) {
244
+ continue;
245
+ }
246
+ alreadyPlanned.add(from);
247
+ starts.push({
248
+ type: "start",
249
+ from,
250
+ to: endOfStretch(from, coverage.freeRunFrom(from)),
251
+ because: `#${from} is wanted and nobody is making it`
252
+ });
253
+ }
254
+
255
+ // ONE ENCODER'S WORK ENDS WHERE THE NEXT ONE'S BEGINS.
256
+ //
257
+ // A free stretch may run to the end of the track, and an encoder given all of
258
+ // it stands in the road of every encoder placed behind it: they write the
259
+ // same names, and each one's output is the other's "material somebody else
260
+ // made", so they stop one another. Field 2026-09-05: three encoders started
261
+ // on one track within 200 ms, each into the road another was already writing.
262
+ // Fifteen readers on a piece store that holds sixteen pieces followed, half
263
+ // of all evictions took a piece a reader had declared, and `/stream` began
264
+ // handing out bytes that were not the file's.
265
+ //
266
+ // The bound is taken from the NEXT ENCODER'S START, not from a band edge: a
267
+ // band edge travels with the viewer, so every step forward would leave a
268
+ // sliver just past the previous encoder and buy an encoder for it.
269
+ const placed = [...moves, ...starts].sort(
270
+ (left, right) => /** @type {any} */ (left).from - /** @type {any} */ (right).from
271
+ );
272
+ for (let index = 0; index < placed.length - 1; index += 1) {
273
+ const here = /** @type {{ from: number, to: number }} */ (placed[index]);
274
+ const next = /** @type {{ from: number }} */ (placed[index + 1]);
275
+ if (here.to < 0 || here.to >= next.from) {
276
+ here.to = next.from - 1;
277
+ }
278
+ }
279
+
266
280
  return [...stops, ...moves, ...starts, ...keeps];
267
281
  }
268
282
 
@@ -307,3 +321,117 @@ export function firstUnmetWant(coverage, windows) {
307
321
  }
308
322
  return lowest;
309
323
  }
324
+
325
+ /**
326
+ * Where to put the encoders this machine can afford.
327
+ *
328
+ * Two things are wanted of a division of the film, and they are wanted in this
329
+ * order:
330
+ *
331
+ * 1. **the viewer must not stop.** An encoder starting at `q` stays ahead of a
332
+ * viewer at `p` while `y / s <= q + y - p`, so it holds `(q - p) * s / (1-s)`
333
+ * of film and no more. Beyond that the viewer catches it, and the next
334
+ * encoder has to be standing there. That is where the first ones go, and it
335
+ * is why the stretches grow: the further off one starts, the later the
336
+ * viewer arrives and the longer it may work;
337
+ * 2. **the film should be finished as soon as possible.** Once the viewer is
338
+ * safe, whatever is left is divided EQUALLY between the encoders that
339
+ * remain: equal shares finish together, and any other division finishes when
340
+ * its longest share does. That is what makes seeking cheap — the film exists.
341
+ *
342
+ * At or above realtime the first requirement is met by one encoder for the
343
+ * whole film, and every other encoder goes to the second — which is the common
344
+ * case on a copied picture, and is why "one viewer, one encoder" was never the
345
+ * rule.
346
+ *
347
+ * @param {object} params
348
+ * @param {import("./CoverageMap.js").CoverageMap} params.coverage
349
+ * @param {WantedSpan[]} params.windows - The merged map, in this output's own
350
+ * numbering. Its highest-numbered band starts where the viewer is.
351
+ * @param {number} params.howMany - What the machine affords.
352
+ * @param {number} params.speedX - Measured. Zero when nothing has measured it,
353
+ * and then only the first requirement can be served.
354
+ * @returns {number[]} Where to start each encoder, ascending.
355
+ */
356
+ export function placeEncoders({ coverage, windows, howMany, speedX }) {
357
+ if (!(howMany > 0) || windows.length === 0) {
358
+ return [];
359
+ }
360
+ const byUrgency = [...windows].sort(
361
+ (left, right) => (right.priority ?? 0) - (left.priority ?? 0) || left.from - right.from
362
+ );
363
+ const viewer = byUrgency[0].from;
364
+ const lastWanted = Math.max(...windows.map((span) => span.to));
365
+
366
+ /** @type {number[]} */
367
+ const places = [];
368
+ const take = (at) => {
369
+ const gap = coverage.firstGapFrom(at, lastWanted);
370
+ if (gap !== null && !places.includes(gap)) {
371
+ places.push(gap);
372
+ }
373
+ return gap;
374
+ };
375
+
376
+ // The first one goes where somebody is stopped.
377
+ let previous = take(viewer);
378
+ if (previous === null) {
379
+ // Nothing ahead is missing. Whatever is left anywhere is divided equally.
380
+ return divideEqually(coverage, byUrgency, howMany, places);
381
+ }
382
+
383
+ const growth = speedX > 0 && speedX < 1 ? speedX / (1 - speedX) : Number.POSITIVE_INFINITY;
384
+ while (places.length < howMany) {
385
+ const holds = (previous - viewer) * growth;
386
+ if (!Number.isFinite(holds) || previous + holds > lastWanted) {
387
+ // Either it keeps ahead for the rest of the film, or its guarantee runs
388
+ // past the end of what anybody wants. Nobody is in danger further on, so
389
+ // the encoders left over go to finishing the film sooner.
390
+ break;
391
+ }
392
+ const next = take(Math.ceil(previous + Math.max(1, holds)));
393
+ if (next === null || next <= previous) {
394
+ break;
395
+ }
396
+ previous = next;
397
+ }
398
+ return divideEqually(coverage, byUrgency, howMany, places);
399
+ }
400
+
401
+ /**
402
+ * Spread whatever encoders are left over the film that is still missing.
403
+ *
404
+ * Equal shares, because equal shares finish together: any other division is
405
+ * finished when its longest share is, which is later.
406
+ *
407
+ * @param {import("./CoverageMap.js").CoverageMap} coverage
408
+ * @param {WantedSpan[]} byUrgency
409
+ * @param {number} howMany
410
+ * @param {number[]} places
411
+ * @returns {number[]}
412
+ */
413
+ function divideEqually(coverage, byUrgency, howMany, places) {
414
+ for (const span of byUrgency) {
415
+ if (places.length >= howMany) {
416
+ break;
417
+ }
418
+ const gap = coverage.firstGapFrom(span.from, span.to);
419
+ if (gap === null) {
420
+ continue;
421
+ }
422
+ // Where this band's missing film would be cut if the encoders left over
423
+ // shared it. One share per encoder, and the first share is the gap itself.
424
+ const left = howMany - places.length;
425
+ const width = Math.max(1, Math.floor((span.to - gap + 1) / left));
426
+ for (let at = gap; at <= span.to && places.length < howMany; at += width) {
427
+ const found = coverage.firstGapFrom(at, span.to);
428
+ if (found === null) {
429
+ break;
430
+ }
431
+ if (!places.includes(found)) {
432
+ places.push(found);
433
+ }
434
+ }
435
+ }
436
+ return [...places].sort((left, right) => left - right);
437
+ }
@@ -258,6 +258,35 @@ export class SegmentStore {
258
258
  return proven.sort((left, right) => left - right);
259
259
  }
260
260
 
261
+ /**
262
+ * A run is about to write these numbers again: forget that they were closed.
263
+ *
264
+ * A number closed once is not closed for ever. An encoder started at #N
265
+ * rewrites #N and everything after it, and while it is doing so the file
266
+ * under that name is half a segment — but the store remembered the earlier
267
+ * closing and would call it whole. Field 2026-09-05: seventeen runs were
268
+ * stopped and none ended normally, so numbers were being rewritten
269
+ * constantly, and the player met a fatal append error it never recovered
270
+ * from — an empty picture for the six minutes that followed.
271
+ *
272
+ * @param {string} key
273
+ * @param {number} from
274
+ */
275
+ forgetClosedFrom(key, from) {
276
+ const known = this.#closed.get(key);
277
+ if (!known || !Number.isInteger(from)) {
278
+ return;
279
+ }
280
+ for (const index of known) {
281
+ if (index >= from) {
282
+ known.delete(index);
283
+ }
284
+ }
285
+ // What the directory says has to be read again too: the successor rule
286
+ // would otherwise prove the rewritten piece from a file made before it.
287
+ this.#held.delete(key);
288
+ }
289
+
261
290
  /**
262
291
  * Whether this piece is finished, and may therefore be served.
263
292
  *
@@ -24,6 +24,14 @@
24
24
  *
25
25
  * Which of them bound the answer is returned beside it, because "why is there
26
26
  * only one encoder" is otherwise a question no log can answer.
27
+ *
28
+ * **Only the first is supplied today.** The two readings the others need are
29
+ * both in the torrent thread — what the swarm delivers against the film's own
30
+ * byte rate, and the store's allowance against what one reader keeps — and
31
+ * carrying them across is its own piece of work, deliberately not done here.
32
+ * Until it is, the swarm and the memory terms are inert: this returns what the
33
+ * processor allows, and says so. Stated rather than left to be discovered from
34
+ * a parameter nobody passes.
27
35
  */
28
36
 
29
37
  /**
@@ -24,6 +24,7 @@ import { availableShareFrom } from "./available-share.js";
24
24
  import { contentionPenalty } from "./contention.js";
25
25
  import { minimumBufferFrom } from "./supply-margin.js";
26
26
  import { mapForViewer } from "./priority/PriorityMap.js";
27
+ import { PriorityOrchestrator } from "./priority/PriorityOrchestrator.js";
27
28
  import { baseDrawFrom, costPerMegabyteFrom } from "./torrent-cost.js";
28
29
  import { medianOf, movedBeyondScatter, scatterOf } from "./learned-median.js";
29
30
  import {
@@ -1522,6 +1523,7 @@ export class HlsSessionManager {
1522
1523
  softwarePresetBenchmark = null,
1523
1524
  decodeCostModel = null,
1524
1525
  getSourceStats = null,
1526
+ setPriorityMap = null,
1525
1527
  // What a second job costs on this host, measured at startup. Null when it
1526
1528
  // could not be measured, and then nothing is corrected — the alternative
1527
1529
  // is inventing a penalty, which is the same fault as inventing a fill rate.
@@ -1578,6 +1580,7 @@ export class HlsSessionManager {
1578
1580
  // realtime budget to tell a CPU limit from a download-starved input:
1579
1581
  // (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
1580
1582
  this.getSourceStats = typeof getSourceStats === "function" ? getSourceStats : null;
1583
+ this.setPriorityMap = typeof setPriorityMap === "function" ? setPriorityMap : null;
1581
1584
  this.contentionPenalties = contentionPenalties instanceof Map ? contentionPenalties : null;
1582
1585
  // Totals across every torrent this proxy holds, used to price what the
1583
1586
  // torrent itself costs the machine (item 7). Optional: a proxy wired
@@ -1669,6 +1672,21 @@ export class HlsSessionManager {
1669
1672
  encodersRunningNow: () => this.#encodersRunningNow(),
1670
1673
  torrentCostSecFor: (session) => this.#torrentCostSecFor(session)
1671
1674
  });
1675
+ // The priority map, built from where the viewers are and handed to both
1676
+ // sides that act on it. The downloading lives in another thread, so its
1677
+ // copy travels over the worker channel.
1678
+ this.priority = new PriorityOrchestrator({
1679
+ publish: ({ sourceKey, fileIndex, durationSeconds, zones }) => {
1680
+ void Promise.resolve(
1681
+ this.setPriorityMap?.({ sourceKey, fileIndex, durationSeconds, zones })
1682
+ ).catch(() => {});
1683
+ },
1684
+ viewersOf: (session) => viewersOf(session),
1685
+ allowanceFor: (session) => minimumBufferFrom({
1686
+ segmentSeconds: this.segmentDurationSec,
1687
+ worstSupplyWaitSec: session.supplyFigures?.worstWaitSec
1688
+ })?.seconds ?? this.segmentDurationSec
1689
+ });
1672
1690
  this.encodeOrchestrator = new EncodeOrchestrator({
1673
1691
  maxRunsFor: (address) => this.maxRunsForOutput(address),
1674
1692
  makeRun: ({ address, from, to }) => this.#makeRunAt(address, from, to),
@@ -3710,7 +3728,7 @@ export class HlsSessionManager {
3710
3728
  * @returns {{from: number, to: number, priority: number}[]} In segment
3711
3729
  * numbers, both ends inclusive.
3712
3730
  */
3713
- #demandZonesFor(session, atSeconds) {
3731
+ #demandZonesFor(session, atSeconds, playing = true) {
3714
3732
  const boundaries = session.timeline?.boundaries ?? [];
3715
3733
  const segmentCount = Number(session.timeline?.segmentCount) || 0;
3716
3734
  // Where they are, in this output's own numbering. The viewer holds seconds;
@@ -3731,12 +3749,7 @@ export class HlsSessionManager {
3731
3749
  segmentSeconds: this.segmentDurationSec,
3732
3750
  worstSupplyWaitSec: session.supplyFigures?.worstWaitSec
3733
3751
  })?.seconds ?? this.segmentDurationSec,
3734
- // The slope between two progress reports, which is what this class
3735
- // measures everywhere else it prices an encode. `progress.speed` is
3736
- // ffmpeg's own cumulative figure and includes the run's start, so it
3737
- // reads low for the first seconds of every run; `recentSpeed` is the
3738
- // reading taken over a window and is the one the budget already trusts.
3739
- encodeSpeedX: Number(session.recentSpeed?.speed) || Number(session.progress?.speed) || 0
3752
+ playing
3740
3753
  });
3741
3754
  /** @type {{from: number, to: number, priority: number}[]} */
3742
3755
  const inSegments = [];
@@ -3943,7 +3956,7 @@ export class HlsSessionManager {
3943
3956
  // consulted: the 120 seconds that used to size this window were the
3944
3957
  // suspended encoder's threshold, one chosen number answering seven
3945
3958
  // different questions.
3946
- for (const zone of this.#demandZonesFor(session, at)) {
3959
+ for (const zone of this.#demandZonesFor(session, at, viewer.playing !== false)) {
3947
3960
  this.encodeOrchestrator.want({
3948
3961
  // The claimant is the PERSON, without the priority in it: their
3949
3962
  // zones are separate windows, but they leave together, and
@@ -3958,6 +3971,10 @@ export class HlsSessionManager {
3958
3971
  }
3959
3972
  }
3960
3973
  }
3974
+ this.priority.publishFor({
3975
+ sessionGroups: byOutput.values(),
3976
+ staleAfterMs: this.presenceStaleAfterMs()
3977
+ });
3961
3978
  this.encodeOrchestrator.reconcile();
3962
3979
  }
3963
3980
 
@@ -27,6 +27,7 @@ import { CoverageMap } from "../encode/CoverageMap.js";
27
27
  import { firstUnmetWant, planEncoders } from "../encode/EncodePlan.js";
28
28
  import { endOfRun } from "../encode/EncodeRun.js";
29
29
  import { ENCODE_EXIT } from "../encode/encode-exit.js";
30
+ import { mergeMaps } from "../priority/PriorityMap.js";
30
31
  import { affordableRuns } from "../encode/run-budget.js";
31
32
  import { RunCosts } from "../encode/run-costs.js";
32
33
  import { SegmentDemand } from "../encode/SegmentDemand.js";
@@ -244,11 +245,28 @@ export class EncodeOrchestrator {
244
245
  // order: what a viewer must have before they set off comes before what is
245
246
  // merely in front of them, which comes before the rest of the track. Passed
246
247
  // as a plain number so the plan stays arithmetic.
247
- const windows = this.demand.windowsOn(address).map((window) => ({
248
- from: window.from,
249
- to: window.to,
250
- priority: Number(window.priority) || 0
251
- }));
248
+ // ONE MAP, NOT ONE WINDOW PER VIEWER PER ZONE.
249
+ //
250
+ // Two viewers a few seconds apart state stretches that overlap, and the
251
+ // plan puts one encoder on each stretch it is given — so unmerged windows
252
+ // buy an encoder per viewer for film they both want, which is the opposite
253
+ // of what sharing the output is for. Merged, the highest number per segment
254
+ // wins and the stretches do not overlap, so one encoder serves everyone
255
+ // standing in front of it.
256
+ const windows = mergeMaps([
257
+ this.demand.windowsOn(address).map((window) => ({
258
+ from: window.from,
259
+ // Half-open on the way in and back again: these are whole segment
260
+ // numbers, and #10..#20 next to #21..#30 must not be read as touching
261
+ // at 20 and 21 at once.
262
+ to: window.to + 1,
263
+ // A window stated without a number is still a want — one
264
+ // undifferentiated want, which is what a caller that knows only a
265
+ // position states. Zero would read as "nothing wanted here" and the
266
+ // merge would drop it.
267
+ priority: Number(window.priority) || 1
268
+ }))
269
+ ]).map((zone) => ({ from: zone.from, to: zone.to - 1, priority: zone.priority }));
252
270
  const live = this.runsOn(address).filter((run) => run.isAlive);
253
271
  const actions = planEncoders({
254
272
  coverage,
@@ -325,6 +343,10 @@ export class EncodeOrchestrator {
325
343
  const onThisOutput = this.#runs.get(address) ?? [];
326
344
  onThisOutput.push(run);
327
345
  this.#runs.set(address, onThisOutput);
346
+ // This run rewrites everything from here on, so what was closed from here
347
+ // on is no longer closed. Without this a number closed by an earlier run
348
+ // stays servable while a later one is halfway through writing it again.
349
+ this.segmentStore?.forgetClosedFrom(address, from);
328
350
  this.coverageOf(address).claim(run, from, endOfRun({ from, to }));
329
351
  run.start(because);
330
352
  }