@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 +8 -0
- package/package.json +1 -1
- package/services/server/playback-readiness.js +100 -39
- package/services/storage/piece-store/shared-piece-store.js +50 -30
- package/services/torrent/worker/worker.js +4 -4
- package/test/piece-store-eviction.test.js +37 -2
- package/test/playback-readiness.test.js +47 -4
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
|
@@ -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) ||
|
|
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,
|
|
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
|
|
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
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
512
|
-
|
|
513
|
-
|
|
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 (
|
|
520
|
-
const { trackIndex, segment } =
|
|
521
|
-
addRange(
|
|
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
|
-
|
|
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
|
-
|
|
530
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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(
|
|
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
|
|
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
|
|
602
|
-
*
|
|
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
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
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
|
|
670
|
-
//
|
|
671
|
-
// `MIN_RESIDENT_PIECES`
|
|
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
|
-
//
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
|
|
684
|
-
|
|
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
|
|
696
|
-
// share says less.
|
|
697
|
-
//
|
|
698
|
-
//
|
|
699
|
-
//
|
|
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).
|
|
705
|
-
//
|
|
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
|
-
|
|
712
|
+
const activeDemandPieces = Math.max(
|
|
708
713
|
MIN_RESIDENT_PIECES,
|
|
709
|
-
demand.readers > 0 ? demand.unionPieces :
|
|
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
|
|
713
|
-
|
|
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
|
-
|
|
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.
|
|
641
|
+
} else if (revised.belowActiveDemand) {
|
|
642
642
|
log(
|
|
643
|
-
`piece-store "${revised.name.slice(0, 40)}": the machine's share is smaller than
|
|
644
|
-
`
|
|
645
|
-
"
|
|
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.
|
|
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.
|
|
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 -
|
|
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 -
|
|
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.
|
|
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 >
|
|
365
|
+
assert.ok(forecast.delaySeconds > 4 && forecast.delaySeconds < 5);
|
|
323
366
|
});
|