Skip to content

Commit b0d1280

Browse files
committed
fix: improve QuotaExceededError recovery with stats.loaded fallback and eviction gating
The previous eviction logic never triggered because it relied on frag.byteLength (which reads stats.total from Content-Length). While Jellyfin does set Content-Length, stats.total can still be 0 during progressive loading or with other servers using chunked transfer. Switch to stats.loaded (actual bytes received, always populated after fragment completion) with byteLength as fallback. Additionally, progressive loading creates a new append operation per chunk, each with its own quotaEvictionAttempted closure. This caused every chunk to independently trigger eviction or emit BUFFER_FULL_ERROR. Add a class-level _quotaEvictionPending flag per SourceBuffer type so that subsequent QuotaExceededErrors piggyback on the in-flight eviction by queuing retries behind the pending remove, avoiding redundant evictions and suppressing the BUFFER_FULL_ERROR spam. Changes: - fragment-tracker: use stats.loaded as primary byte source in getBackBufferEvictionEnd, falling back to byteLength - buffer-controller: add _quotaEvictionPending flag gating per type - buffer-controller: add getQuotaEvictionFlushOp that clears the pending flag on complete/error - buffer-controller: log warning when no back buffer is available to evict
1 parent e34dbc6 commit b0d1280

2 files changed

Lines changed: 71 additions & 19 deletions

File tree

src/controller/buffer-controller.ts

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ export default class BufferController extends Logger implements ComponentAPI {
118118
audiovideo: 0,
119119
};
120120
private appendError?: ErrorData;
121+
// Tracks whether a QuotaExceededError back-buffer eviction is in progress
122+
// for a given SourceBuffer type. While true, subsequent QuotaExceededErrors
123+
// on the same type piggyback on the pending eviction instead of triggering
124+
// their own or emitting BUFFER_FULL_ERROR.
125+
private _quotaEvictionPending: Partial<
126+
Record<SourceBufferName, boolean>
127+
> = {};
121128
// Record of required or created buffers by type. SourceBuffer is stored in Track.buffer once created.
122129
private tracks: SourceBufferTrackSet = {};
123130
// Array of SourceBuffer type and SourceBuffer (or null). One entry per TrackSet in this.tracks.
@@ -907,27 +914,38 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
907914
error.name == 'QuotaExceededError' ||
908915
`quota` in error;
909916

910-
// On QuotaExceededError: evict back buffer and retry the append once
911-
// instead of emitting an error that forces the segment to be re-downloaded.
912-
if (isQuotaError && !quotaEvictionAttempted) {
913-
const evictEnd = this.getBackBufferEvictionTarget(
914-
type,
915-
data.byteLength,
916-
frag.type,
917-
);
918-
if (evictEnd > 0) {
919-
quotaEvictionAttempted = true;
917+
if (isQuotaError) {
918+
// An eviction is already in flight for this type — piggyback on it
919+
// by queuing a retry after the pending remove, no new eviction needed.
920+
if (this._quotaEvictionPending[type]) {
920921
this.log(
921-
`QuotaExceededError on "${type}" append sn: ${sn}evicting back buffer to ${evictEnd.toFixed(3)}s and retrying`,
922+
`QuotaExceededError on "${type}" sn: ${sn}eviction already pending, queuing retry`,
922923
);
923-
// Insert a remove operation followed by a retry of this append
924-
// right after the current (failed) operation in the queue.
925-
// When shiftAndExecuteNext removes the failed op, the remove runs
926-
// first, then the retry append with the same data.
927-
const removeOp = this.getFlushOp(type, 0, evictEnd);
928-
this.insertNext([removeOp, operation], type);
924+
this.insertNext([operation], type);
929925
return;
930926
}
927+
928+
// First QuotaExceededError for this type: evict minimum back buffer
929+
if (!quotaEvictionAttempted) {
930+
const evictEnd = this.getBackBufferEvictionTarget(
931+
type,
932+
data.byteLength,
933+
frag.type,
934+
);
935+
if (evictEnd > 0) {
936+
quotaEvictionAttempted = true;
937+
this._quotaEvictionPending[type] = true;
938+
this.log(
939+
`QuotaExceededError on "${type}" append sn: ${sn} — evicting back buffer to ${evictEnd.toFixed(3)}s and retrying`,
940+
);
941+
const removeOp = this.getQuotaEvictionFlushOp(type, 0, evictEnd);
942+
this.insertNext([removeOp, operation], type);
943+
return;
944+
}
945+
this.warn(
946+
`QuotaExceededError on "${type}" sn: ${sn} — no back buffer available to evict`,
947+
);
948+
}
931949
}
932950

933951
// in case any error occured while appending, put back segment in segments table
@@ -997,6 +1015,33 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
9971015
this.append(operation, type, this.isPending(this.tracks[type]));
9981016
}
9991017

1018+
// Like getFlushOp but clears _quotaEvictionPending on complete/error
1019+
private getQuotaEvictionFlushOp(
1020+
type: SourceBufferName,
1021+
start: number,
1022+
end: number,
1023+
): BufferOperation {
1024+
this.log(`queuing quota-eviction "${type}" remove ${start}-${end}`);
1025+
return {
1026+
label: 'remove',
1027+
execute: () => {
1028+
this.removeExecutor(type, start, end);
1029+
},
1030+
onStart: () => {},
1031+
onComplete: () => {
1032+
this._quotaEvictionPending[type] = false;
1033+
this.hls.trigger(Events.BUFFER_FLUSHED, { type });
1034+
},
1035+
onError: (error: Error) => {
1036+
this._quotaEvictionPending[type] = false;
1037+
this.warn(
1038+
`Failed to remove ${start}-${end} from "${type}" SourceBuffer (quota eviction)`,
1039+
error,
1040+
);
1041+
},
1042+
};
1043+
}
1044+
10001045
private getFlushOp(
10011046
type: SourceBufferName,
10021047
start: number,

src/controller/fragment-tracker.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,10 @@ export class FragmentTracker implements ComponentAPI {
486486
* Returns the eviction end position needed to free at least `bytesNeeded`
487487
* from the back buffer, or 0 if not enough data is available.
488488
* Walks buffered fragments oldest-first, accumulating actual byte sizes.
489+
*
490+
* Uses stats.loaded (actual bytes received) as the primary byte measure
491+
* since it is always set after fragment completion, unlike stats.total
492+
* which depends on Content-Length and may be 0 with chunked transfer.
489493
*/
490494
public getBackBufferEvictionEnd(
491495
beforePosition: number,
@@ -503,7 +507,9 @@ export class FragmentTracker implements ComponentAPI {
503507
continue;
504508
}
505509
const frag = entity.body;
506-
if (frag.end <= beforePosition && frag.byteLength) {
510+
// Use stats.loaded (always set after load) with byteLength as fallback
511+
const bytes = (frag.hasStats && frag.stats.loaded) || frag.byteLength;
512+
if (frag.end <= beforePosition && bytes) {
507513
candidates[count++] = entity;
508514
}
509515
}
@@ -525,7 +531,8 @@ export class FragmentTracker implements ComponentAPI {
525531
let evictEnd = 0;
526532
for (let i = 0; i < count; i++) {
527533
const frag = candidates[i].body;
528-
bytesFreed += frag.byteLength!;
534+
const bytes = (frag.hasStats && frag.stats.loaded) || frag.byteLength;
535+
bytesFreed += bytes!;
529536
evictEnd = frag.end;
530537
if (bytesFreed >= bytesNeeded) {
531538
return evictEnd;

0 commit comments

Comments
 (0)