Skip to content

Mark fragments as gaps on SourceBuffer errors - #8045

Open
hongjun-bae wants to merge 11 commits into
video-dev:masterfrom
hongjun-bae:bugfix/skip-repeatedly-rejected-fragment
Open

hongjun-bae wants to merge 11 commits into
video-dev:masterfrom
hongjun-bae:bugfix/skip-repeatedly-rejected-fragment

Conversation

@hongjun-bae

@hongjun-bae hongjun-bae commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

This PR will...

Mark a fragment as a gap when its append fails with a SourceBuffer error, so playback continues after the automatic recoverMediaError() instead of loading the same fragment again.

Why is this Pull Request needed?

A SourceBuffer error sets HTMLMediaElement.error, and error-controller resets the MediaSource with recoverMediaError(). Without an alternate level the same fragment is loaded after every reset until append errors escalate to fatal.

Are there any points in the code the reviewer needs to double check?

  • buffer-controller only names the error it creates in onSBUpdateError (HlsJsSourceBufferError). Retry counts, append error counting and fatal escalation are unchanged.
  • stream-controller and audio-stream-controller handle MEDIA_SOURCE_REQUIRES_RESET with that name and mark the fragment with addAsGap(). Fragments of an ended playlist are not marked, so a damaged VOD segment keeps the existing retry path and ends fatal.
  • Gaps are carried across detach with gapFragments(), as suggested. Fragment and part selection skip only fragments marked this way. The seek that follows re-attach skips the gap purge once when such a gap was carried; later seeks clear the tracker entries, so a marked fragment is retried when it is loaded again as a whole segment or after a playlist update.
  • The fragment is marked, not the part. The byte stream parser keeps an incomplete media segment and fails once later bytes do not parse (segment parser loop, append error), so a truncated part is accepted and the error is reported on the next append. Marking the reported part marked healthy media and ended fatal in 2 of 5 runs. The previous fragment is marked too when the buffer ends more than maxBufferHole before the parsed start of the refused fragment or first part, on the inference that its tail never reached the buffer. A damaged first part leaves no such hole, so only its own fragment is marked. Decoder errors do not raise the SourceBuffer error event and are not marked.

Resolves issues:

#8035

Checklist

  • changes have been done against master branch, and PR does not conflict
  • new unit / functional tests have been added (whenever applicable)
  • API or design changes are documented in API.md

The api-extractor report adds FragmentTracker.gapFragments(), FragmentTracker.isGap() and the protected onSourceBufferError(). npm run test:unit passes 1233 tests.

Tested in Chrome on three live LL-HLS streams (2, 4 and 5 parts per segment), over CDN (five variants, 1080p pinned by the player via preserveManualLevelOnError, so every reset came back to the damaged variant) and over a P2P agent (one variant), at the live edge, rewound 120 s into the DVR window (whole-segment loading) and without DVR. One segment was corrupted in the player before append:

Not covered: a tail cut by so few bytes that the next append completes it, or damage the demuxer accepts and only the decoder rejects; those stall on master and here alike. After re-attach, if playback stalls a few ms before the marked fragment, the gap-controller nudge seek clears the mark and the fragment is refused once more (3 of about 60 runs). Automatic level selection was not measured.

@robwalch robwalch left a comment

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 needs to be re-thinked such that more of the existing error handling and selection logic is reused or tuned to handle the problem. This PR only adds new code. This "bandaid" will likely cause new regressions and skip valid media, in addition to addressing whatever is causing #8035.

Comment thread src/controller/base-stream-controller.ts Outdated
Comment thread src/utils/level-helper.ts Outdated
Comment thread src/controller/fragment-tracker.ts Outdated
Comment thread src/controller/buffer-controller.ts Outdated
Comment thread src/controller/buffer-controller.ts Outdated
Comment thread src/controller/base-stream-controller.ts Outdated
Comment thread src/controller/buffer-controller.ts Outdated
Comment thread src/controller/buffer-controller.ts Outdated
Comment thread tests/unit/controller/level-helper.ts Outdated
Comment thread tests/unit/controller/buffer-controller.ts Outdated
@hongjun-bae
hongjun-bae force-pushed the bugfix/skip-repeatedly-rejected-fragment branch from 683294c to 3d145e0 Compare September 20, 2026 11:26
An append rejection sets HTMLMediaElement.error, so every later appendBuffer throws until
the media is rebuilt. buffer-controller reclassifies that as MEDIA_SOURCE_REQUIRES_RESET,
the MediaSource is reset, and the reset puts the playhead back on the same fragment. With
no alternate level to switch to, the cycle runs for as long as the stream is watched.

