Skip to content

Conversation

@robwalch
Copy link
Collaborator

@robwalch robwalch commented Jan 16, 2026

This PR will...

Restart loading in recoverMediaError when autoStartLoad is disabled.

Why is this Pull Request needed?

Because otherwise, detachMedia will stop all loading, and attachMedia will not restart it when autoStartLoad is set to false.

The problem when testing the fix in #7683 (call recoverMediaError when MediaSource of attached media element is closed) for the steps in #7687 is that with autoStartLoad: false, loading is not automatically restarted. This change ensures that loading is restarted when hls.started is true (meaning hls.stopLoad() was never called).

Resolves issues:

Fixes #7687

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

@robwalch
Copy link
Collaborator Author

CC @gjanblaszczyk @zalishchuk @christriants. Please review.

@zalishchuk
Copy link

@robwalch I'm unsure if this will resolve the issue, as bufferAppendError errors occur every "cold start" regardless of the autoStartLoad property being set to false. After these errors occur, tapping the "Start loading" button doesn't change the state, only the "Recover media-error" button does.

error.MP4

@zalishchuk
Copy link

zalishchuk commented Jan 16, 2026

If I understand the issue correctly, the problem is that in iOS 26+ Safari cold starts and cache resets, ManagedMediaSource transitions to the ended state before any data is buffered. This causes appendBuffer() to fail. One possible approach is to check mediaSource.readyState before attempting buffer operations. If it's not open and we haven't buffered anything yet, that's clearly an unexpected state, and we could trigger recoverMediaError() right there instead of waiting for the operation to fail.

Dummycode:

if (mediaSource.readyState !== 'open') {
  if (mediaSource.readyState === 'ended' && !this.hasBufferedData) {
    this.hls.recoverMediaError();
  }
  
  return;
}

Not 100% sure this is the right fix, though. It feels a bit like treating the symptom rather than the root cause. Also, it's not clear if there are edge cases where MediaSource legitimately isn't open, but we shouldn't recover.

When reviewing the debug logs, we see a "Media source opened" message, but there’s no sign of a "Media source ended" before the error. Instead, it jumps directly to readyState: ended. This suggests that the MediaSource isn't ending in the usual way, it's just marking itself as done.

