@torrent-tv/proxy 2.87.12 → 2.87.14

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 CHANGED
@@ -1,3 +1,11 @@
1
+ ## 2.87.14
2
+
3
+ - **Fix**: Evaluate source-stall coverage against the continuous timeline already prepared on the proxy, while independently checking client-buffer continuity and delivery capacity.
4
+
5
+ ## 2.87.13
6
+
7
+ - **Fix**: Include distinct active piece reads in memory demand and preserve their slots immediately, so reads without priority-map ranges cannot deadlock the torrent piece store.
8
+
1
9
  ## 2.87.12
2
10
 
3
11
  - **Fix**: Check each automatic output against whole-machine encoding occupancy before opening it, so a rejected size can resolve to a lower admissible rung instead of failing the request.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.87.12",
3
+ "version": "2.87.14",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -81,7 +81,7 @@ export class RateTrend {
81
81
  * @param {number} input.durationSeconds
82
82
  * @param {number} input.bufferedAheadSeconds
83
83
  * @param {number} input.bufferLimitSeconds
84
- * @param {number} input.reserveSeconds
84
+ * @param {number} input.reserveSeconds - Measured source-stall coverage, in media seconds.
85
85
  * @param {number} input.lookaheadSeconds
86
86
  * @param {number} input.now
87
87
  * @param {boolean} [input.requiredAudio]
@@ -110,12 +110,9 @@ export function predictPlaybackReadiness(input = {}) {
110
110
  if (remaining <= buffered) {
111
111
  return result(true, 0, buffered, reserve, 0, "client-buffer-covers-end", 0);
112
112
  }
113
- if (!(duration > 0) || !(capacity > 0) || !(reserve > 0) || tracks.length === 0) {
113
+ if (!(duration > 0) || !(capacity > 0) || tracks.length === 0) {
114
114
  return result(false, null, buffered, reserve, null, "incomplete-state", 0);
115
115
  }
116
- if (reserve > capacity) {
117
- return result(false, null, buffered, reserve, null, "reserve-exceeds-client-capacity", 0);
118
- }
119
116
  const requiredTracks = input.requiredAudio === true ? 2 : 1;
120
117
  if (tracks.length < requiredTracks) {
121
118
  return result(false, null, buffered, reserve, null, "separate-audio-not-observed", 0);
@@ -166,7 +163,7 @@ export function predictPlaybackReadiness(input = {}) {
166
163
 
167
164
  for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
168
165
  const state = trackState[trackIndex];
169
- const { track, ready, segments } = state;
166
+ const { track, segments } = state;
170
167
  const measuredBitsPerMediaSecond = state.bitsPerMediaSecond;
171
168
  for (const segment of segments) {
172
169
  if (segment.endSeconds <= position + buffered) {
@@ -277,6 +274,7 @@ export function predictPlaybackReadiness(input = {}) {
277
274
  const trackFinish = trackState.map(() => 0);
278
275
  let transferFinish = 0;
279
276
  const completions = [];
277
+ const productions = [];
280
278
  for (const job of jobs) {
281
279
  const { track, trackIndex, segment, ahead, sizeBits, encodeWork, sourceIntervals } = job;
282
280
  if (prefillOnly && (ahead > prefillReach || segment.endSeconds - position > capacity)) {
@@ -322,7 +320,14 @@ export function predictPlaybackReadiness(input = {}) {
322
320
  if (!job.produced) {
323
321
  trackFinish[trackIndex] = encodedAt;
324
322
  }
325
- const producedAt = Math.max(sourceReadyAtLatest, encodedAt);
323
+ const producedAt = job.produced ? 0 : Math.max(sourceReadyAtLatest, encodedAt);
324
+ productions.push({
325
+ at: producedAt,
326
+ sourceReadyAt: sourceReadyAtLatest,
327
+ trackIndex,
328
+ segment,
329
+ requiresSource: sourceIntervals.size > 0
330
+ });
326
331
  const roomAt = segment.endSeconds - position <= capacity
327
332
  ? 0
328
333
  : startDelaySeconds + segment.endSeconds - position - capacity;
@@ -339,12 +344,16 @@ export function predictPlaybackReadiness(input = {}) {
339
344
  ? completions.filter(({ segment }) => segment.startSeconds - position <= prefillReach &&
340
345
  segment.endSeconds - position <= capacity)
341
346
  : completions;
342
- const safety = isSafeSchedule(usefulCompletions, trackState, position, duration,
347
+ const usefulProductions = prefillOnly
348
+ ? productions.filter(({ segment }) => segment.startSeconds - position <= prefillReach &&
349
+ segment.endSeconds - position <= capacity)
350
+ : productions;
351
+ const safety = isSafeSchedule(usefulCompletions, usefulProductions, trackState, position, duration,
343
352
  buffered, capacity, reserve, startDelaySeconds);
344
353
  return { safe: safety.safe,
345
354
  bufferedAtStart: safety.bufferedAtStart,
346
- finishAt: usefulCompletions.reduce((latest, item) => Math.max(latest, item.at), 0),
347
- completions: usefulCompletions };
355
+ neededSeconds: safety.neededSeconds,
356
+ finishAt: usefulCompletions.reduce((latest, item) => Math.max(latest, item.at), 0) };
348
357
  };
349
358
 
350
359
  const prefillReach = Math.min(capacity, lookahead, remaining);
@@ -353,7 +362,8 @@ export function predictPlaybackReadiness(input = {}) {
353
362
  if (!Number.isFinite(maximumUsefulDelay)) {
354
363
  return result(false, null, buffered, reserve, null, "prefill-cannot-complete", preparedSegments);
355
364
  }
356
- if (schedule(0).safe) {
365
+ const immediate = schedule(0);
366
+ if (immediate.safe) {
357
367
  return result(true, 0, buffered, reserve, 0, "trajectory-safe-now", preparedSegments);
358
368
  }
359
369
  const searchLimit = Math.max(maximumUsefulDelay, rateRecoveryDelay);
@@ -396,7 +406,7 @@ export function predictPlaybackReadiness(input = {}) {
396
406
  }
397
407
  }
398
408
  const readiness = schedule(high);
399
- return result(false, high, buffered, reserve, Math.max(0, reserve - buffered),
409
+ return result(false, high, buffered, reserve, immediate.neededSeconds,
400
410
  "minimum-safe-delay", preparedSegments, readiness.bufferedAtStart);
401
411
  }
402
412
 
@@ -508,53 +518,104 @@ function averageBitsPerMediaSecond(track, segments, ready) {
508
518
  return finiteNonNegative(track.bitsPerMediaSecond);
509
519
  }
510
520
 
511
- function isSafeSchedule(completions, trackState, position, duration, buffered, capacity, reserve, startDelay) {
512
- const ranges = trackState.map(() => [{ start: position, end: position + buffered }]);
513
- const ordered = [...completions]
521
+ function isSafeSchedule(completions, productions, trackState, position, duration, buffered, capacity, reserve,
522
+ startDelay) {
523
+ // Client continuity is constrained by its buffer limit; source-stall reserve
524
+ // is covered by the larger union of client data and output already prepared here.
525
+ const clientRanges = trackState.map(() => [{ start: position, end: position + buffered }]);
526
+ const preparedRanges = trackState.map(() => [{ start: position, end: position + buffered }]);
527
+ const orderedCompletions = [...completions]
514
528
  .filter(({ at }) => Number.isFinite(at))
515
529
  .sort((left, right) => left.at - right.at || left.trackIndex - right.trackIndex);
530
+ const orderedProductions = [...productions]
531
+ .filter(({ at }) => Number.isFinite(at))
532
+ .sort((left, right) => left.at - right.at || left.trackIndex - right.trackIndex);
533
+ const reserveUntil = trackState.map(() => Number.NEGATIVE_INFINITY);
534
+ // The measured reserve applies only until this track's last required source
535
+ // interval has arrived; encoding and client delivery stay in the schedule.
536
+ for (const production of productions) {
537
+ if (production.requiresSource) {
538
+ reserveUntil[production.trackIndex] = Math.max(reserveUntil[production.trackIndex],
539
+ production.sourceReadyAt);
540
+ }
541
+ }
516
542
  let completionIndex = 0;
543
+ let productionIndex = 0;
517
544
 
518
545
  const addThrough = (time) => {
519
- while (completionIndex < ordered.length && ordered[completionIndex].at <= time) {
520
- const { trackIndex, segment } = ordered[completionIndex];
521
- addRange(ranges[trackIndex], segment.startSeconds, segment.endSeconds);
546
+ while (productionIndex < orderedProductions.length && orderedProductions[productionIndex].at <= time) {
547
+ const { trackIndex, segment } = orderedProductions[productionIndex];
548
+ addRange(preparedRanges[trackIndex], segment.startSeconds, segment.endSeconds);
549
+ productionIndex += 1;
550
+ }
551
+ while (completionIndex < orderedCompletions.length && orderedCompletions[completionIndex].at <= time) {
552
+ const { trackIndex, segment } = orderedCompletions[completionIndex];
553
+ addRange(clientRanges[trackIndex], segment.startSeconds, segment.endSeconds);
554
+ addRange(preparedRanges[trackIndex], segment.startSeconds, segment.endSeconds);
522
555
  completionIndex += 1;
523
556
  }
524
557
  };
525
- const safeAt = (time) => {
558
+ const safeAt = (time, checkClient = true) => {
526
559
  const playerPosition = position + Math.max(0, time - startDelay);
527
- const available = Math.min(capacity, ...ranges.map((trackRanges) =>
560
+ if (playerPosition >= duration) {
561
+ return { safe: true, stockSafe: true, available: 0, neededSeconds: 0 };
562
+ }
563
+ const available = Math.min(capacity, ...clientRanges.map((trackRanges) =>
528
564
  Math.max(0, contiguousEnd(trackRanges, playerPosition) - playerPosition)));
529
- const required = Math.min(reserve, Math.max(0, duration - playerPosition));
530
- return { safe: available >= required, available };
565
+ let neededSeconds = 0;
566
+ let stockSafe = true;
567
+ for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
568
+ const required = time <= reserveUntil[trackIndex]
569
+ ? Math.min(reserve, Math.max(0, duration - playerPosition))
570
+ : 0;
571
+ const preparedAhead = Math.max(0,
572
+ contiguousEnd(preparedRanges[trackIndex], playerPosition) - playerPosition);
573
+ const deficit = Math.max(0, required - preparedAhead);
574
+ neededSeconds = Math.max(neededSeconds, deficit);
575
+ stockSafe &&= deficit === 0;
576
+ }
577
+ return {
578
+ safe: (!checkClient || available > 0) && stockSafe,
579
+ stockSafe,
580
+ available,
581
+ neededSeconds
582
+ };
531
583
  };
532
584
 
533
585
  addThrough(startDelay);
534
586
  const atStart = safeAt(startDelay);
535
587
  if (!atStart.safe) {
536
- return { safe: false, bufferedAtStart: atStart.available };
537
- }
538
-
539
- while (completionIndex < ordered.length) {
540
- const time = ordered[completionIndex].at;
541
- if (time > startDelay) {
542
- const beforeArrival = safeAt(time);
543
- if (!beforeArrival.safe) {
544
- return { safe: false, bufferedAtStart: atStart.available };
545
- }
588
+ return { safe: false, bufferedAtStart: atStart.available, neededSeconds: atStart.neededSeconds };
589
+ }
590
+
591
+ const playbackEnd = startDelay + Math.max(0, duration - position);
592
+ const playbackBoundaries = [position + buffered,
593
+ ...trackState.flatMap(({ segments }) => segments.map(({ endSeconds }) => endSeconds))]
594
+ .filter((boundary) => boundary > position && boundary < duration)
595
+ .map((boundary) => startDelay + boundary - position);
596
+ const sourceReadyTimes = productions
597
+ .filter(({ requiresSource }) => requiresSource)
598
+ .map(({ sourceReadyAt }) => sourceReadyAt);
599
+ const eventTimes = [...orderedCompletions.map(({ at }) => at),
600
+ ...orderedProductions.map(({ at }) => at), ...playbackBoundaries, ...sourceReadyTimes]
601
+ .filter((time) => Number.isFinite(time) && time > startDelay && time < playbackEnd)
602
+ .sort((left, right) => left - right)
603
+ .filter((time, index, all) => index === 0 || time !== all[index - 1]);
604
+
605
+ for (const time of eventTimes) {
606
+ const beforeArrival = safeAt(time, false);
607
+ if (!beforeArrival.stockSafe) {
608
+ return { safe: false, bufferedAtStart: atStart.available, neededSeconds: beforeArrival.neededSeconds };
546
609
  }
547
610
  addThrough(time);
548
- if (time > startDelay) {
549
- const afterArrival = safeAt(time);
550
- if (!afterArrival.safe) {
551
- return { safe: false, bufferedAtStart: atStart.available };
552
- }
611
+ const afterArrival = safeAt(time);
612
+ if (!afterArrival.safe) {
613
+ return { safe: false, bufferedAtStart: atStart.available, neededSeconds: afterArrival.neededSeconds };
553
614
  }
554
615
  }
555
616
 
556
- const atEnd = safeAt(startDelay + Math.max(0, duration - position));
557
- return { safe: atEnd.safe, bufferedAtStart: atStart.available };
617
+ const atEnd = safeAt(playbackEnd);
618
+ return { safe: atEnd.safe, bufferedAtStart: atStart.available, neededSeconds: atStart.neededSeconds };
558
619
  }
559
620
 
560
621
  function addRange(ranges, start, end) {
@@ -593,13 +593,13 @@ export class SharedPieceStore {
593
593
  }
594
594
 
595
595
  /**
596
- * Whether the machine's share of memory is smaller than one reader's window.
596
+ * Whether the machine's share is smaller than current read demand.
597
597
  *
598
598
  * The store holds the window anyway — refusing would leave the read it is
599
599
  * serving unable to finish, which is worse — but it is the honest measure of
600
600
  * "this machine cannot take any more", and it is measured rather than
601
- * guessed: it is the last revision's own comparison of what the machine
602
- * allowed against what the widest reader declared.
601
+ * guessed: it is the last revision's comparison of the machine's share
602
+ * against declared windows and distinct pieces pinned by live reads.
603
603
  */
604
604
  get isBeyondTheMachine() {
605
605
  return this.#beyondTheMachine;
@@ -650,15 +650,17 @@ export class SharedPieceStore {
650
650
  * resident piece ends up pinned, the read returns zero bytes and ffmpeg takes
651
651
  * that for the end of the file (field 2026-08-15, roadmap item 9).
652
652
  *
653
- * With no reader declaring anything there is no demand to speak of, so the
654
- * store asks for what the machine allows and the first revision after a read
655
- * begins brings it down to what that read needs.
653
+ * A declared window is not the only evidence of demand: a live reader pins
654
+ * each piece it is waiting on or handing to its consumer. Include those
655
+ * distinct pins as well, because some byte reads do not have a priority-map
656
+ * range to declare.
656
657
  */
657
658
  get wantedBytes() {
658
659
  const demand = this.#lru.demand();
660
+ const pinned = this.#lru.pinnedCount;
659
661
  if (demand.readers > 0) {
660
662
  this.#widestSeenPieces = Math.max(this.#widestSeenPieces, demand.widestPieces);
661
- const pieces = Math.max(MIN_RESIDENT_PIECES, demand.unionPieces, demand.widestPieces);
663
+ const pieces = Math.max(MIN_RESIDENT_PIECES, demand.unionPieces, demand.widestPieces, pinned);
662
664
  // Plus room to absorb what arrives while a write is finishing. Asking for
663
665
  // exactly what the readers want leaves no free place ever, so every
664
666
  // arrival evicts one of them — measured 2026-09-02: `6 reader(s) want 23
@@ -666,9 +668,9 @@ export class SharedPieceStore {
666
668
  // that followed.
667
669
  return (pieces + this.slackPieces()) * this.#chunkLength;
668
670
  }
669
- // WITH NO READER, THE FLOOR IS ONE READER'S WINDOW — the widest this store
670
- // has actually been asked for, which is measured rather than chosen, and
671
- // `MIN_RESIDENT_PIECES` only until it has been asked for anything.
671
+ // WITH NO DECLARED RANGE, the floor is the widest prior window or the
672
+ // distinct pieces pinned by current reads, both measured rather than
673
+ // chosen. `MIN_RESIDENT_PIECES` applies until either has been observed.
672
674
  //
673
675
  // It used to fall to the minimum the moment the last reader went, and the
674
676
  // allowance is re-derived once a minute (`STORE_REPORT_INTERVAL_MS`) while
@@ -677,12 +679,11 @@ export class SharedPieceStore {
677
679
  // to another file of the same torrent and the store was at
678
680
  // `0/3 (0MB of 12MB allowed)` with the machine offering 4.3 GB.
679
681
  //
680
- // Holding one window's worth between readers is not waste: it is what the
681
- // next read will ask for within seconds, and the pieces in it are the ones
682
- // that reader left off at. A store that has never been read keeps nothing
683
- // either — its pieces are arriving for a reader on the way, and they have
684
- // the disk.
685
- return (Math.max(MIN_RESIDENT_PIECES, this.#widestSeenPieces) + this.slackPieces()) * this.#chunkLength;
682
+ // Retaining the last declared window between reads avoids dropping back to
683
+ // the minimum; current pins cover reads that have no declared window. A
684
+ // store that has never been read keeps only the measured minimum.
685
+ const pieces = Math.max(MIN_RESIDENT_PIECES, this.#widestSeenPieces, pinned);
686
+ return (pieces + this.slackPieces()) * this.#chunkLength;
686
687
  }
687
688
 
688
689
  reviseGrowthCeiling(allowedBytes) {
@@ -692,27 +693,33 @@ export class SharedPieceStore {
692
693
  // the machine was full kept a small allowance for its whole life, however
693
694
  // much memory was freed afterwards (roadmap item 2, 2026-09-02).
694
695
  const wanted = Math.floor(Number(allowedBytes) / this.#chunkLength);
695
- // Never below what the live readers together hold, even when the machine's
696
- // share says less. A store that cannot hold what its readers are pinning
697
- // cannot complete any of their reads at all: every resident piece ends up
698
- // pinned, a read returns zero bytes and ffmpeg takes that for the end of
699
- // the file, which killed every encoder on that file in the field on
696
+ // Never below what the live readers together need, even when the machine's
697
+ // share says less. That is the union of declared windows, or the distinct
698
+ // pieces pinned by direct reads that have no declared window. A store that
699
+ // cannot hold what its readers are pinning cannot complete those reads:
700
+ // every resident piece ends up pinned, a read returns zero bytes and
701
+ // ffmpeg takes that for the end of the file, which killed every encoder on
702
+ // that file in the field on
700
703
  // 2026-08-15 (one reader) and again on 2026-09-07 (three readers of one
701
704
  // file at once — picture, sound and the edge-warming read — where this
702
705
  // floor was still computed from only the widest one of them, `wantedBytes`
703
706
  // above already asks for the union and got it right; this is the same
704
- // union, used as the floor instead of only as the ask). Exceeding the share
705
- // is the lesser failure, and the line below says when it happens.
707
+ // union, used as the floor instead of only as the ask). A live read can
708
+ // also pin pieces without a priority-map range, so those measured pins are
709
+ // part of the same minimum. Exceeding the share is the lesser failure, and
710
+ // the line below says when it happens.
706
711
  const demand = this.#lru.demand();
707
- this.#growthCeiling = Math.max(
712
+ const activeDemandPieces = Math.max(
708
713
  MIN_RESIDENT_PIECES,
709
- demand.readers > 0 ? demand.unionPieces : MIN_RESIDENT_PIECES,
714
+ demand.readers > 0 ? demand.unionPieces : 0,
715
+ this.#lru.pinnedCount
716
+ );
717
+ this.#growthCeiling = Math.max(
718
+ activeDemandPieces,
710
719
  Number.isFinite(wanted) ? wanted : MIN_RESIDENT_PIECES
711
720
  );
712
- const belowAWindow = demand.readers > 0
713
- && Number.isFinite(wanted)
714
- && wanted < demand.unionPieces;
715
- this.#beyondTheMachine = belowAWindow;
721
+ const belowActiveDemand = Number.isFinite(wanted) && wanted < activeDemandPieces;
722
+ this.#beyondTheMachine = belowActiveDemand;
716
723
  // The LRU is told too. It was constructed with the store's original
717
724
  // capacity and never revised, so `isFull()` answered against a number that
718
725
  // had not been the limit for some time — dormant only because nothing calls
@@ -754,7 +761,7 @@ export class SharedPieceStore {
754
761
  committedBytes: this.#blocksAllocated * this.#chunkLength,
755
762
  evicted,
756
763
  releasedBlocks,
757
- belowAWindow
764
+ belowActiveDemand
758
765
  };
759
766
  }
760
767
 
@@ -803,6 +810,19 @@ export class SharedPieceStore {
803
810
 
804
811
  pin(index) {
805
812
  this.#lru.pin(index);
813
+ // A piece reader pins before it waits for the bytes to arrive or return
814
+ // from disk. That is the only live record of some reads; the priority map
815
+ // can have no declared range for them (for example, a verified piece that
816
+ // was spilled). Include the measured set of distinct active pins in the
817
+ // immediate floor so a claim does not wait for the next budget pass to get
818
+ // a slot. `wantedBytes` carries the same demand to the machine-wide owner.
819
+ const pinned = this.#lru.pinnedCount;
820
+ if (pinned > this.#growthCeiling) {
821
+ this.#growthCeiling = pinned;
822
+ this.#lru.setCapacity(pinned);
823
+ this.#beyondTheMachine = true;
824
+ this.#wake();
825
+ }
806
826
  }
807
827
 
808
828
  unpin(index) {
@@ -638,11 +638,11 @@ setInterval(() => {
638
638
  (revised.releasedBlocks > 0 ? `, gave back ${revised.releasedBlocks} block(s) of memory` : "") +
639
639
  ` — now ${Math.round(revised.committedBytes / 1048576)}MB committed`
640
640
  );
641
- } else if (revised.belowAWindow) {
641
+ } else if (revised.belowActiveDemand) {
642
642
  log(
643
- `piece-store "${revised.name.slice(0, 40)}": the machine's share is smaller than one ` +
644
- `reader's window, so the allowance is held at ${Math.round(revised.ceilingBytes / 1048576)}MB ` +
645
- "anyway — a store that cannot hold the window of the read it is serving cannot finish that read"
643
+ `piece-store "${revised.name.slice(0, 40)}": the machine's share is smaller than active read demand, ` +
644
+ `so the allowance is held at ${Math.round(revised.ceilingBytes / 1048576)}MB ` +
645
+ "— a store cannot finish a read if it cannot hold the pieces that read is using"
646
646
  );
647
647
  } else if (revised.committedBytes > revised.ceilingBytes) {
648
648
  log(
@@ -428,6 +428,41 @@ test("a store whose readers have gone asks for nothing, one that never had them
428
428
  }
429
429
  });
430
430
 
431
+ test("live piece pins set memory demand when no priority-map range is declared", async () => {
432
+ const { store, directory } = await makeStore(2);
433
+ try {
434
+ await put(store, 0, pieceOf(0));
435
+ await put(store, 1, pieceOf(1));
436
+
437
+ store.pin(0);
438
+ store.pin(1);
439
+ store.pin(2);
440
+
441
+ assert.equal(store.stats().demand.readers, 0, "this read has no priority-map range");
442
+ assert.equal(store.stats().pinned, 3, "the three distinct live pieces are measured");
443
+ assert.equal(store.capacity, 3, "the active read demand expands capacity immediately");
444
+ assert.equal(store.wantedBytes, 3 * PIECE, "the budget owner sees the same measured demand");
445
+
446
+ const revised = store.reviseGrowthCeiling(2 * PIECE);
447
+ assert.equal(revised.ceilingBytes, 3 * PIECE, "a budget pass preserves pieces in active reads");
448
+ assert.equal(revised.belowActiveDemand, true, "the store reports that the machine share was short");
449
+
450
+ await put(store, 2, pieceOf(2));
451
+ assert.ok(store.locate(0), "the first active piece remains resident");
452
+ assert.ok(store.locate(1), "the second active piece remains resident");
453
+ assert.ok(store.locate(2), "the pending active piece obtains a memory slot");
454
+
455
+ store.unpin(0);
456
+ store.unpin(1);
457
+ store.unpin(2);
458
+ const released = store.reviseGrowthCeiling(2 * PIECE);
459
+ assert.equal(released.ceilingBytes, 2 * PIECE, "the measured floor falls after the reads release their pieces");
460
+ } finally {
461
+ await store.destroy();
462
+ await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
463
+ }
464
+ });
465
+
431
466
  test("the allowance is never cut below one reader's whole window", async () => {
432
467
  const { store, directory } = await makeStore(16);
433
468
  try {
@@ -438,13 +473,13 @@ test("the allowance is never cut below one reader's whole window", async () => {
438
473
  // which killed every encoder on that file in the field on 2026-08-15.
439
474
  const revised = store.reviseGrowthCeiling(2 * PIECE);
440
475
  assert.equal(revised.ceilingBytes, 10 * PIECE, "one whole window is the floor");
441
- assert.equal(revised.belowAWindow, true, "and the store says the share was smaller than that");
476
+ assert.equal(revised.belowActiveDemand, true, "and the store says the share was smaller than that");
442
477
 
443
478
  // With no reader there is no window to protect and the share is obeyed.
444
479
  store.releaseProtection("video");
445
480
  const obeyed = store.reviseGrowthCeiling(2 * PIECE);
446
481
  assert.equal(obeyed.ceilingBytes, 2 * PIECE);
447
- assert.equal(obeyed.belowAWindow, false);
482
+ assert.equal(obeyed.belowActiveDemand, false);
448
483
  } finally {
449
484
  store.destroy(() => undefined);
450
485
  await fs.rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
@@ -36,7 +36,50 @@ test("finds the first safe start from segments delivered before playback", () =>
36
36
 
37
37
  assert.equal(forecast.ready, false);
38
38
  assert.equal(forecast.reason, "minimum-safe-delay");
39
- assert.ok(Math.abs(forecast.delaySeconds - 3) < 1e-8);
39
+ assert.ok(Math.abs(forecast.delaySeconds - 1) < 1e-8);
40
+ });
41
+
42
+ test("keeps source-stall reserve in the proxy's prepared timeline, not the capped browser buffer", () => {
43
+ const now = 10_000;
44
+ const forecast = predictPlaybackReadiness({
45
+ now,
46
+ positionSeconds: 0,
47
+ durationSeconds: 12,
48
+ bufferedAheadSeconds: 2,
49
+ bufferLimitSeconds: 4,
50
+ reserveSeconds: 6,
51
+ lookaheadSeconds: 12,
52
+ sources: [{
53
+ id: "source",
54
+ complete: false,
55
+ bytesPerMediaSecond: 1,
56
+ readings: [{ at: now - 1_000, value: 2 }, { at: now, value: 2 }]
57
+ }],
58
+ tracks: [{
59
+ id: "video",
60
+ sourceIds: ["source"],
61
+ processedSeconds: 8,
62
+ bitsPerMediaSecond: 80,
63
+ readings: [{ at: now - 1_000, value: 2 }, { at: now, value: 2 }],
64
+ segments: [
65
+ { index: 0, startSeconds: 0, endSeconds: 2 },
66
+ { index: 1, startSeconds: 2, endSeconds: 4 },
67
+ { index: 2, startSeconds: 4, endSeconds: 6 },
68
+ { index: 3, startSeconds: 6, endSeconds: 8 },
69
+ { index: 4, startSeconds: 8, endSeconds: 10 },
70
+ { index: 5, startSeconds: 10, endSeconds: 12 }
71
+ ],
72
+ readySegmentIndices: [0, 1, 2, 3],
73
+ segmentSizesBytes: new Map([[0, 20], [1, 20], [2, 20], [3, 20], [4, 20], [5, 20]])
74
+ }],
75
+ linkReadings: [{ at: now - 1_000, value: 80_000 }, { at: now, value: 80_000 }]
76
+ });
77
+
78
+ assert.equal(forecast.ready, true);
79
+ assert.equal(forecast.reason, "trajectory-safe-now");
80
+ assert.equal(forecast.reserveSeconds, 6);
81
+ assert.equal(forecast.bufferedSeconds, 2);
82
+ assert.equal(forecast.preparedSegments, 4);
40
83
  });
41
84
 
42
85
  test("overlaps source reads and encoding, then waits for the slower stage and delivery", () => {
@@ -53,7 +96,7 @@ test("overlaps source reads and encoding, then waits for the slower stage and de
53
96
  const forecast = predictPlaybackReadiness(input({ sources: [source], tracks: [track] }));
54
97
 
55
98
  assert.equal(forecast.ready, false);
56
- assert.ok(Math.abs(forecast.delaySeconds - 3) < 1e-8);
99
+ assert.ok(Math.abs(forecast.delaySeconds - 2) < 1e-8);
57
100
  });
58
101
 
59
102
  test("does not produce an ETA without a client-link measurement", () => {
@@ -177,7 +220,7 @@ test("downloads an overlapping source interval once when two tracks use the same
177
220
 
178
221
  assert.equal(forecast.ready, false);
179
222
  assert.equal(forecast.reason, "minimum-safe-delay");
180
- assert.ok(Math.abs(forecast.delaySeconds - 2.048) < 1e-8);
223
+ assert.ok(Math.abs(forecast.delaySeconds - 2.032) < 1e-8);
181
224
  });
182
225
 
183
226
  test("downloads sources on separate torrents concurrently", () => {
@@ -319,5 +362,5 @@ test("finds a safe interval when rising encode speed and falling link speed make
319
362
  assert.equal(forecast.ready, false);
320
363
  assert.equal(forecast.reason, "minimum-safe-delay");
321
364
  assert.ok(Number.isFinite(forecast.delaySeconds));
322
- assert.ok(forecast.delaySeconds > 7 && forecast.delaySeconds < 10);
365
+ assert.ok(forecast.delaySeconds > 4 && forecast.delaySeconds < 5);
323
366
  });