Skip to content

fix(stream-controller): defer unsafe live upswitches - #8026

Open
slhck wants to merge 1 commit into
video-dev:masterfrom
slhck:bugfix/live-upswitch-safety-budget
Open

slhck wants to merge 1 commit into
video-dev:masterfrom
slhck:bugfix/live-upswitch-safety-budget

Conversation

@slhck

@slhck slhck commented Sep 1, 2026

Copy link
Copy Markdown

This PR will...

This change checks whether segments are available before committing to an automatic live upswitch near the playlist
edge, and defers the upswitch if the next segment is not yet (predicted to be) available.

If the higher rendition already contains the next segment (i.e., a fragment that extends the forward buffer), the switch continues normally (as currently the case).

If the higher rendition has no next segment, hls.js either waits for its scheduled playlist refresh or just continues at the current rendition (i.e., it defers the upswitch).

We assume that we want at least half a segment duration remaining after the refresh. So we check the time stamps when the refresh will occur, and calculate the reserve.

Example 1:

2.0 s buffered
candidate playlist refresh in:  2.0 s
remaining after refresh:        0 s
required reserve:               1.0 s (half the target duration)

→ This would keep the current rendition and load its available next segment.

Example 2:

2.0 s buffered
candidate playlist refresh in:  0.2 s
remaining after refresh:        1.8 s
required reserve:               1.0 s (half the target duration)

→ the switch is permitted, we wait for the candidate playlist refresh.

This only applies to live playlists near the edge. We do not change VoD behavior, manual level selection, downswitches,
LL-HLS part playlists, or live playback where enough segments are published ahead of time.

Why is this Pull Request needed?

We are investigating a streaming setup where the live playback sometimes stalls even though the estimated bandwidth
would be sufficient. The problem is that a low edge latency is the goal, yet playlist refreshes cannot always happen
in time.

Specifically, the setup uses liveSyncDurationCount: 2. While we acknowledge that this is explicitly documented as
prone to causing stalls, it still menas that a live stream can start with only one segment of playable buffer remaining.
The ABR would then select a higher rendition because the estimated bandwidth is indeed sufficient, even though that rendition's media playlist does not even contain the segment needed at the end of the current buffer.

The current StreamController just commits to this switch and enters the WAITING_LEVEL. Then, if the buffer is
exhausted, the player emits a short waiting event, then hls.js reports a bufferStalledError.
Once the missing segment becomes available, everything is back to normal.

These waiting events would trigger user-visible stalls and warnings during stream monitoring.

Reproduction

We reproduced this on current master with the public Das Erste live master playlist:
https://daserste-live.ard-mcdn.de/daserste/live/hls/de/master.m3u8.

We added unit test coverage to create LevelDetails that mimic what is going on in that live stream.

Configuration:

{
  lowLatencyMode: true,
  liveSyncDurationCount: 2,
  backBufferLength: 90,
}

When we ran the current master vs this PR against the stream, we could observe no stalls with this PR while they did
occur on master.

We also verified that even with the defensive logic of this PR, it allowed all sessions to reach 1080p within a short amount of time (less than 1 s after startup), so it really only defers what is needed to avoid stalls, not more.

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

There are some points:

  1. We aren't quite sure if the target duration / 2 metric is the best choice. When we tried a target duration ⨉ 2 threshold, we found that in 40% of the test runs the quality wouldn't switch up at all. This would've been unacceptable.

  2. As we're not so familiar with the code base, please check if LevelDetails.requestScheduled is used correctly here.

  3. Is excluding LL-HLS part playlists done correctly?

Full disclosure: unit test coverage has been added with Codex + GPT-5.6 Sol (High). The unit tests were then reviewed and run by real humans™.

Resolves issues:

No issue number.

Test plan

npm run prettier
npm run sanity-check
npm run test:func

Checklist

  • changes have been done against master branch, and PR does not conflict
  • new unit / functional tests have been added (whenever applicable) (tests/unit/controller/stream-controller.ts under live upswitch availability)
  • API or design changes are documented in API.md → none needed IMO

@robwalch
robwalch self-requested a review September 1, 2026 18:00
@slhck

slhck commented Sep 29, 2026

Copy link
Copy Markdown
Author

@robwalch Hi, did you get a chance yet to review this? Appreciate it! 🙏

@robwalch

Copy link
Copy Markdown
Collaborator

Hi @slhck,

I understand the motivation and logic. The volume of code and where it's been added - stream-controller vs abr-controller - is something I'm still undecided on. I also haven't been able to verify that the change works as intended and that it would not completely prevent switching in certain cases.

@robwalch

Copy link
Copy Markdown
Collaborator

Added milestone so that you get a review within the next release cycle.

@slhck
slhck force-pushed the bugfix/live-upswitch-safety-budget branch from 18a2333 to aa9060c Compare September 30, 2026 06:54
@slhck

slhck commented Sep 30, 2026

Copy link
Copy Markdown
Author

Thanks for the feedback, I understand.

@robwalch

robwalch commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Noting similar PR #8087. Commented that I'd like us to primarily switch on segment boundaries (and specifically avoid switching when non-independent parts remain) while loading parts. While this change is more concerned with non-low-latency live buffer and playlist position, I'd prefer that the logic for when to change be simpler and unified.

The issue with both PRs is that they still read the hls.nextLoadLevel getter and that may come with side-effects. The determination that it is appropriate to switch while part loading must be made first before polling for it with hls.nextLoadLevel.

@slhck

slhck commented Oct 22, 2026

Copy link
Copy Markdown
Author

Thanks for the comment. I see your point that we read hls.nextLoadLevel first and then override its result, I didn't notice that a getter had that side effect.

Would you consider a smaller version of the patch? doTickIdle would check if a switch is allowed before reading nextLoadLevel:

const level =
  this.buffering && this.canSwitchLevel(bufferInfo)
    ? hls.nextLoadLevel
    : hls.loadLevel;

If you'd rather handle this as part of your v1.8 work, that's fine. One could test it against the Das Erste stream (liveSyncDurationCount: 2), where we can reproduce the stalls reliably.

@robwalch

Copy link
Copy Markdown
Collaborator

Would you consider a smaller version of the patch? doTickIdle would check if a switch is allowed before reading nextLoadLevel:

That sounds better. See the comment I left #8087 (comment) regarding this work.

Comment on lines +399 to +401
if (
this.hls.manualLevel !== -1 ||
!levels ||

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.

Manual selection is covered in nextLoadLevel.

const currentDetails = levels[currentLevel]?.details;
const candidateDetails = levels[candidateLevel]?.details;

// do not switch up if either playlist is not live or has parts (low-latency)

@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.

Suggested change
// do not switch up if either playlist is not live or has parts (low-latency)
// switch up if either playlist is not live or has parts (low-latency)

Do switch up right?

What we care about wrt to LL-HLS is if this.loadingParts is true. #8087 includes discussion of allowing switching on segment boundaries, and potentially on independent parts only.

Comment on lines +479 to +488
// helper to check if there is a fragment in the playlist that extends past the end of the buffer, within tolerance.
private hasFragmentAtBufferEnd(
details: LevelDetails,
bufferEnd: number,
): boolean {
const fragment = this.getNextFragment(bufferEnd, details);
return !!(
fragment && fragment.end > bufferEnd + this.config.maxFragLookUpTolerance
);
}

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 method seems unnecessary or should be renamed to hasFragmentAt with time as the target param. It's called twice, both times with bufferInfo.end + this.config.maxFragLookUpTolerance on either level details.

Comment on lines +437 to +461
const currentReloadAt = currentDetails.requestScheduled;
const candidateReloadAt = candidateDetails?.requestScheduled ?? -1;
// Time until the candidate playlist's next scheduled refresh, in seconds.
const candidateReloadDelay = Math.max(
0,
(candidateReloadAt - self.performance.now()) / 1000,
);
// Retain half a target duration to request and append the candidate fragment.
const minBufferAfterCandidateReload =
currentDetails.levelTargetDuration / 2;
const canWaitForCandidate =
!!candidateDetails &&
bufferInfo.len -
candidateReloadDelay +
this.config.maxFragLookUpTolerance >=
minBufferAfterCandidateReload;

const currentHasFragment = this.hasFragmentAtBufferEnd(
currentDetails,
bufferInfo.end,
);

if (currentHasFragment) {
// Continue at the current level unless the buffer covers the candidate refresh.
return canWaitForCandidate ? candidateLevel : currentLevel;

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 appears to recreate logic that does or should reside in abr-controller. It's nice that the requestScheduled delay is factored in, but why not have that as part of the calculation in getTimeToLoadFrag's playlistLoadSec estimation? Also consider that it is irrelevant if the fragment or part needed is present in the candidate details after #8107 is merged.

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.

2 participants