Skip to content
Open
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
109 changes: 103 additions & 6 deletions src/controller/buffer-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ 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 +860,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 +908,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,
Expand All @@ -914,13 +962,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 +1015,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 +1269,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 +2031,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
61 changes: 61 additions & 0 deletions src/controller/fragment-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,67 @@ 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[] = [];
for (const key of Object.keys(fragments)) {
const entity = fragments[key];
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,
Expand Down
Loading