Skip to content

Commit e34dbc6

Browse files
committed
fix: recover from QuotaExceededError by evicting back buffer and retrying append
When a SourceBuffer append throws QuotaExceededError, intercept the error before it propagates, evict the minimum number of back buffer segments needed to fit the new data, and retry the append with the original data still in memory — avoiding the re-download loop that plagues hls.js upstream (#6776, #6711). Eviction uses actual byte sizes from the fragment tracker rather than time-based estimates, so the calculation is exact regardless of bitrate or ABR level switches. If eviction + retry fails (e.g. no back buffer to evict), falls through to the existing BUFFER_FULL_ERROR path. Changes: - buffer-controller: on QuotaExceededError, calculate eviction target and queue a remove + retry append instead of emitting an error - fragment-tracker: add getBackBufferEvictionEnd() which walks buffered fragments oldest-first accumulating byte sizes to find the minimum eviction point - buffer-operation-queue: add insertNext() to queue operations after the current one for remove-then-retry sequencing
1 parent 7a45b89 commit e34dbc6

File tree

3 files changed

+120
-6
lines changed

3 files changed

+120
-6
lines changed

src/controller/buffer-controller.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -853,6 +853,7 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
853853
}
854854

855855
const fragStart = (part || frag).start;
856+
let quotaEvictionAttempted = false;
856857
const operation: BufferOperation = {
857858
label: `append-${type}`,
858859
execute: () => {
@@ -900,6 +901,35 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
900901
},
901902
onError: (error: Error) => {
902903
this.clearBufferAppendTimeoutId(this.tracks[type]);
904+
905+
const isQuotaError =
906+
(error as DOMException).code === DOMException.QUOTA_EXCEEDED_ERR ||
907+
error.name == 'QuotaExceededError' ||
908+
`quota` in error;
909+
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;
920+
this.log(
921+
`QuotaExceededError on "${type}" append sn: ${sn} — evicting back buffer to ${evictEnd.toFixed(3)}s and retrying`,
922+
);
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);
929+
return;
930+
}
931+
}
932+
903933
// in case any error occured while appending, put back segment in segments table
904934
const event: ErrorData = {
905935
type: ErrorTypes.MEDIA_ERROR,
@@ -914,13 +944,9 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
914944
fatal: false,
915945
};
916946
const mediaError = this.media?.error;
917-
if (
918-
(error as DOMException).code === DOMException.QUOTA_EXCEEDED_ERR ||
919-
error.name == 'QuotaExceededError' ||
920-
`quota` in error
921-
) {
947+
if (isQuotaError) {
922948
// QuotaExceededError: http://www.w3.org/TR/html5/infrastructure.html#quotaexceedederror
923-
// let's stop appending any segments, and report BUFFER_FULL_ERROR error
949+
// Eviction was already attempted or not possible — report BUFFER_FULL_ERROR
924950
event.details = ErrorDetails.BUFFER_FULL_ERROR;
925951
} else if (
926952
(error as DOMException).code === DOMException.INVALID_STATE_ERR &&
@@ -1198,6 +1224,26 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
11981224
}
11991225
}
12001226

1227+
// Returns the end position to evict back buffer to on QuotaExceededError,
1228+
// or 0 if there is nothing to evict. Delegates to the fragment tracker which
1229+
// walks buffered fragments oldest-first using actual byte sizes to find the
1230+
// minimum eviction needed to fit the new segment.
1231+
private getBackBufferEvictionTarget(
1232+
type: SourceBufferName,
1233+
segmentBytes: number,
1234+
playlistType: PlaylistLevelType,
1235+
): number {
1236+
const { media } = this;
1237+
if (!media) {
1238+
return 0;
1239+
}
1240+
return this.fragmentTracker.getBackBufferEvictionEnd(
1241+
media.currentTime,
1242+
playlistType,
1243+
segmentBytes,
1244+
);
1245+
}
1246+
12011247
private resetAppendErrors() {
12021248
this.appendErrors = {
12031249
audio: 0,
@@ -1940,6 +1986,12 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
19401986
}
19411987
}
19421988

1989+
private insertNext(operations: BufferOperation[], type: SourceBufferName) {
1990+
if (this.operationQueue) {
1991+
this.operationQueue.insertNext(operations, type);
1992+
}
1993+
}
1994+
19431995
private appendBlocker(type: SourceBufferName): Promise<void> | undefined {
19441996
if (this.operationQueue) {
19451997
return this.operationQueue.appendBlocker(type);

src/controller/buffer-operation-queue.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,14 @@ export default class BufferOperationQueue {
7979
);
8080
}
8181

82+
public insertNext(operations: BufferOperation[], type: SourceBufferName) {
83+
if (this.queues === null) {
84+
return;
85+
}
86+
// Insert after the current (index 0) operation so they execute next
87+
this.queues[type].splice(1, 0, ...operations);
88+
}
89+
8290
public unblockAudio(op: BufferOperation) {
8391
if (this.queues === null) {
8492
return;

src/controller/fragment-tracker.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,60 @@ export class FragmentTracker implements ComponentAPI {
482482
return !!this.activePartLists[type]?.length;
483483
}
484484

485+
/**
486+
* Returns the eviction end position needed to free at least `bytesNeeded`
487+
* from the back buffer, or 0 if not enough data is available.
488+
* Walks buffered fragments oldest-first, accumulating actual byte sizes.
489+
*/
490+
public getBackBufferEvictionEnd(
491+
beforePosition: number,
492+
levelType: PlaylistLevelType,
493+
bytesNeeded: number,
494+
): number {
495+
const { fragments } = this;
496+
497+
// Collect back buffer fragments with known byte sizes
498+
let count = 0;
499+
const candidates: FragmentEntity[] = [];
500+
for (const key of Object.keys(fragments)) {
501+
const entity = fragments[key];
502+
if (!entity || !entity.buffered || entity.body.type !== levelType) {
503+
continue;
504+
}
505+
const frag = entity.body;
506+
if (frag.end <= beforePosition && frag.byteLength) {
507+
candidates[count++] = entity;
508+
}
509+
}
510+
511+
if (count === 0) {
512+
return 0;
513+
}
514+
515+
// Sort by sn (oldest first) — small array of references, no data copying.
516+
// Candidates are unsorted because this.fragments is a hash map keyed by
517+
// "type_level_sn", and Object.keys() returns insertion order which depends
518+
// on when each fragment was loaded, not its position in the timeline.
519+
// ABR level switches and seeks can cause fragments to load out of order.
520+
candidates.length = count;
521+
candidates.sort((a, b) => (a.body.sn as number) - (b.body.sn as number));
522+
523+
// Walk oldest-first, accumulating bytes until we have enough
524+
let bytesFreed = 0;
525+
let evictEnd = 0;
526+
for (let i = 0; i < count; i++) {
527+
const frag = candidates[i].body;
528+
bytesFreed += frag.byteLength!;
529+
evictEnd = frag.end;
530+
if (bytesFreed >= bytesNeeded) {
531+
return evictEnd;
532+
}
533+
}
534+
535+
// Not enough to fully cover bytesNeeded, return what we have
536+
return evictEnd > 0 ? evictEnd : 0;
537+
}
538+
485539
public removeFragmentsInRange(
486540
start: number,
487541
end: number,

0 commit comments

Comments
 (0)