Skip to content

Commit d972a23

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 stats.loaded (actual bytes received, always populated after fragment completion) rather than frag.byteLength (which depends on stats.total from Content-Length and may be 0 during progressive loading or with chunked transfer), so the calculation is reliable regardless of how segments are served. A class-level _quotaEvictionPending flag per SourceBuffer type prevents progressive loading chunks from each independently triggering eviction or emitting BUFFER_FULL_ERROR. The first QuotaExceededError triggers eviction; subsequent errors on the same type piggyback by queuing retries behind the pending remove. If eviction + retry fails, 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 - buffer-controller: add _quotaEvictionPending flag gating per type - buffer-controller: add getQuotaEvictionFlushOp that clears the pending flag on complete/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 d972a23

3 files changed

Lines changed: 172 additions & 6 deletions

File tree

src/controller/buffer-controller.ts

Lines changed: 102 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ 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<Record<SourceBufferName, boolean>> =
126+
{};
121127
// Record of required or created buffers by type. SourceBuffer is stored in Track.buffer once created.
122128
private tracks: SourceBufferTrackSet = {};
123129
// Array of SourceBuffer type and SourceBuffer (or null). One entry per TrackSet in this.tracks.
@@ -853,6 +859,7 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
853859
}
854860

855861
const fragStart = (part || frag).start;
862+
let quotaEvictionAttempted = false;
856863
const operation: BufferOperation = {
857864
label: `append-${type}`,
858865
execute: () => {
@@ -900,6 +907,46 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
900907
},
901908
onError: (error: Error) => {
902909
this.clearBufferAppendTimeoutId(this.tracks[type]);
910+
911+
const isQuotaError =
912+
(error as DOMException).code === DOMException.QUOTA_EXCEEDED_ERR ||
913+
error.name == 'QuotaExceededError' ||
914+
`quota` in error;
915+
916+
if (isQuotaError) {
917+
// An eviction is already in flight for this type — piggyback on it
918+
// by queuing a retry after the pending remove, no new eviction needed.
919+
if (this._quotaEvictionPending[type]) {
920+
this.log(
921+
`QuotaExceededError on "${type}" sn: ${sn} — eviction already pending, queuing retry`,
922+
);
923+
this.insertNext([operation], type);
924+
return;
925+
}
926+
927+
// First QuotaExceededError for this type: evict minimum back buffer
928+
if (!quotaEvictionAttempted) {
929+
const evictEnd = this.getBackBufferEvictionTarget(
930+
type,
931+
data.byteLength,
932+
frag.type,
933+
);
934+
if (evictEnd > 0) {
935+
quotaEvictionAttempted = true;
936+
this._quotaEvictionPending[type] = true;
937+
this.log(
938+
`QuotaExceededError on "${type}" append sn: ${sn} — evicting back buffer to ${evictEnd.toFixed(3)}s and retrying`,
939+
);
940+
const removeOp = this.getQuotaEvictionFlushOp(type, 0, evictEnd);
941+
this.insertNext([removeOp, operation], type);
942+
return;
943+
}
944+
this.warn(
945+
`QuotaExceededError on "${type}" sn: ${sn} — no back buffer available to evict`,
946+
);
947+
}
948+
}
949+
903950
// in case any error occured while appending, put back segment in segments table
904951
const event: ErrorData = {
905952
type: ErrorTypes.MEDIA_ERROR,
@@ -914,13 +961,9 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
914961
fatal: false,
915962
};
916963
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-
) {
964+
if (isQuotaError) {
922965
// QuotaExceededError: http://www.w3.org/TR/html5/infrastructure.html#quotaexceedederror
923-
// let's stop appending any segments, and report BUFFER_FULL_ERROR error
966+
// Eviction was already attempted or not possible — report BUFFER_FULL_ERROR
924967
event.details = ErrorDetails.BUFFER_FULL_ERROR;
925968
} else if (
926969
(error as DOMException).code === DOMException.INVALID_STATE_ERR &&
@@ -971,6 +1014,33 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
9711014
this.append(operation, type, this.isPending(this.tracks[type]));
9721015
}
9731016

1017+
// Like getFlushOp but clears _quotaEvictionPending on complete/error
1018+
private getQuotaEvictionFlushOp(
1019+
type: SourceBufferName,
1020+
start: number,
1021+
end: number,
1022+
): BufferOperation {
1023+
this.log(`queuing quota-eviction "${type}" remove ${start}-${end}`);
1024+
return {
1025+
label: 'remove',
1026+
execute: () => {
1027+
this.removeExecutor(type, start, end);
1028+
},
1029+
onStart: () => {},
1030+
onComplete: () => {
1031+
this._quotaEvictionPending[type] = false;
1032+
this.hls.trigger(Events.BUFFER_FLUSHED, { type });
1033+
},
1034+
onError: (error: Error) => {
1035+
this._quotaEvictionPending[type] = false;
1036+
this.warn(
1037+
`Failed to remove ${start}-${end} from "${type}" SourceBuffer (quota eviction)`,
1038+
error,
1039+
);
1040+
},
1041+
};
1042+
}
1043+
9741044
private getFlushOp(
9751045
type: SourceBufferName,
9761046
start: number,
@@ -1198,6 +1268,26 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
11981268
}
11991269
}
12001270

1271+
// Returns the end position to evict back buffer to on QuotaExceededError,
1272+
// or 0 if there is nothing to evict. Delegates to the fragment tracker which
1273+
// walks buffered fragments oldest-first using actual byte sizes to find the
1274+
// minimum eviction needed to fit the new segment.
1275+
private getBackBufferEvictionTarget(
1276+
type: SourceBufferName,
1277+
segmentBytes: number,
1278+
playlistType: PlaylistLevelType,
1279+
): number {
1280+
const { media } = this;
1281+
if (!media) {
1282+
return 0;
1283+
}
1284+
return this.fragmentTracker.getBackBufferEvictionEnd(
1285+
media.currentTime,
1286+
playlistType,
1287+
segmentBytes,
1288+
);
1289+
}
1290+
12011291
private resetAppendErrors() {
12021292
this.appendErrors = {
12031293
audio: 0,
@@ -1940,6 +2030,12 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
19402030
}
19412031
}
19422032

2033+
private insertNext(operations: BufferOperation[], type: SourceBufferName) {
2034+
if (this.operationQueue) {
2035+
this.operationQueue.insertNext(operations, type);
2036+
}
2037+
}
2038+
19432039
private appendBlocker(type: SourceBufferName): Promise<void> | undefined {
19442040
if (this.operationQueue) {
19452041
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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,68 @@ 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+
* 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.
493+
*/
494+
public getBackBufferEvictionEnd(
495+
beforePosition: number,
496+
levelType: PlaylistLevelType,
497+
bytesNeeded: number,
498+
): number {
499+
const { fragments } = this;
500+
501+
// Collect back buffer fragments with known byte sizes
502+
let count = 0;
503+
const candidates: FragmentEntity[] = [];
504+
const keys = Object.keys(fragments);
505+
for (let i = 0; i < keys.length; i++) {
506+
const entity = fragments[keys[i]];
507+
if (!entity || !entity.buffered || entity.body.type !== levelType) {
508+
continue;
509+
}
510+
const frag = entity.body;
511+
// Use stats.loaded (always set after load) with byteLength as fallback
512+
const bytes = (frag.hasStats && frag.stats.loaded) || frag.byteLength;
513+
if (frag.end <= beforePosition && bytes) {
514+
candidates[count++] = entity;
515+
}
516+
}
517+
518+
if (count === 0) {
519+
return 0;
520+
}
521+
522+
// Sort by sn (oldest first) — small array of references, no data copying.
523+
// Candidates are unsorted because this.fragments is a hash map keyed by
524+
// "type_level_sn", and Object.keys() returns insertion order which depends
525+
// on when each fragment was loaded, not its position in the timeline.
526+
// ABR level switches and seeks can cause fragments to load out of order.
527+
candidates.length = count;
528+
candidates.sort((a, b) => (a.body.sn as number) - (b.body.sn as number));
529+
530+
// Walk oldest-first, accumulating bytes until we have enough
531+
let bytesFreed = 0;
532+
let evictEnd = 0;
533+
for (let i = 0; i < count; i++) {
534+
const frag = candidates[i].body;
535+
const bytes = (frag.hasStats && frag.stats.loaded) || frag.byteLength;
536+
bytesFreed += bytes!;
537+
evictEnd = frag.end;
538+
if (bytesFreed >= bytesNeeded) {
539+
return evictEnd;
540+
}
541+
}
542+
543+
// Not enough to fully cover bytesNeeded, return what we have
544+
return evictEnd > 0 ? evictEnd : 0;
545+
}
546+
485547
public removeFragmentsInRange(
486548
start: number,
487549
end: number,

0 commit comments

Comments
 (0)