From d972a236a4324d87bd37e63a54622c05492bd8d3 Mon Sep 17 00:00:00 2001 From: alchemyyy Date: Thu, 12 Mar 2026 15:41:33 -0700 Subject: [PATCH 1/3] fix: recover from QuotaExceededError by evicting back buffer and retrying append MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (video-dev/hls.js#6776, video-dev/hls.js#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 --- src/controller/buffer-controller.ts | 108 +++++++++++++++++++++-- src/controller/buffer-operation-queue.ts | 8 ++ src/controller/fragment-tracker.ts | 62 +++++++++++++ 3 files changed, 172 insertions(+), 6 deletions(-) diff --git a/src/controller/buffer-controller.ts b/src/controller/buffer-controller.ts index 31640e9ae8d..bddf826c332 100755 --- a/src/controller/buffer-controller.ts +++ b/src/controller/buffer-controller.ts @@ -118,6 +118,12 @@ export default class BufferController extends Logger implements ComponentAPI { audiovideo: 0, }; private appendError?: ErrorData; + // Tracks whether a QuotaExceededError back-buffer eviction is in progress + // for a given SourceBuffer type. While true, subsequent QuotaExceededErrors + // on the same type piggyback on the pending eviction instead of triggering + // their own or emitting BUFFER_FULL_ERROR. + private _quotaEvictionPending: Partial> = + {}; // Record of required or created buffers by type. SourceBuffer is stored in Track.buffer once created. private tracks: SourceBufferTrackSet = {}; // 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 } const fragStart = (part || frag).start; + let quotaEvictionAttempted = false; const operation: BufferOperation = { label: `append-${type}`, execute: () => { @@ -900,6 +907,46 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe }, onError: (error: Error) => { this.clearBufferAppendTimeoutId(this.tracks[type]); + + const isQuotaError = + (error as DOMException).code === DOMException.QUOTA_EXCEEDED_ERR || + error.name == 'QuotaExceededError' || + `quota` in error; + + if (isQuotaError) { + // An eviction is already in flight for this type — piggyback on it + // by queuing a retry after the pending remove, no new eviction needed. + if (this._quotaEvictionPending[type]) { + this.log( + `QuotaExceededError on "${type}" sn: ${sn} — eviction already pending, queuing retry`, + ); + this.insertNext([operation], type); + return; + } + + // First QuotaExceededError for this type: evict minimum back buffer + if (!quotaEvictionAttempted) { + const evictEnd = this.getBackBufferEvictionTarget( + type, + data.byteLength, + frag.type, + ); + if (evictEnd > 0) { + quotaEvictionAttempted = true; + this._quotaEvictionPending[type] = true; + this.log( + `QuotaExceededError on "${type}" append sn: ${sn} — evicting back buffer to ${evictEnd.toFixed(3)}s and retrying`, + ); + const removeOp = this.getQuotaEvictionFlushOp(type, 0, evictEnd); + this.insertNext([removeOp, operation], type); + return; + } + this.warn( + `QuotaExceededError on "${type}" sn: ${sn} — no back buffer available to evict`, + ); + } + } + // in case any error occured while appending, put back segment in segments table const event: ErrorData = { type: ErrorTypes.MEDIA_ERROR, @@ -914,13 +961,9 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe fatal: false, }; const mediaError = this.media?.error; - if ( - (error as DOMException).code === DOMException.QUOTA_EXCEEDED_ERR || - error.name == 'QuotaExceededError' || - `quota` in error - ) { + if (isQuotaError) { // QuotaExceededError: http://www.w3.org/TR/html5/infrastructure.html#quotaexceedederror - // let's stop appending any segments, and report BUFFER_FULL_ERROR error + // Eviction was already attempted or not possible — report BUFFER_FULL_ERROR event.details = ErrorDetails.BUFFER_FULL_ERROR; } else if ( (error as DOMException).code === DOMException.INVALID_STATE_ERR && @@ -971,6 +1014,33 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe this.append(operation, type, this.isPending(this.tracks[type])); } + // Like getFlushOp but clears _quotaEvictionPending on complete/error + private getQuotaEvictionFlushOp( + type: SourceBufferName, + start: number, + end: number, + ): BufferOperation { + this.log(`queuing quota-eviction "${type}" remove ${start}-${end}`); + return { + label: 'remove', + execute: () => { + this.removeExecutor(type, start, end); + }, + onStart: () => {}, + onComplete: () => { + this._quotaEvictionPending[type] = false; + this.hls.trigger(Events.BUFFER_FLUSHED, { type }); + }, + onError: (error: Error) => { + this._quotaEvictionPending[type] = false; + this.warn( + `Failed to remove ${start}-${end} from "${type}" SourceBuffer (quota eviction)`, + error, + ); + }, + }; + } + private getFlushOp( type: SourceBufferName, start: number, @@ -1198,6 +1268,26 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe } } + // Returns the end position to evict back buffer to on QuotaExceededError, + // or 0 if there is nothing to evict. Delegates to the fragment tracker which + // walks buffered fragments oldest-first using actual byte sizes to find the + // minimum eviction needed to fit the new segment. + private getBackBufferEvictionTarget( + type: SourceBufferName, + segmentBytes: number, + playlistType: PlaylistLevelType, + ): number { + const { media } = this; + if (!media) { + return 0; + } + return this.fragmentTracker.getBackBufferEvictionEnd( + media.currentTime, + playlistType, + segmentBytes, + ); + } + private resetAppendErrors() { this.appendErrors = { audio: 0, @@ -1940,6 +2030,12 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe } } + private insertNext(operations: BufferOperation[], type: SourceBufferName) { + if (this.operationQueue) { + this.operationQueue.insertNext(operations, type); + } + } + private appendBlocker(type: SourceBufferName): Promise | undefined { if (this.operationQueue) { return this.operationQueue.appendBlocker(type); diff --git a/src/controller/buffer-operation-queue.ts b/src/controller/buffer-operation-queue.ts index c2765b53a24..eb1efff25ec 100644 --- a/src/controller/buffer-operation-queue.ts +++ b/src/controller/buffer-operation-queue.ts @@ -79,6 +79,14 @@ export default class BufferOperationQueue { ); } + public insertNext(operations: BufferOperation[], type: SourceBufferName) { + if (this.queues === null) { + return; + } + // Insert after the current (index 0) operation so they execute next + this.queues[type].splice(1, 0, ...operations); + } + public unblockAudio(op: BufferOperation) { if (this.queues === null) { return; diff --git a/src/controller/fragment-tracker.ts b/src/controller/fragment-tracker.ts index 3e5b5a2e43a..3154a2bfc23 100644 --- a/src/controller/fragment-tracker.ts +++ b/src/controller/fragment-tracker.ts @@ -482,6 +482,68 @@ export class FragmentTracker implements ComponentAPI { return !!this.activePartLists[type]?.length; } + /** + * Returns the eviction end position needed to free at least `bytesNeeded` + * from the back buffer, or 0 if not enough data is available. + * Walks buffered fragments oldest-first, accumulating actual byte sizes. + * + * Uses stats.loaded (actual bytes received) as the primary byte measure + * since it is always set after fragment completion, unlike stats.total + * which depends on Content-Length and may be 0 with chunked transfer. + */ + public getBackBufferEvictionEnd( + beforePosition: number, + levelType: PlaylistLevelType, + bytesNeeded: number, + ): number { + const { fragments } = this; + + // Collect back buffer fragments with known byte sizes + let count = 0; + const candidates: FragmentEntity[] = []; + const keys = Object.keys(fragments); + for (let i = 0; i < keys.length; i++) { + const entity = fragments[keys[i]]; + if (!entity || !entity.buffered || entity.body.type !== levelType) { + continue; + } + const frag = entity.body; + // Use stats.loaded (always set after load) with byteLength as fallback + const bytes = (frag.hasStats && frag.stats.loaded) || frag.byteLength; + if (frag.end <= beforePosition && bytes) { + candidates[count++] = entity; + } + } + + if (count === 0) { + return 0; + } + + // Sort by sn (oldest first) — small array of references, no data copying. + // Candidates are unsorted because this.fragments is a hash map keyed by + // "type_level_sn", and Object.keys() returns insertion order which depends + // on when each fragment was loaded, not its position in the timeline. + // ABR level switches and seeks can cause fragments to load out of order. + candidates.length = count; + candidates.sort((a, b) => (a.body.sn as number) - (b.body.sn as number)); + + // Walk oldest-first, accumulating bytes until we have enough + let bytesFreed = 0; + let evictEnd = 0; + for (let i = 0; i < count; i++) { + const frag = candidates[i].body; + const bytes = (frag.hasStats && frag.stats.loaded) || frag.byteLength; + bytesFreed += bytes!; + evictEnd = frag.end; + if (bytesFreed >= bytesNeeded) { + return evictEnd; + } + } + + // Not enough to fully cover bytesNeeded, return what we have + return evictEnd > 0 ? evictEnd : 0; + } + public removeFragmentsInRange( start: number, end: number, From 55f903acaa647f835ba3a1b9710ea870ca29a2b9 Mon Sep 17 00:00:00 2001 From: alchemyyy Date: Mon, 16 Mar 2026 15:45:19 -0700 Subject: [PATCH 2/3] update candidate sort to use frag.end instead of sn. update api doc. --- api-extractor/report/hls.js.api.md | 1 + src/controller/fragment-tracker.ts | 10 ++++------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/api-extractor/report/hls.js.api.md b/api-extractor/report/hls.js.api.md index b9325c66dd9..52d5f280dce 100644 --- a/api-extractor/report/hls.js.api.md +++ b/api-extractor/report/hls.js.api.md @@ -1987,6 +1987,7 @@ export class FragmentTracker implements ComponentAPI { // (undocumented) fragBuffered(frag: MediaFragment, force?: true): void; getAppendedFrag(position: number, levelType: PlaylistLevelType): MediaFragment | Part | null; + getBackBufferEvictionEnd(beforePosition: number, levelType: PlaylistLevelType, bytesNeeded: number): number; getBufferedFrag(position: number, levelType: PlaylistLevelType): MediaFragment | null; // (undocumented) getFragAtPos(position: number, levelType: PlaylistLevelType, buffered?: boolean): MediaFragment | null; diff --git a/src/controller/fragment-tracker.ts b/src/controller/fragment-tracker.ts index 3154a2bfc23..0ba544eb588 100644 --- a/src/controller/fragment-tracker.ts +++ b/src/controller/fragment-tracker.ts @@ -519,13 +519,11 @@ export class FragmentTracker implements ComponentAPI { return 0; } - // Sort by sn (oldest first) — small array of references, no data copying. - // Candidates are unsorted because this.fragments is a hash map keyed by - // "type_level_sn", and Object.keys() returns insertion order which depends - // on when each fragment was loaded, not its position in the timeline. - // ABR level switches and seeks can cause fragments to load out of order. + // Sort by end time (earliest first). Using frag.end rather than sn because + // the tracker may contain fragments from different HLS variants where + // media sequence numbers are not guaranteed to align across levels. candidates.length = count; - candidates.sort((a, b) => (a.body.sn as number) - (b.body.sn as number)); + candidates.sort((a, b) => a.body.end - b.body.end); // Walk oldest-first, accumulating bytes until we have enough let bytesFreed = 0; From 01830cbd30ada84f424e4baee966e329b146cb37 Mon Sep 17 00:00:00 2001 From: alchemyyy Date: Mon, 16 Mar 2026 17:20:48 -0700 Subject: [PATCH 3/3] fix: simplify back buffer eviction --- src/controller/fragment-tracker.ts | 49 +++++++++--------------------- 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/src/controller/fragment-tracker.ts b/src/controller/fragment-tracker.ts index 0ba544eb588..1f774f0a03d 100644 --- a/src/controller/fragment-tracker.ts +++ b/src/controller/fragment-tracker.ts @@ -483,13 +483,10 @@ export class FragmentTracker implements ComponentAPI { } /** - * Returns the eviction end position needed to free at least `bytesNeeded` - * from the back buffer, or 0 if not enough data is available. - * Walks buffered fragments oldest-first, accumulating actual byte sizes. - * - * Uses stats.loaded (actual bytes received) as the primary byte measure - * since it is always set after fragment completion, unlike stats.total - * which depends on Content-Length and may be 0 with chunked transfer. + * Returns the end position needed to free at least `bytesNeeded` from the + * back buffer, or 0 if not enough data is available. Walks buffered + * fragments in key order, accumulating byte sizes using stats.loaded, + * byteLength, or a bitrate estimate as fallback. */ public getBackBufferEvictionEnd( beforePosition: number, @@ -499,8 +496,8 @@ export class FragmentTracker implements ComponentAPI { const { fragments } = this; // Collect back buffer fragments with known byte sizes - let count = 0; - const candidates: FragmentEntity[] = []; + let bytesFreed = 0; + let evictEnd = 0; const keys = Object.keys(fragments); for (let i = 0; i < keys.length; i++) { const entity = fragments[keys[i]]; @@ -509,32 +506,16 @@ export class FragmentTracker implements ComponentAPI { } const frag = entity.body; // Use stats.loaded (always set after load) with byteLength as fallback - const bytes = (frag.hasStats && frag.stats.loaded) || frag.byteLength; + const bytes = + (frag.hasStats && frag.stats.loaded) || + frag.byteLength || + (frag.bitrate && frag.bitrate * 8 * frag.duration); if (frag.end <= beforePosition && bytes) { - candidates[count++] = entity; - } - } - - if (count === 0) { - return 0; - } - - // Sort by end time (earliest first). Using frag.end rather than sn because - // the tracker may contain fragments from different HLS variants where - // media sequence numbers are not guaranteed to align across levels. - candidates.length = count; - candidates.sort((a, b) => a.body.end - b.body.end); - - // Walk oldest-first, accumulating bytes until we have enough - let bytesFreed = 0; - let evictEnd = 0; - for (let i = 0; i < count; i++) { - const frag = candidates[i].body; - const bytes = (frag.hasStats && frag.stats.loaded) || frag.byteLength; - bytesFreed += bytes!; - evictEnd = frag.end; - if (bytesFreed >= bytesNeeded) { - return evictEnd; + bytesFreed += bytes; + evictEnd = Math.max(evictEnd, frag.end); + if (bytesFreed >= bytesNeeded) { + return evictEnd; + } } }