Logs
[Log] [log] > – "Debug logs enabled for \"Hls instance\" in hls.js version 1.6.15" (hls.min.js, line 1)
[Log] [log] > – "stopLoad" (hls.min.js, line 1)
[Log] [log] > – "loadSource:https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8" (hls.min.js, line 1)
[Log] [log] > – "[interstitials]:" – "clear schedule state" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Trigger BUFFER_RESET" (hls.min.js, line 1)
[Log] [log] > – "attachMedia" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "created media source: ManagedMediaSource" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "Media source opened" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "checkPendingTracks (pending: 0 codec events expected: 0) {}" (hls.min.js, line 1)
[Log] [log] > – "[level-controller]:" – "manifest loaded, 5 level(s) found, first bitrate: 2149280" (hls.min.js, line 1)
[Log] [log] > – "[abr]:" – "setting initial bwe to 2149280" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "1 bufferCodec event(s) expected." (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Both AAC/HE-AAC audio found in levels; declaring level codec as HE-AAC" (hls.min.js, line 1)
[Log] [log] > – "auto startLoad with configured startPosition -1" (hls.min.js, line 1)
[Log] [log] > – "startLoad(-1)" (hls.min.js, line 1)
[Log] [log] > – "[abr]:" – "picked start tier {\"codecSet\":\"avc1,mp4a\",\"videoRanges\":[\"SDR\"],\"preferHDR\":false,\"minFramerate\":0,\"minBitrate\":246440,\"minIndex\":0}" (hls.min.js, line 1)
[Info] [info] > – "[abr]:" – "switch candidate:3->3 adjustedbw(2149280)-bitrate=0 ttfb:0.1 avgDuration:0.0 maxFetchDuration:4.0 fetchDuration:0.2 firstSelection:tru…" (hls.min.js, line 1)
"switch candidate:3->3 adjustedbw(2149280)-bitrate=0 ttfb:0.1 avgDuration:0.0 maxFetchDuration:4.0 fetchDuration:0.2 firstSelection:true codecSet:avc1,mp4a videoRange:SDR hls.loadLevel:-1"
[Log] [log] > – "[level-controller]:" – "Switching to level 3 (720p SDR avc1,mp4a @2149280) from level -1" (hls.min.js, line 1)
[Log] [log] > – "[level-controller]:" – "Loading level index 3 https://test-streams.mux.dev/x36xhzz/url_0/193039199_mp4_h264_aac_hd_7.m3u8" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "STOPPED->IDLE" (hls.min.js, line 1)
[Log] [log] > – "[subtitle-stream-controller]:" – "STOPPED->IDLE" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Level 3 loaded [0,63][part-63--1], cc [0, 0] duration:634.584" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "setting startPosition to 0 by default" (hls.min.js, line 1)
[Log] [log] > – "[interstitials]:" – "[checkStart] Advancing timeline position to 0" (hls.min.js, line 1)
[Log] [log] > – "[interstitials]:" – "setSchedulePosition 0, undefined ([primary: 0.00-634.58]) pos: 0" (hls.min.js, line 1)
[Log] [log] > – "[interstitials]:" – "INTERSTITIALS_BUFFERED_TO_BOUNDARY [primary: 0.00-634.58]" (hls.min.js, line 1)
[Log] [log] > – "[interstitials]:" – "resuming [primary: 0.00-634.58]" (hls.min.js, line 1)
[Log] [log] > – "[interstitials]:" – "[attachPrimary] Advancing timeline position to 0" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "Updating MediaSource duration to 634.584" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Loading main sn: 0 of level 3 (frag:[0.000-10.000]) cc: 0 [0-63], target: 0" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "IDLE->FRAG_LOADING" (hls.min.js, line 1)
[Log] [log] > – "injecting Web Worker for \"main\"" (hls.min.js, line 1)
[Log] [log] > – "[transmuxer-interface]: Starting new transmux session for main sn: 0 level: 3 id: 1↵        discontinuity: true↵        trackSwitch: true↵…" (hls.min.js, line 1)
"[transmuxer-interface]: Starting new transmux session for main sn: 0 level: 3 id: 1
      discontinuity: true
      trackSwitch: true
      contiguous: false
      accurateTimeOffset: true
      timeOffset: 0
      initSegmentChange: true"
[Log] [log] > – "[stream-controller]:" – "Loaded main sn: 0 of level 3" (hls.min.js, line 1)
[Log] [log] > – "Debug logs enabled for \"main\" in hls.js version 1.6.15" (7990593c-25d1-4636-bc33-bbb93925acb1, line 1)
[Log] [log] > – "[mp4-remuxer]: ISGenerated flag reset" (hls.min.js, line 1)
[Log] [log] > – "[mp4-remuxer]: Reset initPTS: null > null" (hls.min.js, line 1)
[Log] [log] > – "[mp4-remuxer]: reset next timestamp" (hls.min.js, line 1)
[Log] [log] > – "manifest codec:mp4a.40.2, parsed codec:mp4a.40.2, channels:2, rate:44100 (ADTS object type:2 sampling index:4)" (7990593c-25d1-4636-bc33-bbb93925acb1, line 1)
[Log] [log] > – "[mp4-remuxer]: Found initPTS at playlist time: 0 offset: 10.0101 (900909/90000) trackId: 1" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "FRAG_LOADING->PARSING" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Swapping manifest audio codec \"mp4a.40.2\" for \"mp4a.40.5\"" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Init audio buffer, container:audio/mp4, codecs[selected/level/parsed]=[mp4a.40.5/mp4a.40.2/mp4a.40.2]" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Init video buffer, container:video/mp4, codecs[level/parsed]=[avc1.64001f/avc1.64001f]" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "BUFFER_CODECS: \"audio,video\" (current SB count 0)" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "checkPendingTracks (pending: 2 codec events expected: 1) {\"audio\":{\"listeners\":[],\"codec\":\"mp4a.40.2\",\"container\":\"audio/mp4\",\"levelCodec\":\"…" (hls.min.js, line 1)
"checkPendingTracks (pending: 2 codec events expected: 1) {\"audio\":{\"listeners\":[],\"codec\":\"mp4a.40.2\",\"container\":\"audio/mp4\",\"levelCodec\":\"mp4a.40.5\",\"metadata\":{\"channelCount\":2},\"id\":\"main\"},\"video\":{\"listeners\":[],\"codec\":\"avc1.64001f\",\"container\":\"video/mp4\",\"levelCodec\":\"avc1.64001f\",\"metadata\":{\"width\":1280,\"height\":720},\"id\":\"main\"}}"
[Log] [log] > – "[buffer-controller]:" – "creating sourceBuffer(audio/mp4;codecs=mp4a.40.2) {\"listeners\":[],\"codec\":\"mp4a.40.2\",\"container\":\"audio/mp4\",\"levelCodec\":\"mp4a.40.5\",\"meta…" (hls.min.js, line 1)
"creating sourceBuffer(audio/mp4;codecs=mp4a.40.2) {\"listeners\":[],\"codec\":\"mp4a.40.2\",\"container\":\"audio/mp4\",\"levelCodec\":\"mp4a.40.5\",\"metadata\":{\"channelCount\":2},\"id\":\"main\"}"
[Log] [log] > – "[buffer-controller]:" – "creating sourceBuffer(video/mp4;codecs=avc1.64001f) {\"listeners\":[],\"codec\":\"avc1.64001f\",\"container\":\"video/mp4\",\"levelCodec\":\"avc1.64001f\"…" (hls.min.js, line 1)
"creating sourceBuffer(video/mp4;codecs=avc1.64001f) {\"listeners\":[],\"codec\":\"avc1.64001f\",\"container\":\"video/mp4\",\"levelCodec\":\"avc1.64001f\",\"metadata\":{\"width\":1280,\"height\":720},\"id\":\"main\"}"
[Log] [log] > – "[buffer-controller]:" – "SourceBuffers created. Running queue: ↵video: (SourceBuffer) ↵audio: (SourceBuffer) ↵audiovideo: (none) }" (hls.min.js, line 1)
"SourceBuffers created. Running queue:
video: (SourceBuffer)
audio: (SourceBuffer)
audiovideo: (none) }"
[Log] [log] > – "[buffer-controller]:" – "queuing \"audio\" append sn: 0 of level 3 cc: 0" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "queuing \"video\" append sn: 0 of level 3 cc: 0" (hls.min.js, line 1)
[Log] [log] > – "[audio-stream-controller]:" – "InitPTS for cc: 0 found from main: 10.0101 (900909/90000) trackId: 1" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "queuing \"video\" append sn: 0 of level 3 cc: 0" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "queuing \"audio\" append sn: 0 of level 3 cc: 0" (hls.min.js, line 1)
[Log] [log] > – "[transmuxer.ts]: Flushed main sn: 0 of level 3" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "PARSING->PARSED" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Parsed main sn: 0 of level 3 (frag:[0.000-10.023])" (hls.min.js, line 1)
[Error] [error] > – "[buffer-controller]:" – "Error: video SourceBuffer error. MediaSource readyState: ended" – Event {isTrusted: true, type: "error", target: ManagedSourceBuffer, …}
Event {isTrusted: true, type: "error", target: ManagedSourceBuffer, currentTarget: ManagedSourceBuffer, eventPhase: 2, …}Event
  (anonymous function) (hls.min.js:1:281868)
