Skip to content

Infinite segment re-request loop on QuotaExceededError when paused (no back buffer to evict) #7777

@hongjun-bae

Description

@hongjun-bae

What version of Hls.js are you using?

v1.6.13 (also reproduced on latest main branch with PR #7749 applied)

What browser (including version) are you using?

Chrome 146.0.7680.178 (Official Build) (arm64)

Important: This issue started appearing after Chrome 146.0.7680.178 update. Chrome 146.0.7680.167 did not exhibit this behavior. We suspect stricter SourceBuffer quota enforcement was introduced in that Chrome update (possibly related to CVE-2026-5274 "Integer Overflow in Codecs" or CVE-2026-5272 "Heap Buffer Overflow in GPU", both security-patched and non-public).

What OS (including version) are you using?

macOS (Apple Silicon), Windows 10/11

Test stream

Live fMP4/CMAF HLS stream (~2s segments). Not publicly shareable, but reproducible with any live stream configuration described below.

Configuration

{
  "autoStartLoad": true,
  "startFragPrefetch": true,
  "lowLatencyMode": true,
  "manifestLoadingMaxRetry": 3,
  "levelLoadingMaxRetry": 1,
  "fragLoadingMaxRetry": 3,
  "maxMaxBufferLength": 60
}

liveMaxLatencyDuration and liveMaxLatencyDurationCount are both undefined (defaults).

Steps to reproduce

  1. Start playing a live HLS stream (fMP4/CMAF, ~2s segments)
  2. Immediately pause the player
  3. Wait ~3–6 minutes (while paused, hls.js continues buffering forward)
  4. Observe in the Network tab: the same segment is requested 20+ times in rapid succession
  5. After exhausting retries on one segment, the player moves to the next segment and repeats the same loop

Reproduction rate: ~80% (4 out of 5 attempts across multiple machines)

Environment-specific results:

Tester Chrome Version Reproduced? Notes
Tester A 146.0.7680.178 Mac/Windows both
Tester B 146.0.7680.177 Reproduced
Tester C 146.0.7680.167 → .178 ❌ → ✅ Only after update
Tester D 146.0.7680.167 → .178 ❌ → ✅ Same segment repeated 10–20x

Expected behaviour

When QuotaExceededError occurs and back buffer eviction is not possible (e.g., all buffered data is ahead of currentTime because the player is paused near the start), hls.js should:

  1. Stop attempting to append/re-request the same segment indefinitely
  2. Either pause buffering or skip the failing segment gracefully
  3. Resume normal buffering when the user seeks or resumes playback

What actually happened?

hls.js enters an infinite loop re-requesting and re-appending the same segment. The loop occurs in reduceLengthAndFlushBuffer() in base-stream-controller.ts:

QuotaExceededError
  → buffer-controller triggers BUFFER_FULL_ERROR (BUFFER_APPENDED never fires)
  → stream-controller.onError → reduceLengthAndFlushBuffer()
  → reduceMaxBufferLength() → already at minimum 2s, no effect
  → fragmentTracker.removeFragment(frag)  → marks fragment as NOT_LOADED
  → nextLoadPosition = frag.start          ← ROOT CAUSE: resets to same position
  → resetLoadingState() → state = IDLE
  → next tick: doTickIdle() → finds NOT_LOADED fragment at frag.start → loadFragment()
  → QuotaExceededError again → infinite loop

Root cause code (base-stream-controller.ts ~L2014):

if (frag) {
  this.fragmentTracker.removeFragment(frag);  // fragment → NOT_LOADED
  this.nextLoadPosition = frag.start;          // ← resets to same position
}
this.resetLoadingState();  // state = IDLE → doTickIdle re-runs

Why this happens only when paused:

  • While paused, media.currentTime is fixed near the start
  • Forward buffer accumulates continuously (no playback consumption)
  • After ~190s of buffering, SourceBuffer quota is exceeded
  • During playback, back buffer is naturally evicted so the quota is never reached

Console output

Full log timeline (11,767 lines total)

Normal buffering (10:19:29 – 10:22:11): Segments sn:573–667 loaded and appended successfully while paused. Buffer grows from 0s to ~190s.

Last successful append before loop:

10:22:11.492  Buffered main sn: 667 (buffer:[0.000-2.513][38.013-189.995])

First QuotaExceededError (10:22:11.575):

10:22:11.493  Loading main sn: 668 of level 4 (frag:[189.995-191.995])
10:22:11.575  [buffer-controller]: queuing "audiovideo" append sn: 668
10:22:11.575  Reduce max buffer length to 30s    ← first QuotaExceededError

Stack trace:
  i.reduceMaxBufferLength
  i.reduceLengthAndFlushBuffer
  n.onError
  o.trigger (BUFFER_FULL_ERROR)
  onError
  i.executeNext
  i.append
  s.onBufferAppending                             ← SourceBuffer.appendBuffer() fails

Rapid reduction to minimum (10:22:37):

10:22:37.210  Reduce max buffer length to 15s   (sn:668)
10:22:37.309  Reduce max buffer length to 7.5s  (sn:668)
10:22:37.408  Reduce max buffer length to 3.75s (sn:668)
10:22:37.503  Reduce max buffer length to 2s    (sn:668) ← minimum reached

Infinite loop at minimum (10:22:37 – 10:22:49+):

10:22:37.602  Reduce max buffer length to 2s  (sn:668)
10:22:37.701  Reduce max buffer length to 2s  (sn:668)
10:22:37.801  Reduce max buffer length to 2s  (sn:668)
... repeats every ~100ms ...
10:22:43.100  Reduce max buffer length to 2s  (sn:668 → moves to sn:669, 670, ...)
10:22:49.803  Reduce max buffer length to 2s  (sn:673) ← still looping 12+ seconds later

Total: 117 "Reduce max buffer length to 2s" warnings in ~12 seconds.

Each iteration: Load segment → Parse → Append → QuotaExceededError → Remove fragment → Reset to same position → Reload same segment.

PR #7749 does not fix this case

We applied PR #7749 (cherry-picked commit dddaed8db) which adds back buffer eviction on QuotaExceededError. However, in this scenario:

  • media.currentTime is near the beginning (player was paused immediately after start)
  • ALL buffered data is forward buffer (ahead of currentTime)
  • The eviction logic (frag.end <= media.currentTime) finds no candidates
  • Falls back to the existing BUFFER_FULL_ERROR path → infinite loop continues
16:04:41.518  [buffer-controller]: QuotaExceededError on "audiovideo" sn: 3337 — no back buffer available to evict
16:04:41.518  [stream-controller]: Reduce max buffer length to 2s
... (sn:3337 repeats, then sn:3338, same pattern)

Workaround applied (circuit breaker)

As a temporary workaround, we added a circuit breaker in BaseStreamController that tracks consecutive BUFFER_FULL_ERROR failures per segment and stops buffering after 3 failures on the same segment:

base-stream-controller.ts — circuit breaker in reduceLengthAndFlushBuffer():

// Properties added to BaseStreamController
protected _bufferFullErrorSn: number = -1;
protected _bufferFullErrorCount: number = 0;
private static readonly BUFFER_FULL_ERROR_MAX_RETRY = 3;

// Inside reduceLengthAndFlushBuffer(), before existing logic:
if (data.details === ErrorDetails.BUFFER_FULL_ERROR && frag) {
  const fragSn = frag.sn as number;
  if (fragSn === this._bufferFullErrorSn) {
    this._bufferFullErrorCount++;
  } else {
    this._bufferFullErrorSn = fragSn;
    this._bufferFullErrorCount = 1;
  }

  if (this._bufferFullErrorCount >= BaseStreamController.BUFFER_FULL_ERROR_MAX_RETRY) {
    this.warn(
      `BUFFER_FULL_ERROR circuit breaker activated: sn ${fragSn} failed ` +
      `${this._bufferFullErrorCount} times, stopping buffer loading. ` +
      `loadPos: ${loadPos.toFixed(3)}, currentTime: ${this.media?.currentTime?.toFixed(3)}`
    );
    this.pauseBuffering();
    this.fragmentTracker.removeFragment(frag);
    this.resetLoadingState();
    return false;
  }
}

stream-controller.ts — recovery on startLoad() and doTickIdle():

// In startLoad(): reset circuit breaker state
this.resetBufferFullErrorState();

// In doTickIdle(): auto-recover when playback resumes
if (!this.buffering) {
  if (media && !media.paused && this._bufferFullErrorCount > 0) {
    this.resetBufferFullErrorState();
  }
  if (!this.buffering) {
    return;
  }
}

// Reset method:
protected resetBufferFullErrorState() {
  this._bufferFullErrorSn = -1;
  this._bufferFullErrorCount = 0;
  if (!this.buffering) this.resumeBuffering();
}

Result with circuit breaker:

10:26:18.601  BUFFER_FULL_ERROR circuit breaker activated: sn 1629 failed 4 times,
              stopping buffer loading. loadPos: 2.807, currentTime: 2.807
... (buffering stops — no more infinite loop, playlist reload continues normally)

--- User seeks → automatic recovery ---
10:26:44.691  Resetting BUFFER_FULL_ERROR circuit breaker (was sn: 2377, count: 4)
10:26:44.693  Loading main sn: 2391 of level 4
10:26:44.834  Buffered main sn: 2391 of level 4    ← normal append succeeds

This workaround complements PR #7749#7749 handles cases where back buffer exists, while the circuit breaker handles cases where it doesn't. The two modify different files and can be applied together.

Suggested fix

The root cause is that reduceLengthAndFlushBuffer() unconditionally resets nextLoadPosition = frag.start after removing the fragment, creating a reload loop when the append consistently fails.

Possible approaches:

  1. Circuit breaker (as described above): track consecutive failures per segment, pause buffering after N failures
  2. Advance past failing fragment: instead of nextLoadPosition = frag.start, advance to frag.start + frag.duration when reduceMaxBufferLength() is already at minimum
  3. Check if reduction had effect: if reduceMaxBufferLength() was already at minimum and couldn't reduce further, don't reset nextLoadPosition to the same fragment

Related issues / PRs

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions