Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api-extractor/report/hls.js.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
108 changes: 102 additions & 6 deletions src/controller/buffer-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SourceBufferName, boolean>> =
{};
// 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.
Expand Down Expand Up @@ -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: () => {
Expand Down Expand Up @@ -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);
Comment on lines +919 to +923
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bit introduced a recursion issue when this._quotaEvictionPending[type]) isn't cleared before the operation is reattempted. See #7776.

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,
Expand All @@ -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 &&
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> | undefined {
if (this.operationQueue) {
return this.operationQueue.appendBlocker(type);
Expand Down
8 changes: 8 additions & 0 deletions src/controller/buffer-operation-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions src/controller/fragment-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,47 @@ export class FragmentTracker implements ComponentAPI {
return !!this.activePartLists[type]?.length;
}

/**
* 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,
levelType: PlaylistLevelType,
bytesNeeded: number,
): number {
const { fragments } = this;

// Collect back buffer fragments with known byte sizes
let bytesFreed = 0;
let evictEnd = 0;
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 ||
(frag.bitrate && frag.bitrate * 8 * frag.duration);
if (frag.end <= beforePosition && bytes) {
bytesFreed += bytes;
evictEnd = Math.max(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,
Expand Down
Loading