[Warning] [warn] > – "[buffer-controller]:" – "Failed 1/3 times to append segment in \"video\" sourceBuffer (no media error)" (hls.min.js, line 1)
[Warning] [warn] > – "[stream-controller]:" – "Buffer full error while media.currentTime (10) is not buffered, flush main buffer" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Reset loading state" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "PARSED->IDLE" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Reset loading state" (hls.min.js, line 1)
[Warning] [warn] > – "[error-controller]:" – "switching to level 2 after bufferAppendError" (hls.min.js, line 1)
[Log] [log] > – "[level-controller]:" – "Switching to level 2 (480p SDR avc1,mp4a @836280) from level 3" (hls.min.js, line 1)
[Log] [log] > – "[level-controller]:" – "Loading level index 2 https://test-streams.mux.dev/x36xhzz/url_6/193039199_mp4_h264_aac_hq_7.m3u8" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "IDLE->WAITING_LEVEL" (hls.min.js, line 1)
[Warning] [warn] > – "[error-controller]:" – "MediaSource ended after \"video\" sourceBuffer append error. Attempting to recover from media error." (hls.min.js, line 1)
[Log] [log] > – "recoverMediaError" (hls.min.js, line 1)
[Log] [log] > – "detachMedia" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "media source detaching" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "WAITING_LEVEL->STOPPED" (hls.min.js, line 1)
[Log] [log] > – "[subtitle-stream-controller]:" – "IDLE->STOPPED" (hls.min.js, line 1)
[Log] [log] > – "attachMedia" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "created media source: ManagedMediaSource" (hls.min.js, line 1)
[Log] [log] > – "Debug logs enabled for \"main\" in hls.js version 1.6.15" (7990593c-25d1-4636-bc33-bbb93925acb1, line 1)
[Log] [log] > – "[buffer-controller]:" – "Media source opened" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "Updating MediaSource duration to 634.592" (hls.min.js, line 1)
[Log] [log] > – "[interstitials]:" – "clear schedule state" (hls.min.js, line 1)
[Log] [log] > – "[interstitials]:" – "setSchedulePosition 0, undefined ([primary: 0.00-634.58]) pos: 0" (hls.min.js, line 1)
[Log] [log] > – "[interstitials]:" – "INTERSTITIALS_BUFFERED_TO_BOUNDARY [primary: 0.00-634.58]" (hls.min.js, line 1)
[Log] [log] > – "[interstitials]:" – "resuming [primary: 0.00-634.58]" (hls.min.js, line 1)
[Log] [log] > – "[interstitials]:" – "[attachPrimary] Advancing timeline position to 0" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "STOPPED->IDLE" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "IDLE->WAITING_LEVEL" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "checkPendingTracks (pending: 0 codec events expected: 1) {}" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Level 2 loaded [0,63][part-63--1], cc [0, 0] duration:634.6" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "setting startPosition to 0 by default" (hls.min.js, line 1)
[Log] [log] > – "[interstitials]:" – "INTERSTITIALS_UPDATED (0): ↵Schedule: [primary: 0.00-634.60] pos: 0" (hls.min.js, line 1)
"INTERSTITIALS_UPDATED (0):
Schedule: [primary: 0.00-634.60] pos: 0"
[Log] [log] > – "[buffer-controller]:" – "Updating MediaSource duration to 634.600" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "WAITING_LEVEL->IDLE" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Loading main sn: 0 of level 2 (frag:[0.000-10.000]) cc: 0 [0-63], target: 0" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "IDLE->FRAG_LOADING" (hls.min.js, line 1)
[Log] [log] > – "[transmuxer-interface]: Starting new transmux session for main sn: 0 level: 2 id: 1↵        discontinuity: true↵        trackSwitch: true↵…" (hls.min.js, line 1)
"[transmuxer-interface]: Starting new transmux session for main sn: 0 level: 2 id: 1
      discontinuity: true
      trackSwitch: true
      contiguous: false
      accurateTimeOffset: true
      timeOffset: 0
      initSegmentChange: true"
[Log] [log] > – "[stream-controller]:" – "Loaded main sn: 0 of level 2" (hls.min.js, line 1)
[Log] [log] > – "[mp4-remuxer]: ISGenerated flag reset" (hls.min.js, line 1)
[Log] [log] > – "[mp4-remuxer]: Reset initPTS: null > 10.0101 (900909/90000) trackId: 1" (hls.min.js, line 1)
[Log] [log] > – "[mp4-remuxer]: reset next timestamp" (hls.min.js, line 1)
[Log] [log] > – "manifest codec:mp4a.40.2, parsed codec:mp4a.40.2, channels:2, rate:44100 (ADTS object type:2 sampling index:4)" (7990593c-25d1-4636-bc33-bbb93925acb1, line 1)
[Log] [log] > – "[stream-controller]:" – "FRAG_LOADING->PARSING" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Swapping manifest audio codec \"mp4a.40.2\" for \"mp4a.40.5\"" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Init audio buffer, container:audio/mp4, codecs[selected/level/parsed]=[mp4a.40.5/mp4a.40.2/mp4a.40.2]" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Init video buffer, container:video/mp4, codecs[level/parsed]=[avc1.64001f/avc1.64001f]" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "BUFFER_CODECS: \"audio,video\" (current SB count 0)" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "checkPendingTracks (pending: 2 codec events expected: 1) {\"audio\":{\"listeners\":[],\"codec\":\"mp4a.40.2\",\"container\":\"audio/mp4\",\"levelCodec\":\"…" (hls.min.js, line 1)
"checkPendingTracks (pending: 2 codec events expected: 1) {\"audio\":{\"listeners\":[],\"codec\":\"mp4a.40.2\",\"container\":\"audio/mp4\",\"levelCodec\":\"mp4a.40.5\",\"metadata\":{\"channelCount\":2},\"id\":\"main\"},\"video\":{\"listeners\":[],\"codec\":\"avc1.64001f\",\"container\":\"video/mp4\",\"levelCodec\":\"avc1.64001f\",\"metadata\":{\"width\":848,\"height\":480},\"id\":\"main\"}}"
[Log] [log] > – "[buffer-controller]:" – "creating sourceBuffer(audio/mp4;codecs=mp4a.40.2) {\"listeners\":[],\"codec\":\"mp4a.40.2\",\"container\":\"audio/mp4\",\"levelCodec\":\"mp4a.40.5\",\"meta…" (hls.min.js, line 1)
"creating sourceBuffer(audio/mp4;codecs=mp4a.40.2) {\"listeners\":[],\"codec\":\"mp4a.40.2\",\"container\":\"audio/mp4\",\"levelCodec\":\"mp4a.40.5\",\"metadata\":{\"channelCount\":2},\"id\":\"main\"}"
[Log] [log] > – "[buffer-controller]:" – "creating sourceBuffer(video/mp4;codecs=avc1.64001f) {\"listeners\":[],\"codec\":\"avc1.64001f\",\"container\":\"video/mp4\",\"levelCodec\":\"avc1.64001f\"…" (hls.min.js, line 1)
"creating sourceBuffer(video/mp4;codecs=avc1.64001f) {\"listeners\":[],\"codec\":\"avc1.64001f\",\"container\":\"video/mp4\",\"levelCodec\":\"avc1.64001f\",\"metadata\":{\"width\":848,\"height\":480},\"id\":\"main\"}"
[Log] [log] > – "[buffer-controller]:" – "SourceBuffers created. Running queue: ↵video: (SourceBuffer) ↵audio: (SourceBuffer) ↵audiovideo: (none) }" (hls.min.js, line 1)
"SourceBuffers created. Running queue:
video: (SourceBuffer)
audio: (SourceBuffer)
audiovideo: (none) }"
[Log] [log] > – "[buffer-controller]:" – "queuing \"audio\" append sn: 0 of level 2 cc: 0" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "queuing \"video\" append sn: 0 of level 2 cc: 0" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "queuing \"video\" append sn: 0 of level 2 cc: 0" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "queuing \"audio\" append sn: 0 of level 2 cc: 0" (hls.min.js, line 1)
[Log] [log] > – "[transmuxer.ts]: Flushed main sn: 0 of level 2" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "PARSING->PARSED" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Parsed main sn: 0 of level 2 (frag:[0.000-10.023])" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "Updating audio SourceBuffer timestampOffset to -10.0101 (sn: 0 cc: 0)" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "Updating video SourceBuffer timestampOffset to -10.0101 (sn: 0 cc: 0)" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Buffered main sn: 0 of level 2 (frag:[0.000-10.023] > buffer:[0.023-10.008])" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "PARSED->IDLE" (hls.min.js, line 1)
[Info] [info] > – "[abr]:" – "switch candidate:2->4 adjustedbw(101447808)-bitrate=95226208 ttfb:0.1 avgDuration:10.0 maxFetchDuration:10.0 fetchDuration:0.…" (hls.min.js, line 1)
"switch candidate:2->4 adjustedbw(101447808)-bitrate=95226208 ttfb:0.1 avgDuration:10.0 maxFetchDuration:10.0 fetchDuration:0.7 firstSelection:false codecSet:avc1,mp4a videoRange:SDR hls.loadLevel:2"
[Log] [log] > – "[stream-controller]:" – "Adapting to level 4 from level 2" (hls.min.js, line 1)
[Log] [log] > – "[level-controller]:" – "Switching to level 4 (1080p SDR avc1,mp4a @6221600) from level 2" (hls.min.js, line 1)
[Log] [log] > – "[level-controller]:" – "Loading level index 4 https://test-streams.mux.dev/x36xhzz/url_8/193039199_mp4_h264_aac_fhd_7.m3u8" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "IDLE->WAITING_LEVEL" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Level 4 loaded [0,63][part-63--1], cc [0, 0] duration:634.567" (hls.min.js, line 1)
[Log] [log] > – "[interstitials]:" – "INTERSTITIALS_UPDATED (0): ↵Schedule: [primary: 0.00-634.57] pos: 0" (hls.min.js, line 1)
"INTERSTITIALS_UPDATED (0):
Schedule: [primary: 0.00-634.57] pos: 0"
[Log] [log] > – "[stream-controller]:" – "WAITING_LEVEL->IDLE" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Loading main sn: 1 of level 4 (frag:[10.000-20.000]) cc: 0 [0-63], target: 10.008" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "IDLE->FRAG_LOADING" (hls.min.js, line 1)
[Log] [log] > – "[transmuxer-interface]: Starting new transmux session for main sn: 1 level: 4 id: 1↵        discontinuity: false↵        trackSwitch: true…" (hls.min.js, line 1)
"[transmuxer-interface]: Starting new transmux session for main sn: 1 level: 4 id: 1
      discontinuity: false
      trackSwitch: true
      contiguous: false
      accurateTimeOffset: true
      timeOffset: 10
      initSegmentChange: false"
[Log] [log] > – "[stream-controller]:" – "Loaded main sn: 1 of level 4" (hls.min.js, line 1)
[Log] [log] > – "[mp4-remuxer]: ISGenerated flag reset" (hls.min.js, line 1)
[Log] [log] > – "[mp4-remuxer]: reset next timestamp" (hls.min.js, line 1, x2)
[Log] [log] > – "[stream-controller]:" – "FRAG_LOADING->PARSING" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Swapping manifest audio codec \"mp4a.40.2\" for \"mp4a.40.5\"" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Init audio buffer, container:audio/mp4, codecs[selected/level/parsed]=[mp4a.40.5/mp4a.40.2/mp4a.40.2]" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Init video buffer, container:video/mp4, codecs[level/parsed]=[avc1.640028/avc1.640028]" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "BUFFER_CODECS: \"audio,video\" (current SB count 2)" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "queuing \"audio\" append sn: 1 of level 4 cc: 0" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "queuing \"video\" append sn: 1 of level 4 cc: 0" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "queuing \"video\" append sn: 1 of level 4 cc: 0" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "queuing \"audio\" append sn: 1 of level 4 cc: 0" (hls.min.js, line 1)
[Log] [log] > – "[transmuxer.ts]: Flushed main sn: 1 of level 4" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "PARSING->PARSED" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Parsed main sn: 1 of level 4 (frag:[10.008-20.023])" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Buffered main sn: 1 of level 4 (frag:[10.008-20.023] > buffer:[0.023-19.992])" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "PARSED->IDLE" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Loading main sn: 2 of level 4 (frag:[19.992-29.992]) cc: 0 [0-63], target: 19.992" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "IDLE->FRAG_LOADING" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Loaded main sn: 2 of level 4" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "FRAG_LOADING->PARSING" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "queuing \"video\" append sn: 2 of level 4 cc: 0" (hls.min.js, line 1)
[Log] [log] > – "[buffer-controller]:" – "queuing \"audio\" append sn: 2 of level 4 cc: 0" (hls.min.js, line 1)
[Log] [log] > – "[transmuxer.ts]: Flushed main sn: 2 of level 4" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "PARSING->PARSED" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Parsed main sn: 2 of level 4 (frag:[19.992-30.023])" (hls.min.js, line 1)
[Log] [log] > – "pause buffering" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "Buffered main sn: 2 of level 4 (frag:[19.992-30.023] > buffer:[0.023-30.000])" (hls.min.js, line 1)
[Log] [log] > – "[stream-controller]:" – "PARSED->IDLE" (hls.min.js, line 1)

@robwalch
Copy link
Collaborator Author

robwalch commented Jan 16, 2026

Did you report a bug to Safari? The root cause is MediaSource in Safari, not HLS.js, as this issue does not occur in other browsers.

Please test the fix. Verification or results with logs based on these changes would help make sure we’re hitting the mark. recoverMediaError is a workaround. This is a fix to make that workaround consistent with standard behavior when autoStartLoad is disabled.

This PR is not the place to provide logs of behavior before the changes being submitted. That information belongs in the original issue to aid in diagnosis and solutioning.

@robwalch
Copy link
Collaborator Author

There are a number of append errors, some are stream issues where this change and #7683 should not be expected to work, that should be re-evaluated. As part of this change or a follow-up, I'd like to emit MediaSource closure while attached as a new type of error event, so that recovery is handled by the error-controller.

@zalishchuk
Copy link

I'm still investigating the actual cause of the issue. Just before calling appendBuffer, Media Source's readyState is open, but then the source buffer reports an error because the readyState is ended, but sourceended was not firing, and after that, readyState checks show that it's still open.

I haven't filed a Safari bug yet because I want to be certain it's not an issue with HLS.js. I created a simple demo without HLS.js and couldn't reproduce the strange ended state when appending the buffer.

@robwalch
Copy link
Collaborator Author

robwalch commented Jan 16, 2026

Thanks for looking into this in more detail @zalishchuk. I also tried reproducing the issue using demo/basic-usage.html (with a modified <video playsinline/> element and config to match the bug report), but could not reproduce the issue there either. Have you been able to reproduce the issue with hls.js outside of the demo page? Perhaps, it's related to something else specific to the demo page, like the use of history.pushState?

@zalishchuk
Copy link

zalishchuk commented Jan 17, 2026

@robwalch

Have you been able to reproduce the issue with hls.js outside of the demo page?