video-dev#7941 already ends a per-fragment budget in fragmentTracker.addAsGap(), after which
fragment-loader refuses the fragment. The budget did not cover the rejection case, and the
gap it sets did not survive to the next load. Four places dropped it and masked each other:
addAsGap() marked only the object that was appended, frag.stats.retry was incremented after
the event that marks the gap had already been handled, initTracks() cleared the per-fragment
count on every SourceBuffer rebuild, and mergeDetails lost a gap set on a preload hint that
has no duration yet.

The only public addition is fragmentTracker.gapFragments(), so onMediaDetaching can re-add
gaps around removeAllFragments(). Nothing is written to tagList, no part is marked, and
there is no new config, event, error detail, field or error action. Playlist lookups reuse
getFragmentWithSN().

The trade-off is that fragment-loader reads frag.stats.retry as "already given up on", and
that field is also the network retry counter. FragmentLoader has no reference to
FragmentTracker, so no existing signal carries this on its own.

Fixes video-dev#8035
@hongjun-bae
hongjun-bae force-pushed the bugfix/skip-repeatedly-rejected-fragment branch from 3d145e0 to ff85808 Compare September 20, 2026 11:48
`frag.stats.retry` counts network retries as well as the attempt the append
budget spends, so refusing to reset a gap on `retry > 0` alone also refused
fragments that were gapped before any of their bytes reached the SourceBuffer.
A fragment that hit a transient network failure and was later passed to
`treatAsGap` lost the one reload the branch exists to give it, which is the
"skip valid media" case raised in review.

Require the append attempt as well. `stats.buffering.start` is set in
`onBufferAppending` and stays zero for a media fragment gapped from a load or
parse error, so it separates the two sources of a retry without a new field.

Covers both terms: a gap with a retry but no append attempt is reset, and a
gap with an append attempt but no retry is reset.
Comment thread src/controller/buffer-controller.ts Outdated
Comment thread src/controller/base-stream-controller.ts Outdated
Comment thread src/controller/buffer-controller.ts Outdated
Comment thread src/controller/buffer-controller.ts Outdated
Comment thread src/controller/buffer-controller.ts Outdated
Comment thread src/controller/fragment-tracker.ts Outdated
Comment thread src/loader/fragment-loader.ts Outdated
Comment thread src/utils/level-helper.ts Outdated
Comment thread tests/unit/controller/level-helper.ts Outdated
배홍준 added 2 commits September 23, 2026 17:19
Replace the append budget approach. buffer-controller only names the error
created in onSBUpdateError. The stream controllers mark the fragment as a gap
on MEDIA_SOURCE_REQUIRES_RESET with that name, and also the previous fragment
when the first part of a fragment is refused, since the parser reports
incomplete bytes on the following append.

Gaps are carried across detach and kept on the seek that follows re-attach.
Fragment and part selection skip only fragments marked this way. The last
fragment of an ended playlist is not marked.
@hongjun-bae hongjun-bae changed the title Skip a fragment the SourceBuffer keeps rejecting Mark fragments as gaps on SourceBuffer errors Sep 23, 2026
배홍준 added 5 commits September 25, 2026 21:34
…ed first part

The unconditional rule dropped a healthy previous fragment whenever the
first part itself was damaged, as in video-dev#8035 (10 of 10 runs). Compare the
parsed start of the refused part with the buffered range instead; playlist
times drift from the parsed timeline by up to 0.1 s on five-part streams.
addAsGap() clears the parsed info of the fragment's parts, so with separate
audio and video SourceBuffers the previous-fragment rule was silently
disabled. Fragments of an ended playlist are not marked; they keep the
existing retry path, as the load-error gap handling does.
With whole-segment loading (a rewound DVR window, or a rendition without
parts) a truncated tail is accepted and the next segment is refused. The
rule only read the parsed start of a first part, so every reset marked one
more healthy segment (7 to 35 in 60 s). Read the fragment's parsed start
when there is no part; the same input now recovers with one reset.
@hongjun-bae

hongjun-bae commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

@robwalch I believe treating corrupted chunks as gaps rather than errors could significantly improve playback QoE. Any remaining concerns, or are we good to merge?

@robwalch robwalch added this to the 1.7.4 milestone Oct 20, 2026
@robwalch robwalch moved this from Top priorities to Nice to have in HLS.js Release Planning and Backlog Oct 20, 2026
@robwalch robwalch moved this from Nice to have to Top priorities in HLS.js Release Planning and Backlog Oct 22, 2026
Comment on lines +397 to +398
this.keepGapsOnSeek = false;
this.sourceBufferErrorGaps.clear();

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.

We should remove this, and just never remove gaps on first seek (from 0) after reattach. That way, we're not introducing use of Set to the library, or a another state flag (keepGapsOnSeek) to stream controllers.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Removed, with the Set and the flag. onMediaSeeking now keeps gaps until hasEnoughToStart is set and the seek lands outside the buffer, as in the thread below.


function gapKey(frag: Fragment): string {
return `${frag.level}_${frag.sn}`;
}

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.

Remove along with sourceBufferErrorGaps

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Removed.

Comment thread src/controller/base-stream-controller.ts Outdated
Comment on lines +379 to +383
this.keepGapsOnSeek = gaps.some(
(frag) =>
frag.type === this.playlistType &&
this.sourceBufferErrorGaps.has(gapKey(frag)),
);

@robwalch robwalch Oct 22, 2026 •

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.

Remove in favor of keeping gaps on seek to start pos.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Removed. The detach still carries tracked gaps across removeAllFragments().

Comment on lines +455 to +466
if (this.keepGapsOnSeek) {
// Keep gap fragments on the first seek after re-attach
this.keepGapsOnSeek = false;
} else {
// Remove gap fragments
this.fragmentTracker.removeFragmentsInRange(
currentTime,
Infinity,
this.playlistType,
true,
);
}

@robwalch robwalch Oct 22, 2026 •

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.

Use hasEnoughToStart:

Suggested change
if (this.keepGapsOnSeek) {
// Keep gap fragments on the first seek after re-attach
this.keepGapsOnSeek = false;
} else {
// Remove gap fragments
this.fragmentTracker.removeFragmentsInRange(
currentTime,
Infinity,
this.playlistType,
true,
);
}
if (this.hls?.hasEnoughToStart) {
// Remove gap fragments on seek after `seekToStartPos`
this.fragmentTracker.removeFragmentsInRange(
currentTime,
Infinity,
this.playlistType,
true,
);
}

Then, change stream-controller to set _hasEnoughToStart immediately after seekToStartPos():

     if (!this._hasEnoughToStart && BufferHelper.getBuffered(media).length) {
-      this._hasEnoughToStart = true;
       this.seekToStartPos();
+      this._hasEnoughToStart = true;
     }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Applied as suggested, with one more condition: gaps are removed only when the seek lands outside the buffer (!fowardBuffer).

The gate alone left two measured cases:

  • seeking is dispatched from a queued task, so onMediaSeeking already reads hasEnoughToStart === true for the seek issued by seekToStartPos() after re-attach. Rewound into the DVR window, 6 of 6 runs removed the carried gap on that seek and ended fatal.
  • At the live edge the stream controller sometimes restarts with startPosition = -1 and no start seek; the next seek is the gap-controller's buffer-start adjustment (or a nudge), and it removed a gap ahead of the playhead, 4 of 4 runs.

The seeks hls.js issues itself all land inside the buffer. A user seek onto a gap lands outside it and still removes gaps so they are retried. With this, all eight cells (CDN/P2P, LL-HLS/HLS, live edge/rewound) recover with one reset, 52 of 52 runs, and no seek issued by hls.js removed a tracked gap.

Remove the set of SourceBuffer error gaps and the keepGapsOnSeek flag.
Fragment and part selection skip any fragment the tracker holds as a
gap, so a gap carried across detach is skipped until a seek removes it.

onMediaSeeking removes tracked gaps only once hasEnoughToStart is set
and the seek lands outside the buffer. seeking is dispatched from a
queued task, so the seek issued by seekToStartPos() already reads
hasEnoughToStart as true, and the short seeks hls.js issues itself
after re-attach (buffer-start adjustment, hole skip, nudge) all land
inside the buffer. A seek out of the buffer still removes gaps so the
fragments are retried.

Set _hasEnoughToStart after seekToStartPos() as suggested.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

SourceBuffer append rejection loops indefinitely on a single variant stream: locally determined gaps do not survive recoverMediaError()

2 participants