Repository navigation
Mark fragments as gaps on SourceBuffer errors - #8045
hongjun-bae wants to merge 11 commits into
Conversation
robwalch
left a comment
There was a problem hiding this comment.
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.
683294c to
3d145e0
Compare
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
3d145e0 to
ff85808
Compare
`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.
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.
…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.
|
@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? |
| this.keepGapsOnSeek = false; | ||
| this.sourceBufferErrorGaps.clear(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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}`; | ||
| } |
There was a problem hiding this comment.
Remove along with sourceBufferErrorGaps
| this.keepGapsOnSeek = gaps.some( | ||
| (frag) => | ||
| frag.type === this.playlistType && | ||
| this.sourceBufferErrorGaps.has(gapKey(frag)), | ||
| ); |
There was a problem hiding this comment.
Remove in favor of keeping gaps on seek to start pos.
There was a problem hiding this comment.
Removed. The detach still carries tracked gaps across removeAllFragments().
| 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, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Use hasEnoughToStart:
| 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;
}There was a problem hiding this comment.
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 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 withrecoverMediaError(). 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?
onSBUpdateError(HlsJsSourceBufferError). Retry counts, append error counting and fatal escalation are unchanged.MEDIA_SOURCE_REQUIRES_RESETwith that name and mark the fragment withaddAsGap(). Fragments of an ended playlist are not marked, so a damaged VOD segment keeps the existing retry path and ends fatal.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.maxBufferHolebefore 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
The api-extractor report adds
FragmentTracker.gapFragments(),FragmentTracker.isGap()and the protectedonSourceBufferError().npm run test:unitpasses 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.