Yes, this issue can be consistently reproduced using the default configuration with a basic HLS.js setup. Whether the start load is enabled or disabled doesn't really affect the issue itself.

  1. Open iOS Safari
  2. Navigate to the HLS.js demo page (or minimal HLS.js custom demo page, it doesn't matter)
  3. Close the Safari
  4. Kill Safari in App Switcher
  5. Open Safari (should open the demo page with buffer errors)
  6. Reload or Open a new demo page tab
  7. No buffer errors until you "kill" the browser and cold start once more

After you reload or open new tabs with the same page, it behaves normally. It looks like the issue only affects the "first" Media Source instance for your Safari "session", but I am still not sure. A ready state ended error when appending the buffer is abnormal browser behavior, but I can't replicate it in a raw MMS setup when appending a video/audio buffer without HLS.js. I've set an interval to log the HLS.js Media Source's ready state; it never sets to ended, but trying to append the buffer throws an error because "internally" the state is ended. I've also checked that we are not losing the instance, since the Safari update mentioned "detachable" objects, but the instance and the source buffer are still fine.

But... As I mentioned, the fact that I can't reproduce that with a simple MMS raw setup is weird.

@zalishchuk
Copy link

@robwalch

Thought I'd share what I found.

The problem

On iOS26, when Safari is cold started (force-closed from app switcher and reopened), the first ManagedMediaSource instance fails on appendBuffer. The weird part - readyState shows "open" but the error says it's "ended". The sourceended event never fires. And it only happens on the very first MMS after cold start - reload the page or open a new tab and everything works fine.

What I tested

I built a minimal repro page and ran a bunch of tests on cold start:

  • Single SourceBuffer → works fine
  • Two SourceBuffers with sequential appends (audio first, wait for updateend, then video) → works fine
  • Two SourceBuffers with simultaneous appends → fails with "ended" state
  • Same MMS, first append to audio, wait for updateend, THEN simultaneous appends → works!

That last test was the smoking gun. It's not about needing a separate "warmup" MMS - the same instance works fine after one append cycle completes. Safari 26's MMS subsystem just needs one updateend to fully initialize internally on cold start.

Root cause (based on my testing)

Simultaneous appendBuffer calls to multiple SourceBuffers on the first MMS after cold start. HLS.js hits this because it creates audio + video SourceBuffers and appends to both right away.

Possible fixes

  1. Warmup workaround (what I implemented): Before creating the real MMS, create a sacrificial one, attach it, append a minimal ftyp box, wait for updateend, clean up, then proceed normally. It's a bit ugly but it works and doesn't touch the core buffering logic.

  2. Serialize first append: Modify HLS.js to wait for the first appendBuffer to complete before starting the second one - but only on the first MMS on iOS 26. After that first updateend, simultaneous appends work fine. Not sure how invasive this would be given how the buffer operations are queued - you'd know better than me if this is a quick change or a rabbit hole.

I'm leaning toward option 1 for now since it's isolated and doesn't mess with the append queue logic, but wanted to get your thoughts.

The warmup function is pretty minimal - about 40 lines, creates an MMS, appends a 20-byte ftyp box, waits for updateend, cleans up.

Happy to share the repro page if you want to poke at it yourself. Let me know what you think.

@christriants
Copy link
Contributor

The current fix looks good to me. Tested this on iOS 26.2 Safari (cold open). Providing logs.

[Error] [error] > – "[buffer-controller]:" – "Error: audio SourceBuffer error. MediaSource readyState: ended" – Event {isTrusted: true, type: "error", target: ManagedSourceBuffer, …}
Event {isTrusted: true, type: "error", target: ManagedSourceBuffer, currentTarget: ManagedSourceBuffer, eventPhase: 2, …}Event
	onSBUpdateError (hls.js:20800)
[Warning] Error event: – {type: "mediaError", details: "bufferAppendingError", sourceBufferName: "audio", …} (hls-demo.js, line 24813)
{type: "mediaError", details: "bufferAppendingError", sourceBufferName: "audio", error: Error: audio SourceBuffer error. MediaSource readyState: ended, fatal: false, …}Object
[Warning] [warn] > – "[buffer-controller]:" – "Failed 1/3 times to append segment in \"audio\" sourceBuffer (no media error)" (hls.js, line 20277)
[Warning] [warn] > – "[stream-controller]:" – "Buffer full error while media.currentTime (10) is not buffered, flush main buffer" (hls.js, line 10699)
[Log] [log] > – "[stream-controller]:" – "Reset loading state" (hls.js, line 10737)
[Log] [log] > – "[stream-controller]:" – "PARSED->IDLE" (hls.js, line 11163)
[Log] [log] > – "[stream-controller]:" – "Reset loading state" (hls.js, line 10737)
[Warning] [warn] > – "[error-controller]:" – "switching to level 1 after bufferAppendError" (hls.js, line 5404)
[Log] [log] > – "[level-controller]:" – "Switching to level 1 (288p SDR avc1,mp4a @460560) from level 3" (hls.js, line 34713)
[Log] [log] > – "[level-controller]:" – "Loading level index 1 https://test-streams.mux.dev/x36xhzz/url_4/193039199_mp4_h264_aac_7.m3u8" (hls.js, line 34590)
[Log] [log] > – "[stream-controller]:" – "IDLE->WAITING_LEVEL" (hls.js, line 11163)
[Warning] [warn] > – "[error-controller]:" – "MediaSource ended after \"audio\" sourceBuffer append error. ~~~~~~Attempting to recover from media error." (hls.js, line 5326)
[Log] [log] > – "recoverMediaError" (hls.js, line 37398)
[Log] [log] > – "detachMedia" (hls.js, line 37268)
[Log] [log] > – "[buffer-controller]:" – "media source detaching" (hls.js, line 19870)
[Log] [log] > – "[stream-controller]:" – "WAITING_LEVEL->STOPPED" (hls.js, line 11163)
[Log] [log] > – "[subtitle-stream-controller]:" – "IDLE->STOPPED" (hls.js, line 11163)
[Log] [log] > – "attachMedia" (hls.js, line 37250)
[Log] [log] > – "[buffer-controller]:" – "created media source: ManagedMediaSource" (hls.js, line 19771)
[Log] ~~~~~~~~~~~startLoad, autoStartLoad false (hls.js, line 37408) <- the fix
[Log] [log] > – "startLoad(-1)" (hls.js, line 37327)
[Log] [log] > – "[level-controller]:" – "Loading level index 1 https://test-streams.mux.dev/x36xhzz/url_4/193039199_mp4_h264_aac_7.m3u8" (hls.js, line 34590)

We're still seeing the append errors, but I believe that issue is with Safari.

There are a number of append errors, some are stream issues where this change and #7683 should not be expected to work, that should be re-evaluated. As part of this change or a follow-up, I'd like to emit MediaSource closure while attached as a new type of error event, so that recovery is handled by the error-controller.

@robwalch +1 to this idea. I would be happy to take this on, if you feel it would be better as a follow-up.

@robwalch
Copy link
Collaborator Author

@zalishchuk, please share the repro page and file an issue with Safari, using that as the minimal example.

@robwalch
Copy link
Collaborator Author

robwalch commented Jan 17, 2026

@christriants thanks. Changed to error handling are not needed for this fix. The “MediaSource readyState: ended" error goes through a different path since the source is not closed and the append error unavoidable without modifying valid append sequencing.

For this issue, recoverMediaError was already being called onErrorOut, it just wasn't restoring the loading state, as described above.

@robwalch
Copy link
Collaborator Author

I plan to merge this PR with only minor changes (started const). If you’d like to look at the relate issues and follow-up, be my guest.

In terms of working around the append sequence leading to the Safari bug, I don't think we should make changes that would defer appends and increase start time. Looking into combining init segment and media appends might as an alternative without performance penalties could be interesting. So would making adjustments to force the first append to be video before audio.

@zalishchuk
Copy link

@robwalch Found a clean fix for the iOS 26 ManagedMediaSource cold-start bug.

The issue: the first MMS after a Safari cold start fails on simultaneous appendBuffer calls (readyState lies: shows "open" but is internally "ended"). Second MMS always works fine.

The fix: just let it fail and recover. In onSBUpdateError, if we detect MMS + "ended" state, call recoverMediaError() to get a fresh MediaSource:

if (
  this.appendSource &&
  this.mediaSource?.readyState === 'ended' &&
  !this.hasRecoveredFromColdStart
) {
  this.hasRecoveredFromColdStart = true;
  this.hls.recoverMediaError();
  return;
}

We could also add a check to see whether this occurred during initial buffering to improve detection precision.

Another option: make this a new error type (e.g., BUFFER_COLD_START_ERROR) so the error-controller can handle recovery instead of doing it inline. That would be more consistent with how other recoverable errors are handled in hls.js.

Simple, no startup latency, uses existing recovery mechanism. Users might notice a brief stall during a cold start, but it's barely noticeable.

@zalishchuk
Copy link

Created a standalone repo for the iOS 26 MMS bug:
https://github.com/zalishchuk/ios-26-mms-bug

Live demo: https://zalishchuk.github.io/ios-26-mms-bug/

It auto-runs on page load and detects whether it's a cold start or a reload. Four tests showing exactly what triggers the bug (simultaneous appends) and what doesn't (sequential, single SB, warmup).

Should be useful for the WebKit bug report.

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.

bufferAppendError on iOS 26.2 when autoStartLoad=false

4 participants