Skip to content

AVIOReader: unbounded reconnect spiral (zero backoff) when the origin refuses a range refill; pinned redirect URL never invalidated on 5xx #307

Description

@tschuegy

Media: 3.7 GB H.264 (avc1.640033, 3840x1920) + AAC MKV, VOD, served by an Xtream-style aggregator with max_connections=1, 302→http redirects, and no suffix-range support. Apple TV 4K, tvOS 26. AetherEngine 6.7.0, native dispatch path.

Symptom: Playback freezes ~1 min in, then dies. The log shows the reader wedged at a 32 MiB range frontier, reconnecting ~15×/s with zero backoff — 925 reconnects in 60 s — until the segment provider's forward-wait escalation tears the demuxer down (demuxer.readPacket threw: read failed (-1)pump finished: reason=readError(-1)), after which the session is unrecoverable:

[AVIOReader] pump conn start gen=5 offset=131554695 len=32MB
[AVIOReader] pump conn gen=5 ended with error: cancelled
[AVIOReader] pump conn start gen=6 offset=131554695 len=32MB
[AVIOReader] pump conn gen=6 ended with error: cancelled
... (925 generations, same offset) ...
[AVIOReader] slow read: 60012ms at offset=131554695 detour=0(0ms,0fetch)
  stallWaits=925(59365ms,925signaled) reconnects=925 backoff=0ms gen=5->931
[HLSSegmentProducer] pump finished: reason=readError(-1)

Root cause (three stacked defects):

  1. A connection that ends in error with zero bytes delivered for its generation lands in the no-connection reposition branch (AVIOReader.swift readPersistent, the activeTask == nil, !connEndedByBackpressure, !windowCanServe guard). That branch reconnects via seekReconnect, which resets unproductiveReconnects and applies no backoff, so the ended-connection ladder (status accounting, Retry-After, backoff, .reconnecting, bounded give-up) is unreachable. The spiral is unbounded by design.
  2. The rejected response's status is never logged (only the 200-ignored-Range case is), so the storm is unexplained in the field log.
  3. The pinned post-redirect URL is only invalidated on 401/403/404/410. An aggregator whose redirect targets expire per connection answers every later range with a hard 500 from the pinned URL, and the reader never falls back to the source URL for a fresh redirect.

Bonus: once the pump dies, handleVODReadErrorExit's revive cap (#169) exhausts silentlyonVODSourceFailed is only fired from the produced-nothing arm, so the host sees a forever-stalled AVPlayer with no error.

Repro: loopback origin that 302-redirects to a "CDN" which serves the first bounded range and answers 500 for every request at the next frontier (deterministic repro used for the tests in the linked PR; also reproduced with aetherctl play against such an origin).

Expected: an error-ended zero-byte connection takes the failure ladder (backoff, bounded give-up), a hard 5xx from a pinned URL drops the pin and re-resolves through the source URL, and revive-cap exhaustion surfaces onVODSourceFailed.

PR with fix + tests follows.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions