Treat un-demuxable fragments as a gap to prevent infinite reloading (#7900)

* Treat un-demuxable fragments as a gap to prevent infinite reloading

When AES-128 media is served without an #EXT-X-KEY tag, the demuxer probe fails and transmuxer.error is set, so the existing gap guard (which only fires when transmuxer.error is null) never marks the fragment as a gap. The error controller then retries and switches levels indefinitely, reloading the same undecodable segment every ~180-200ms. Mark fragments that fail to demux as a gap (when alternate levels exist) so playback advances instead; with a single level the error is left to escalate to fatal after retries.

* refactor: extract named booleans in gap guard for readability

* Gap-skip only behind the live edge so last-fragment errors escalate to fatal

---------

Authored-by: 배홍준[Media Player Tech] <hongjun.bae@navercorp.com>
This commit is contained in:
brodi
2026-06-11 14:41:32 -07:00
committed by GitHub
parent 0063876609
commit 524302330f
+17 -4
View File
@@ -2118,13 +2118,16 @@ export default class BaseStreamController
couldRetry &&
!errorAction.resolved &&
flags & ErrorActionFlags.MoveAllAlternatesMatchingHost;
const live = this.hls.latestLevelDetails?.live;
const levelDetails = this.hls.latestLevelDetails;
const live = levelDetails?.live;
// Errors on the last live fragment fall through to retry and escalate to
// fatal once retries are exhausted, as they do for VOD.
if (
!retry &&
noAlternate &&
isMediaFragment(frag) &&
!frag.endList &&
live &&
frag.sn < levelDetails.endSN &&
!isUnusableKeyError(data)
) {
this.resetFragmentErrors(filterType);
@@ -2380,12 +2383,22 @@ export default class BaseStreamController
false,
);
if (!parsed) {
// The transmuxer error is null when transmuxing completed without error
// but found no elementary streams (e.g. an empty segment), and non-null
// when an error occurred before any media samples were found.
const mediaNotFound = this.transmuxer?.error === null;
const transmuxerError = this.transmuxer?.error != null;
const hasAlternateLevels = (this.levels?.length ?? 0) > 1;
if (
level.fragmentError === 0 ||
(mediaNotFound && (level.fragmentError < 2 || frag.endList))
(mediaNotFound && (level.fragmentError < 2 || frag.endList)) ||
(transmuxerError && hasAlternateLevels)
) {
// Mark and track the odd (or last) empty segment as a gap to avoid reloading
// Mark and track the empty or errored segment as a gap to avoid
// reloading. A fragment with a transmuxer error is only gapped when
// alternate levels exist, so playback advances instead of reloading
// the same segment after each level switch; with a single level the
// error is left to escalate through error-controller retries.
this.treatAsGap(frag, level);
}
if (mediaNotFound) {