SourceBuffer append rejection loops indefinitely on a single variant stream: locally determined gaps do not survive recoverMediaError()
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 16.9k
- Forks
- 2.8k
- Avg merge
- 1d 12h
- Merged PRs (30d)
- 27
Description
What version of HLS.js are you using?
v1.6.13 (a fork of the v1.6.0 line with #7699, #7702 and #7707 backported).
Every code path cited below was re-checked against master at aad2186 (2026-09-09) and is present there, with line numbers given inline. The measurements are from the fork.
What browser (including version) are you using?
Chrome 142.0.7444.176
What OS (including version) are you using?
Windows 10 x64
Test stream
Not publicly shareable. Live LL-HLS fMP4/CMAF, single variant, muxed audiovideo (avc1 + mp4a.40.2), 2s segments with 1s parts, one hour DVR window, delivered over loopback by a peer-assisted delivery agent.
The repro is not stream specific. Any fMP4 stream reaches the same path when one media segment is delivered 200 OK with a corrupted video NAL length prefix and the player has no alternate level to switch to.
Configuration
{
"lowLatencyMode": true,
"autoStartLoad": true,
"startFragPrefetch": true,
"appendErrorMaxRetry": 3,
"manifestLoadingMaxRetry": 3,
"levelLoadingMaxRetry": 1,
"fragLoadingMaxRetry": 3
}
Additional player setup steps
A shim inside the loader mutates exactly one media segment before it reaches the transmuxer: it walks the moof, picks the traf whose sample sizes sum highest (the video track in a muxed segment), resolves the corresponding mdat offset from tfhd.base_data_offset + trun.data_offset, and overwrites a NAL length prefix. The fMP4 boxes still parse; the video sample fails to decode. Response status and headers are untouched.
Targeting the video traf matters. Mutating the first bytes of mdat in a muxed segment hits audio, and the append is accepted. Mutating box sizes produces fragParsingError instead, which is a different class.
Steps to reproduce
- Play the live stream. The playhead can sit at the live edge or inside the DVR window; both reproduce.
- Corrupt the next media segment that has not been appended yet, as described above.
- Let playback reach that segment.
Reproduces on every run in this configuration (8 of 8 across two builds).
Expected behaviour
After appendErrorMaxRetry failed appends of the same fragment, the player either marks that fragment as a gap and advances, or emits a fatal error and stops. Either way the retry count is bounded and the application can act.
What actually happened?
The player enters an unbounded append reject -> recoverMediaError -> detach -> attach -> reload the same fragment loop. In a 90 second observation window we measured 68 and 52 cycles with no end, the corrupted segment requested 58 and 32 times, and the playhead frozen. In production this presented as a 13.6 minute silent freeze with 1,922 recovery cycles.
The first append of the corrupted bytes sets HTMLMediaElement.error to MEDIA_ERR_DECODE. From then on every appendBuffer throws InvalidStateError: The HTMLMediaElement.error attribute is not null, which buffer-controller reclassifies as MEDIA_SOURCE_REQUIRES_RESET. Only a media reset clears the element error, and the reset returns the playhead to the same fragment.
Why the retry budget never binds
1. appendErrors is keyed by SourceBuffer name, not by fragment. buffer-controller.onFragChanged resets it on every successful encounter of buffered media (buffer-controller.ts L1228-1253 on aad2186). During recovery the neighbouring fragments rebuffer successfully, so the reset fires legitimately on every cycle and appendErrorMaxRetry is never reached.
2. A locally determined gap is erased on every recovery cycle. This blocks the natural remedy of marking the fragment as a gap and moving on.
| Where | Code | What it erases |
|---|---|---|
| Media detach | base-stream-controller.onMediaDetaching calls fragmentTracker.removeAllFragments() (L371) |
Every tracker entry, gap entries included |
| Seeking | base-stream-controller.onMediaSeeking calls removeFragmentsInRange(currentTime, Infinity, type, true) (L442), commented Remove gap fragments |
Gap entries ahead of the playhead |
| Reload | fragment-loader.load() sets frag.gap = false when the fragment carries no GAP tag (L81) |
The fragment level gap flag |
recoverMediaError() is detachMedia() then attachMedia() then startLoad(time), so one cycle passes through the first two.
3. stopLoad() does not bound the cycle, because the recovery is started first. In onErrorOut (error-controller.ts L477-506 on aad2186) the ResetMediaSource flag triggers this.hls.recoverMediaError() before the if (data.fatal) this.hls.stopLoad() check. Detach and attach are event driven, so the startLoad(time) inside the recovery runs after stopLoad() has already returned. That matches what we see: the error is fatal on nearly every cycle and loading resumes anyway. The same order is in the published 1.7.2 build:
const flags = data.errorAction?.flags || 0;
if (flags & ErrorActionFlags.ResetMediaSource) {
this.hls.recoverMediaError();
}
if (data.fatal) {
this.hls.stopLoad();
return;
}
4. mapFragmentIntersection does not carry gap across live playlist updates, while mapPartIntersection does. level-helper has newPart.gap = oldPart.gap || newPart.gap for parts (L288) and no equivalent inside the mapFragmentIntersection callback (L168), so a live refresh drops the flag with the replaced Fragment object.
Why a single variant is required
With alternate levels the error resolves through a level switch instead of accumulating. getLevelSwitchAction looks for a level with loadError === 0 (error-controller.ts L403 on aad2186), and level.loadError itself returns to 0 as soon as one fragment from that level buffers (level-controller.ts L604). Because only one segment is corrupted, fragments from the switched-to level append successfully, which also resets appendErrors through point 1. Neither counter reaches its budget, so the error is not promoted.
We measured the same injection over a multi variant CDN path of the same broadcast: with 5 levels the fatal promotion rate was 27 to 29 percent (8/28 and 4/15), against 100 percent (55/55 and 31/31) on the single variant path. Read the 27 to 29 percent as an upper bound, not a rate. Our shim corrupts by segment number, so a level switch still fetched corrupted bytes; real corruption lives in one rendition's bytes, which makes the switch a genuine escape.
Relationship to #7941
#7941 bounds a very similar loop and its remedy is fragmentTracker.addAsGap(). That works there because the appends succeed, so media is never detached and the tracker entry survives. The append rejection path detaches media on every cycle, so the same remedy does not hold. #7941 excludes this path explicitly:
if (cycle?.errored) {
// Counted by the append-error path
return;
}
The append error path referred to is the per SourceBuffer counter in point 1, which does not bind for the reason given.
Console output
# This application build disables hls.js verbose logging, so the log below is the
# ERROR event stream captured with hls.on(Hls.Events.ERROR), plus runtime samples of
# internal state read over CDP. Times are ms from the start of the observation window.
943 bufferAppendingError sn=null part=null fatal=false readyState=ended audiovideo SourceBuffer error. MediaSource readyState: ended
963 mediaSourceRequiresReset sn=5462 part=-1 fatal=true readyState=closed audiovideo SourceBuffer error. MediaSource readyState: ended
3854 bufferAppendingError sn=null part=null fatal=false readyState=ended audiovideo SourceBuffer error. MediaSource readyState: ended
3867 mediaSourceRequiresReset sn=5462 part=-1 fatal=true readyState=closed audiovideo SourceBuffer error. MediaSource readyState: ended
... the same pair repeating every 2 to 3 seconds ...
88692 bufferAppendingError sn=null fatal=false audiovideo SourceBuffer error. MediaSource readyState: ended
88701 mediaSourceRequiresReset sn=5462 fatal=true audiovideo SourceBuffer error. MediaSource readyState: ended
89688 bufferAppendingError sn=null fatal=false audiovideo SourceBuffer error. MediaSource readyState: ended
89698 mediaSourceRequiresReset sn=5462 fatal=true audiovideo SourceBuffer error. MediaSource readyState: ended
# 110 error events, 55 of them fatal, all on sn 5462. Never terminates.
# buffer-controller.appendErrors sampled every 4s during the loop.
# The per SourceBuffer counter never approaches appendErrorMaxRetry (3).
cycles 2 appendErrors={audio:0,video:0,audiovideo:1} appendError=set
cycles 4 appendErrors={audio:0,video:0,audiovideo:1} appendError=set
cycles 10 appendErrors={audio:0,video:0,audiovideo:0} appendError=cleared <- onFragChanged reset
cycles 15 appendErrors={audio:0,video:0,audiovideo:1} appendError=set
cycles 19 appendErrors={audio:0,video:0,audiovideo:1} appendError=set
cycles 24 appendErrors={audio:0,video:0,audiovideo:1} appendError=set
# A per fragment counter added for diagnosis does accumulate, which is what shows the loop is
# stuck on one sn. Separate run on another broadcast, so the sn differs.
appendErrorsByFrag={"main-0-8684":4} ... {"main-0-8684":19} ... {"main-0-8684":36}
# Segment requests over the window (from HAR). 45 distinct segments, the corrupted one 58 times.
sn 5462 -> 58 requests
Chrome media internals output
PIPELINE_ERROR_DECODE: CHUNK_DEMUXER_ERROR_APPEND_FAILED: Failed to prepare video sample for decode
video.error.code = 3 (MEDIA_ERR_DECODE)
Suggested direction
- Count append rejections per fragment, as #7941 already does for no progress cycles.
- Let gap entries survive a media detach. A gap is a judgement about content, not buffer state.
- Carry
gapacross live playlist updates for fragments, mirroring what parts already do. - When the per fragment budget is exhausted, treat the error as a skip rather than a retry: do not promote it to fatal, keep the single MediaSource reset that clears the element error, and mark the fragment so the loader does not fetch it again.
We validated this on a fork. With the same injection the corrupted segment is requested twice and never again, no video.error remains, and playback continues through the following segments.
| Condition | Requests for the corrupted sn | Playhead advanced past it | End state |
|---|---|---|---|
| Before | does not stop (131 in 141s) | no | frozen, no error surfaced |
| After | stops at 2 | yes | playing |
There is no retry count in the shape we settled on. The first rejection cannot tell corrupt bytes from a MediaSource that was already ended, and a media reset settles that, so we remember which MediaSource rejected the fragment and treat the next rejection under a different one as the answer. Counting rejections instead measures nothing: once the first rejection latches HTMLMediaElement.error, every later part or chunk of the same fragment is rejected regardless of its bytes.
Items 2 and 3 look like defects on their own, independent of how the append rejection budget is designed. Happy to open a PR if the maintainers agree on the shape.
Checklist
- The issue observed is not already reported by searching on Github under https://github.com/video-dev/hls.js/issues
- The issue occurs in the stable client (latest release) on https://hlsjs.video-dev.org/demo and not just on my page
- The issue occurs in the latest client (main branch) on https://hlsjs-dev.video-dev.org/demo and not just on my page
- The stream has correct Access-Control-Allow-Origin headers (CORS)
- There are no network errors such as 404s in the browser console when trying to play the stream
The two demo page boxes are unchecked because the repro needs a byte level mutation of one segment in flight, which the hosted demo cannot do. The code paths cited are identical on master. A minimal loader shim that reproduces it against any public fMP4 stream is in the comment below, checked against https://devstreaming-cdn.apple.com/videos/streaming/examples/adv_dv_atmos/main.m3u8 with 1.7.2.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Trace the append-error and recovery paths in buffer-controller.ts, error-controller.ts, base-stream-controller.ts, fragment-loader.ts, and level-helper.ts, starting with the cited handlers and gap state transitions. Use the described single-variant fMP4 corruption scenario to reproduce the loop and compare the behavior with #7941. Done means the corrupted fragment is not fetched indefinitely, gap state survives recovery and playlist updates, and playback advances or a bounded fatal error is surfaced.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, typescript
- Domain
- audio-video-rtc, frontend
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 38/100