Skip to content

6.15.2 - A live reader that stops fighting its own backpressure

Choose a tag to compare

@superuser404notfound superuser404notfound released this 08 Aug 06:16
· 859 commits to main since this release

Three fixes on the persistent AVIOReader's live path, all of them contributed by @tschuegy in #332 with field traces from two Xtream-style panels, plus one follow-up here. Drop-in from 6.15.1, internal surface only.

The three defects share one root: the reader's live path inherited machinery that was designed against a file with byte addresses, and a live source has none.

The reader was cycling its own healthy connections

The 16 MB high-water end from #310 had no live branch. Live connections are open-ended by design (there is no range to bound them with), so ending at high water was the only thing that ever terminated a healthy live connection. Each end drained about 8 MB to low water and re-requested at the frontier, and everything the origin broadcast during that drain is simply gone, so the demuxer rejoined on a corrupt TS packet.

It never happened once, either. IPTV panels serve their ring buffer as a join burst at line rate on every connect, so the burst refilled the window immediately and each reconnect caused the next one. The field trace cycles every ~9.5 MB with one [mpegts] Packet corrupt and one h264 decode error per cycle, forever.

Live readers now run a 64 MB high water, matching streamHighWater, the forward bound the engine already accepts for the other reader that cannot bound by range request. The join burst is absorbed once, steady state plateaus at burst size with the connection never voluntarily ended, and the end-and-refill survives unchanged as the memory backstop for a "live" source that sustainedly outruns realtime. The #310 contract is untouched: an actively delivering flow or no flow, never a parked task.

A reconnect asked for a byte address the origin never promised

The reconnect request carried Range: bytes=<frontier>-. The frontier is reader bookkeeping, the window position delivered bytes are appended at, and whether it means anything server-side depends entirely on the origin. Panels that ignore the offset and serve "from now" masked this for years. A panel that answers 416 to every offset it cannot satisfy turned each reconnect into an unrecoverable rejection loop: the field trace shows the same offset rejected 35 generations in a row at roughly 1/s, the read-ahead draining from 8 MB to zero, and the session ending in CoreMediaErrorDomain -12888.

The rejection is now the signal rather than something to repeat. The first 416 on a nonzero live offset latches the join shape (bytes=0-, the request every origin serves and the one the initial join already uses) for the rest of the session, and the log names the moment it happens:

[AVIOReader] pump live origin rejected offset 14472616 (416); requesting the stream as a join from here

The latch is deliberate rather than unconditional, because LoadOptions.isLive is a host declaration covering two origin shapes that want opposite things. A ring-buffer panel has no byte addresses at all. A live source that is a growing file (a Jellyfin live stream file, or a misdeclared VOD) answers the frontier correctly and resumes exactly where delivery stopped, and asking that one for byte zero re-delivers its whole buffer on top of the window. The flag cannot tell them apart; one rejected request can. A panel pays that one request per reader, a byte-addressable source pays nothing.

509 is metering, not a dead pin

509 "Bandwidth Limit Exceeded" is what a connection-capped panel answers while the slot the reader is replacing has not been torn down server-side yet. It classified as a hard 5xx, so every attempt dropped the pinned post-redirect URL and re-resolved through the portal: latency per attempt, plus a second request against the very origin that has no room for it. That is the 519ae26 reasoning (#307 follow-up) left incomplete for one status code.

429, 503 and 509 now go through a single isRateLimitStatus classifier used by the pin-drop exemption, both reconnect ladders, the detour fetch and the Retry-After parse. A 509 keeps the pin and pays the paced rate-limit streak with its bounded give-up instead of the zero-backoff hard-5xx grind.

Verification

1623 tests in 241 suites green, tvOS and iOS Simulator builds green.

Field-verified by the reporter on an Apple TV 4K running tvOS 26.6 against both panel types: panel A (ignores Range offsets) holds one connection with pumpAheadMB plateauing at burst size and no periodic 509s, 10+ minutes clean; panel B (416s nonzero offsets) previously starved within a minute and now reconnects through every burst cycle.

Every guard was also checked by disabling it and confirming the tests reproduce the field traces:

Guard disabled Result
live high water back to 16 MB window parks at 16.9 MB mid-burst, no connection held
live join shape never latched 4 MB delivered then starvation, 13 rejections at one offset
join shape latched unconditionally a range-honouring live origin's refill restarts at byte zero
509 out of the rate-limit class 13 attempts at the boundary with 12 portal re-resolves

Commits

  • #332 (@tschuegy): d3c4527d, 8a8e00b0, ef350fcd, merged as 1187bd71
  • #336 (latch follow-up): dad0f00a, merged as b8a8a450