Skip to content

[HLSTree] Fix manifest update thread spinning after a failed live update - #2098

Merged
CastagnaIT merged 1 commit into
xbmc:Piersfrom
gizmocuz:fix/hls-manifest-update-spin
Aug 30, 2026
Merged

[HLSTree] Fix manifest update thread spinning after a failed live update#2098
CastagnaIT merged 1 commit into
xbmc:Piersfrom
gizmocuz:fix/hls-manifest-update-spin

Conversation

@gizmocuz

Copy link
Copy Markdown
Contributor

Problem

When a live HLS manifest update fails, the manifest update thread stops waiting between attempts and issues manifest requests as fast as the network allows.

Measured against a local origin returning 404 for the media playlist: 13,158 requests in 38 seconds, sustained at 350-450 per second, for as long as the fault lasted. It stops the instant one update succeeds, because parsing the playlist restores the interval from #EXT-X-TARGETDURATION.

Against a real CDN this is likely to get the client rate limited or blocked, which turns a transient origin problem into a much longer outage.

Cause

Two things combine.

CHLSTree::OnUpdateSegments halves the interval as a temporary backoff:

if (isInvalidUpdate)
{
  m_updateInterval = m_updateInterval / 2;
  m_updThread.ResetInterval();
}

But on the second consecutive failure the value being halved is the NO_VALUE sentinel, not an interval. TreeUpdateThread::Worker sets NO_VALUE before each update so that a successful parse can lower it from the manifest:

// Reset interval value to allow forced update from manifest
if (m_resetInterval)
  m_tree->m_updateInterval = PLAYLIST::NO_VALUE;

m_tree->OnUpdateSegments();

An update that fails to download never reaches the parse, so NO_VALUE is still there when the halving runs. NO_VALUE / 2 is 2^63-1, which passes the loop guard on the next iteration:

while (m_tree->m_updateInterval != NO_VALUE && m_tree->m_updateInterval > 0 && !m_threadStop)
{
  ...
  std::chrono::milliseconds intervalMs = std::chrono::milliseconds(m_tree->m_updateInterval);
  m_cvUpdInterval.wait_for(updLck, intervalMs, ...);

milliseconds(2^63-1) is about 292 million years. Converting it to steady_clock's nanosecond duration overflows int64_t - in wait_for's deadline and in the predicate's >= comparison, which needs the same common type. The wait returns immediately and the loop spins.

Timing matches: the storm starts about 3 seconds after the first failed request, which is exactly two failed update cycles at a 6 second target duration (6 s, then 3 s).

Fix

Two small changes, no new state and no behaviour change for a valid interval:

  • AdaptiveTree gains a MAX_UPDATE_INTERVAL_MS ceiling and clamps the interval before constructing the duration, so no value can produce a non-wait however it got there.
  • CHLSTree::OnUpdateSegments falls back to that ceiling when the sentinel is present, so the backoff always halves a real duration.

The fallback value is a judgement call and I am happy to change it if you would prefer something else - for example remembering the last manifest-derived interval and halving that, which preserves the original cadence more closely at the cost of an extra member.

Notes

  • Only CHLSTree has this backoff. DASHTree and SmoothTree assign m_updateInterval directly and are not affected, though the clamp covers them too.
  • Observed on Windows (MSVC STL), where the overflowed deadline lands in the past. A standard library that saturates instead would show a different symptom - an update thread that stops updating rather than one that spins - but the sentinel arithmetic is wrong either way.
  • I have not built this tree locally, so I am relying on CI for the build.
  • Separately, m_resetInterval is set by ResetInterval() and never cleared, so from the first failure onward every iteration resets the interval. That looks intentional given the comment, so I have left it alone, but it is what makes the sentinel reach the halving.

@greptile-apps

greptile-apps Bot commented Aug 20, 2026

Copy link
Copy Markdown

Greptile Summary

This PR prevents failed live-manifest updates from producing an overflowing wait duration and spinning the update thread.

  • Adds a shared 60-second ceiling before constructing the update wait duration.
  • Replaces HLS sentinel arithmetic with a fixed 30-second failed-update retry.
  • The chosen ceiling also shortens valid long DASH intervals, while the HLS fallback can delay recovery for short-target-duration streams.

Confidence Score: 3/5

The PR should not merge until the overflow guard preserves valid long manifest intervals and the HLS failure path retries according to the stream's established cadence.

The shared clamp overrides valid DASH refresh periods above 60 seconds, and failed short-duration HLS playlists are retried only every 30 seconds, creating excess requests in one path and delayed recovery in the other.

Files Needing Attention: src/common/AdaptiveTree.cpp, src/common/AdaptiveTree.h, src/parser/HLSTree.cpp

Important Files Changed

Filename Overview
src/common/AdaptiveTree.cpp Clamps all shared update waits to 60 seconds, preventing overflow but overriding valid longer parser-defined intervals.
src/common/AdaptiveTree.h Introduces the 60000 ms ceiling used both as a global wait cap and as the HLS failure fallback.
src/parser/HLSTree.cpp Avoids dividing NO_VALUE but turns repeated failed updates into fixed 30-second retries that can be too slow for live HLS.

Sequence Diagram

sequenceDiagram
  participant W as TreeUpdateThread
  participant H as CHLSTree
  participant O as Playlist origin
  W->>W: Reset interval to NO_VALUE
  W->>H: OnUpdateSegments()
  H->>O: Request media playlist
  O-->>H: Failure
  H->>H: "NO_VALUE -> 60000 / 2"
  H-->>W: "Retry interval = 30000 ms"
  W->>W: Wait min(interval, 60000)
  W->>H: Retry update
Loading

Reviews (1): Last reviewed commit: "[HLSTree] Fix update interval backoff ha..." | Re-trigger Greptile

Comment thread src/common/AdaptiveTree.cpp Outdated
Comment on lines +419 to +420
const uint64_t intervalValue = std::min<uint64_t>(m_tree->m_updateInterval,
AdaptiveTree::MAX_UPDATE_INTERVAL_MS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Clamp overrides valid refresh intervals

When a live DASH manifest specifies a representable minimumUpdatePeriod longer than 60 seconds, the shared worker caps it at 60 seconds, causing manifest requests to run more frequently than configured and increasing the risk of origin or CDN rate limiting.

Comment thread src/parser/HLSTree.cpp Outdated
Comment on lines +1131 to +1133
const uint64_t interval =
m_updateInterval == NO_VALUE ? MAX_UPDATE_INTERVAL_MS : m_updateInterval.load();
m_updateInterval = interval / 2;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Fallback loses the HLS cadence

When a short-target-duration HLS playlist update fails after the worker resets m_updateInterval, this fallback schedules every retry 30 seconds later instead of using the playlist's established cadence, delaying recovery long enough for available segments to be exhausted and playback to buffer.

@gizmocuz

Copy link
Copy Markdown
Contributor Author

Thanks - both points were fair, and both are now addressed.

Valid long intervals were being shortened. The ceiling was only ever meant to keep the duration representable, so applying 60 s to every wait was wrong: a DASH manifest asking for a longer minimumUpdatePeriod would have been refreshed more often than it asked for. Raised to 24 hours, which is orders of magnitude beyond any real minimumUpdatePeriod or EXT-X-TARGETDURATION and far below the point where the nanosecond conversion overflows. It can no longer shorten a valid interval.

The HLS retry ignored the stream's cadence. Using the ceiling as the fallback did turn repeated failures into a fixed retry unrelated to the stream. The backoff is now based on the last interval that came from a manifest, which is the m_lastValidUpdateInterval alternative I mentioned in the description:

  • TreeUpdateThread records it just before clearing m_updateInterval for an update, and skips values a parser lowered as a backoff so they cannot compound down towards zero.
  • CHLSTree::OnUpdateSegments keeps it current after each successful update.
  • A lower bound stops the halving reaching zero, which would fail the > 0 guard in the worker loop and leave live updates stopped for the rest of the session.

So a stream with a 6 s target duration now retries at 3 s as the original code intended, rather than at 30 s.

Comment thread src/common/AdaptiveTree.h Outdated
// duration, and wait_for then computes a deadline in the past and returns
// immediately. A day is orders of magnitude beyond any real minimumUpdatePeriod
// or EXT-X-TARGETDURATION, so clamping here cannot shorten a valid interval.
static constexpr uint64_t MAX_UPDATE_INTERVAL_MS = 86400000; // 24 hours

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 force a cap, no manifests specification apply a limit to the maximum update interval value therefore remove this,
and even if a manifest specification declare a maximum value,
the best place to specify a limit is on the appropriate parser not here in the base tree class

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed, both the constant and the clamp at the wait.

You are right that it was policy in the wrong place. Once the backoff stopped touching the sentinel the clamp was only belt and braces anyway: the worker loop already rejects NO_VALUE and 0, and nothing else can now produce an unusable value. The one case it still covered is a manifest declaring an interval past roughly 292 years, where the ms to ns conversion overflows and wait_for returns a deadline in the past, and by your reasoning that guard belongs in the parser that read the value rather than here. Happy to add it to CDashTree as a separate change if you want it, otherwise it stays out.

I moved MIN_UPDATE_INTERVAL_MS into HLSTree.cpp for the same reason, since only the HLS backoff uses it, and m_lastValidUpdateInterval now starts at 0. The floor still holds, because the backoff takes max(last / 2, 500 ms).

Comment thread src/parser/HLSTree.cpp Outdated
Comment on lines +1125 to +1132
//
// Halve the interval the last successful parse produced, not m_updateInterval
// itself: TreeUpdateThread::Worker sets NO_VALUE before each update so that a
// parse can lower it from the manifest, and an update that fails before the
// parse leaves the sentinel in place. Halving NO_VALUE yields 2^63-1, which
// is not a usable duration. Using the last known good value also keeps the
// retry tied to the stream's own cadence and stops repeated failures from
// compounding the halving down towards zero.

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 added big comment add no significant value to the flow, so remove it,
this is only a my oversight on the old code change

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed, the block is back to the original three lines and the changed statement.

That leaves the whole change at 19 lines: store the last manifest derived interval before the worker clears it, keep it current after a successful HLS update, and halve that instead of m_updateInterval itself.

Comment thread src/parser/HLSTree.cpp Outdated
Comment on lines +1133 to +1136
else if (m_updateInterval != NO_VALUE && m_updateInterval > 0)
{
m_lastValidUpdateInterval = m_updateInterval.load();
}

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.

you have add a code to the Worker to update m_lastValidUpdateInterval on next run,
if not i see wrong seem that this code its not really needed,

instead more likey should be restored the m_resetInterval to false,
this is what i mean to do on Worker:

      // Reset interval value to allow forced update from manifest
      if (m_resetInterval) {
        m_tree->m_updateInterval = PLAYLIST::NO_VALUE;
        m_resetInterval = false;
      }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both applied, thanks - and you are right that they are the same point.

Clearing m_resetInterval is the real fix. ResetInterval() is documented as restoring the original value on the next update, but nothing ever set the flag back, so from the first failed update onward every iteration wiped m_updateInterval to NO_VALUE before the parse, for the rest of the session, long after the stream recovered. That is what fed the sentinel into the halving. It also puts back the pre-failure behaviour of the newInterval < m_updateInterval minimum in ParseManifest, instead of re-deriving it from scratch every cycle forever.

Once the flag is cleared the recording in OnUpdateSegments is dead: the Worker already records m_lastValidUpdateInterval at the top of every iteration where the flag is false, which is exactly the value the parser just set. It was only load bearing because the flag was stuck true and the Worker could never record again. Removed.

Worker is now:

      // Reset interval value to allow forced update from manifest
      if (m_resetInterval)
      {
        m_tree->m_updateInterval = PLAYLIST::NO_VALUE;
        m_resetInterval = false;
      }

and OnUpdateSegments ends at the original ResetInterval() call again.

Traced on a 6 s target duration: normal cycles record 6000, the first failure backs off to 3000 and arms the reset, the next iteration clears to NO_VALUE once and disarms, a repeat failure halves the recorded 6000 again so it holds at 3000 rather than halving the sentinel, and a success restores 6000 from #EXT-X-TARGETDURATION. The first ever update failing is covered too, since the Worker records before the first OnUpdateSegments call.

That puts the whole change at 15 lines.

@CastagnaIT

CastagnaIT commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

LGTM thanks for all
please squash all commits in a single one (or as you wish hls on another one), after that i will merge

@CastagnaIT CastagnaIT added Type: Fix non-breaking change which fixes an issue v22 Piers Component: HLS labels Aug 24, 2026
When a live HLS manifest update failed, CHLSTree::OnUpdateSegments halved
m_updateInterval as a temporary backoff. On the second consecutive failure
the value being halved was the NO_VALUE sentinel that TreeUpdateThread
sets before each update, because a failed download never reaches the
parse that would replace it. NO_VALUE / 2 passes the worker loop guard
but overflows the ms-to-ns conversion in wait_for, so the wait returned
immediately and the thread issued manifest requests as fast as the
network allowed (measured at 350-450 per second against a 404 origin).

The sentinel reached the halving because m_resetInterval was set by
ResetInterval() and never cleared, so from the first failure on every
iteration wiped the interval before the parse.

- TreeUpdateThread clears m_resetInterval after applying it, so the
  reset happens once on the next update as documented
- TreeUpdateThread records m_lastValidUpdateInterval before clearing,
  skipping values a parser lowered as a backoff
- CHLSTree::OnUpdateSegments halves that recorded interval instead of
  m_updateInterval, with a lower bound so it cannot reach zero and stop
  the update thread

DASHTree and SmoothTree assign m_updateInterval directly and are not
affected.
@gizmocuz
gizmocuz force-pushed the fix/hls-manifest-update-spin branch from fd3891a to b580820 Compare August 26, 2026 14:49
@CastagnaIT
CastagnaIT merged commit 389fa00 into xbmc:Piers Aug 30, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backport: Done Component: HLS Type: Fix non-breaking change which fixes an issue v22 Piers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants