Skip to content

Commit dddaed8

Browse files
authored
Recover from QuotaExceededError by evicting back buffer and retrying (#7749)
1 parent 33e9e1a commit dddaed8

4 files changed

Lines changed: 152 additions & 6 deletions

File tree

api-extractor/report/hls.js.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1987,6 +1987,7 @@ export class FragmentTracker implements ComponentAPI {
19871987
// (undocumented)
19881988
fragBuffered(frag: MediaFragment, force?: true): void;
19891989
getAppendedFrag(position: number, levelType: PlaylistLevelType): MediaFragment | Part | null;
1990+
getBackBufferEvictionEnd(beforePosition: number, levelType: PlaylistLevelType, bytesNeeded: number): number;
19901991
getBufferedFrag(position: number, levelType: PlaylistLevelType): MediaFragment | null;
19911992
// (undocumented)
19921993
getFragAtPos(position: number, levelType: PlaylistLevelType, buffered?: boolean): MediaFragment | null;

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: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,47 @@ export class FragmentTracker implements ComponentAPI {
482482
return !!this.activePartLists[type]?.length;
483483
}
484484

485+
/**
486+
* Returns the end position needed to free at least `bytesNeeded` from the
487+
* back buffer, or 0 if not enough data is available. Walks buffered
488+
* fragments in key order, accumulating byte sizes using stats.loaded,
489+
* byteLength, or a bitrate estimate as fallback.
490+
*/
491+
public getBackBufferEvictionEnd(
492+
beforePosition: number,
493+
levelType: PlaylistLevelType,
494+
bytesNeeded: number,
495+
): number {
496+
const { fragments } = this;
497+
498+
// Collect back buffer fragments with known byte sizes
499+
let bytesFreed = 0;
500+
let evictEnd = 0;
501+
const keys = Object.keys(fragments);
502+
for (let i = 0; i < keys.length; i++) {
503+
const entity = fragments[keys[i]];
504+
if (!entity || !entity.buffered || entity.body.type !== levelType) {
505+
continue;
506+
}
507+
const frag = entity.body;
508+
// Use stats.loaded (always set after load) with byteLength as fallback
509+
const bytes =
510+
(frag.hasStats && frag.stats.loaded) ||
511+
frag.byteLength ||
512+
(frag.bitrate && frag.bitrate * 8 * frag.duration);
513+
if (frag.end <= beforePosition && bytes) {
514+
bytesFreed += bytes;
515+
evictEnd = Math.max(evictEnd, frag.end);
516+
if (bytesFreed >= bytesNeeded) {
517+
return evictEnd;
518+
}
519+
}
520+
}
521+
522+
// Not enough to fully cover bytesNeeded, return what we have
523+
return evictEnd > 0 ? evictEnd : 0;
524+
}
525+
485526
public removeFragmentsInRange(
486527
start: number,
487528
end: number,

0 commit comments

Comments
 (0)