Prevent loop-loading of segments with dropped appends (#7797)

* Prevent loop-loading of segments that do not change SourceBuffer buffered time ranges
Moves `detectEvictedFragments` to `detectPartialFragments` (all work performed after async yield to sb "updated" instead of some sync and some async)
Refactor `isEndListAppended` workaround added in #7754 to resolve #7770 on playlist update

- #7774 Seeking broken with fMP4 passthrough for h264 open-GOP content
- #7546 Playlist which plays well in Firefox fails in Chrome by requesting same segment repeatedly
- #6711 There is a segment in the hls video that loads many times (QuotaExceededError)
- #7770 Fix FragmentTracker.isEndListAppended after Live to VOD transition

* Address hongjun-bae's feedback
This commit is contained in:
Rob Walch
2026-04-13 14:01:51 -07:00
committed by GitHub
parent b94cd21044
commit e97638fec4
11 changed files with 121 additions and 43 deletions
+3 -1
View File
@@ -1785,7 +1785,7 @@ export interface FragBufferedData {
// (undocumented)
frag: Fragment;
// (undocumented)
id: string;
id: PlaylistLevelType;
// (undocumented)
part: Part | null;
// (undocumented)
@@ -2035,6 +2035,8 @@ export const enum FragmentState {
export class FragmentTracker implements ComponentAPI {
constructor(hls: Hls);
// (undocumented)
addAsGap(frag: MediaFragment): void;
// (undocumented)
destroy(): void;
detectEvictedFragments(elementaryStream: SourceBufferName, timeRange: TimeRanges, playlistType: PlaylistLevelType, appendedPart?: Part | null, removeAppending?: boolean): void;
detectPartialFragments(data: FragBufferedData): void;
+41 -25
View File
@@ -299,20 +299,7 @@ export default class BaseStreamController
const lastFragment =
levelDetails.fragments[levelDetails.fragments.length - 1];
const playlistType = lastFragment.type;
if (this.fragmentTracker.isEndListAppended(playlistType)) {
return true;
}
// When a live playlist transitions to VOD (ENDLIST added), the last
// fragment in the updated playlist has endList=true but the fragment
// tracker entity was created before ENDLIST was known. Check if the
// last fragment is actually buffered even though endListFragments
// was never populated for it.
if (lastFragment.endList) {
const state = this.fragmentTracker.getState(lastFragment);
return state === FragmentState.OK || state === FragmentState.PARTIAL;
}
return false;
return this.fragmentTracker.isEndListAppended(lastFragment.type);
}
public getLevelDetails(): LevelDetails | undefined {
@@ -654,8 +641,26 @@ export default class BaseStreamController
protected checkLiveUpdate(details: LevelDetails) {
if (details.updated && !details.live) {
// Live stream ended, update fragment tracker
const fragmentTracker = this.fragmentTracker;
const lastFragment = details.fragments[details.fragments.length - 1];
this.fragmentTracker.detectPartialFragments({
if (
lastFragment.endList &&
!fragmentTracker.isEndListAppended(this.playlistType)
) {
// When a live playlist transitions to VOD (ENDLIST added), the last
// fragment in the updated playlist has endList=true but the fragment
// tracker entity was created before ENDLIST was known. Check if the
// last fragment is actually buffered even though endListFragments
// was never populated for it. (#7770)
const fragmentAtEnd = fragmentTracker.getPartialFragment(
lastFragment.end,
);
if (fragmentAtEnd) {
fragmentTracker.removeFragment(fragmentAtEnd);
fragmentTracker.fragBuffered(lastFragment, true);
}
}
fragmentTracker.detectPartialFragments({
frag: lastFragment,
part: null,
stats: lastFragment.stats,
@@ -839,11 +844,15 @@ export default class BaseStreamController
}
}
const level = this.levels?.[frag.level];
if (level?.fragmentError) {
if (
level?.fragmentError &&
(part || this.fragmentTracker.getState(frag) !== FragmentState.PARTIAL)
) {
this.log(
`Resetting level fragment error count of ${level.fragmentError} on frag buffered`,
);
level.fragmentError = 0;
frag.stats.retry = 0;
}
}
this.state = State.IDLE;
@@ -1506,10 +1515,19 @@ export default class BaseStreamController
return false;
}
const trackerState = this.fragmentTracker.getState(frag);
return (
trackerState === FragmentState.OK ||
(trackerState === FragmentState.PARTIAL && !!frag.gap)
);
if (trackerState === FragmentState.OK) {
return true;
}
if (trackerState === FragmentState.PARTIAL) {
if (frag.gap) {
return true;
}
if (frag.stats.retry > 1) {
return true;
}
frag.stats.retry++;
}
return false;
}
protected getNextFragmentLoopLoading(
@@ -2011,7 +2029,7 @@ export default class BaseStreamController
}
const gapTagEncountered = data.details === ErrorDetails.FRAG_GAP;
if (gapTagEncountered) {
this.fragmentTracker.fragBuffered(frag as MediaFragment, true);
this.fragmentTracker.addAsGap(frag as MediaFragment);
}
// keep retrying until the limit will be reached
const errorAction = data.errorAction;
@@ -2340,9 +2358,7 @@ export default class BaseStreamController
if (level) {
level.fragmentError++;
}
frag.gap = true;
this.fragmentTracker.removeFragment(frag);
this.fragmentTracker.fragBuffered(frag, true);
this.fragmentTracker.addAsGap(frag);
}
protected resetTransmuxer() {
@@ -2621,7 +2637,7 @@ export default class BaseStreamController
}
}
function interstitialsEnabled(config: HlsConfig): boolean {
function interstitialsEnabled(config: Readonly<HlsConfig>): boolean {
return (
__USE_INTERSTITIALS__ &&
!!config.interstitialsController &&
+7 -1
View File
@@ -1018,11 +1018,12 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
this.append(operation, type, this.isPending(this.tracks[type]));
}
private getClearEvictionPendingOp(type: string): BufferOperation {
private getClearEvictionPendingOp(type: SourceBufferName): BufferOperation {
return {
label: 'clear',
execute: () => {
this._quotaEvictionPending[type] = false;
this.shiftAndExecuteNext(type);
},
onStart: () => {},
onComplete: () => {},
@@ -1114,6 +1115,11 @@ transfer tracks: ${stringify(transferredTracks, (key, value) => (key === 'initSe
stats,
id: frag.type,
});
if (!part && frag.gap && stats.retry) {
this.log(
`Nothing buffered for ${frag.type} level: ${frag.level} sn: ${frag.sn} retries ${stats.retry}`,
);
}
};
if (buffersAppendedTo.length === 0) {
+46 -11
View File
@@ -1,6 +1,11 @@
import { Events } from '../events';
import {
type Fragment,
isMediaFragment,
type MediaFragment,
type Part,
} from '../loader/fragment';
import type Hls from '../hls';
import type { Fragment, MediaFragment, Part } from '../loader/fragment';
import type { SourceBufferName } from '../types/buffer';
import type { ComponentAPI } from '../types/component-api';
import type {
@@ -204,11 +209,10 @@ export class FragmentTracker implements ComponentAPI {
*/
public detectPartialFragments(data: FragBufferedData) {
const timeRanges = this.timeRanges;
if (!timeRanges || data.frag.sn === 'initSegment') {
const { frag, part, id: playlistType } = data;
if (!timeRanges || !isMediaFragment(frag)) {
return;
}
const frag = data.frag as MediaFragment;
const fragKey = getFragmentKey(frag);
const fragmentEntity = this.fragments[fragKey];
if (!fragmentEntity || (fragmentEntity.buffered && frag.gap)) {
@@ -228,20 +232,49 @@ export class FragmentTracker implements ComponentAPI {
partial,
timeRange,
);
this.detectEvictedFragments(
elementaryStream,
timeRange,
playlistType,
part,
);
});
fragmentEntity.loaded = null;
if (Object.keys(fragmentEntity.range).length) {
const trackNames = Object.keys(fragmentEntity.range) as SourceBufferName[];
if (trackNames.length) {
this.bufferedEnd(fragmentEntity, frag);
if (!isPartial(fragmentEntity)) {
// Remove older fragment parts from lookup after frag is tracked as buffered
this.removeParts(frag.sn - 1, frag.type);
}
// Detect nothing buffered for segment append (open-GOP issue #7774)
if (!part) {
const minPartialAppend = Math.min(0.004, frag.duration);
trackNames.some((elementaryStream) => {
const times = fragmentEntity.range[elementaryStream]?.time;
const bufferedGap =
times.length === 0 ||
(times.length === 1 &&
times[0].endPTS - times[0].startPTS < minPartialAppend);
if (bufferedGap) {
// Segment was appended but it did not change buffer. Mark as gap to prevent loop loading.
this.addAsGap(frag);
}
return bufferedGap;
});
}
} else {
// remove fragment if nothing was appended
this.removeFragment(fragmentEntity.body);
this.removeFragment(frag);
}
}
public addAsGap(frag: MediaFragment) {
frag.gap = true;
this.removeFragment(frag);
this.fragBuffered(frag, true);
}
private bufferedEnd(fragmentEntity: FragmentEntity, frag: MediaFragment) {
fragmentEntity.buffered = true;
const endList = (fragmentEntity.body.endList =
@@ -414,7 +447,7 @@ export class FragmentTracker implements ComponentAPI {
private onFragLoaded(event: Events.FRAG_LOADED, data: FragLoadedData) {
// don't track initsegment (for which sn is not a number)
// don't track frags used for bitrateTest, they're irrelevant.
if (data.frag.sn === 'initSegment' || data.frag.bitrateTest) {
if (!isMediaFragment(data.frag) || data.frag.bitrateTest) {
return;
}
@@ -436,8 +469,8 @@ export class FragmentTracker implements ComponentAPI {
event: Events.BUFFER_APPENDED,
data: BufferAppendedData,
) {
const { frag, part, timeRanges, type } = data;
if (frag.sn === 'initSegment') {
const { frag, part, timeRanges } = data;
if (!isMediaFragment(frag)) {
return;
}
const playlistType = frag.type;
@@ -450,8 +483,6 @@ export class FragmentTracker implements ComponentAPI {
}
// Store the latest timeRanges loaded in the buffer
this.timeRanges = timeRanges;
const timeRange = timeRanges[type] as TimeRanges;
this.detectEvictedFragments(type, timeRange, playlistType, part);
}
private onFragBuffered(event: Events.FRAG_BUFFERED, data: FragBufferedData) {
@@ -505,6 +536,10 @@ export class FragmentTracker implements ComponentAPI {
continue;
}
const frag = entity.body;
if (frag.gap) {
// do not evict media when gaps are detected
return 0;
}
// Use stats.loaded (always set after load) with byteLength as fallback
const bytes =
(frag.hasStats && frag.stats.loaded) ||
+10
View File
@@ -960,10 +960,20 @@ export default class StreamController
}
return;
}
let fragError = false;
if (isMediaFragment(frag)) {
this.fragPrevious = frag;
fragError =
!!frag.gap && !frag.tagList.some((tags) => tags[0] === 'GAP');
}
this.fragBufferedComplete(frag, part);
if (fragError) {
frag.stats.retry++;
const level = this.levels?.[frag.level];
if (level) {
level.fragmentError++;
}
}
}
const media = this.media;
+1 -1
View File
@@ -207,7 +207,7 @@ export class SubtitleStreamController
if (frag?.type === PlaylistLevelType.SUBTITLE) {
if (data.details === ErrorDetails.FRAG_GAP) {
this.fragmentTracker.fragBuffered(frag as MediaFragment, true);
this.fragmentTracker.addAsGap(frag as MediaFragment);
}
if (this.fragCurrent) {
this.fragCurrent.abortRequests();
+1
View File
@@ -97,6 +97,7 @@ export default class FragmentLoader {
highWaterMark: frag.sn === 'initSegment' ? Infinity : MIN_CHUNK_SIZE,
};
// Assign frag stats to the loader's stats reference
loader.stats.retry = frag.stats.retry;
frag.stats = loader.stats;
const callbacks: LoaderCallbacks<FragmentLoaderContext> = {
onSuccess: (response, stats, context, networkDetails) => {
+1 -1
View File
@@ -451,7 +451,7 @@ export interface FragBufferedData {
stats: LoadStats;
frag: Fragment;
part: Part | null;
id: string;
id: PlaylistLevelType;
}
export interface LevelsUpdatedData {
+1 -1
View File
@@ -246,7 +246,7 @@ function loadAndBufferFragment(
frag,
part: null,
stats: frag.stats,
id: 'video',
id: frag.type,
},
);
}
+9 -1
View File
@@ -149,12 +149,20 @@ describe('FragmentTracker', function () {
Events.BUFFER_APPENDED,
createBufferAppendedData([
{
startPTS: 0.75,
startPTS: 1,
endPTS: 2,
},
]),
);
const fragment2 = addFragment();
hls.trigger(
Events.FRAG_BUFFERED,
createFragBufferedData(fragment2, true),
);
expect(fragment.gap).to.equal(undefined);
expect(fragmentTracker.getState(fragment)).to.equal(
FragmentState.NOT_LOADED,
);
+1 -1
View File
@@ -495,7 +495,7 @@ describe('StreamController', function () {
stats: new LoadStats(),
frag: firstFrag,
part: null,
id: 'main',
id: firstFrag.type,
});
expect(seekStub).to.have.been.calledOnce;
});