6.11.0 - bounded reconnects, and a backpressure end that holds no flow
Two field reports from the same layer, the persistent HTTP reader, and both are about what the reader does when it should not be doing anything: retrying a refusal, or holding a connection it is not reading. Drop-in from 6.10.0, no source change on the consumer side.
Fixed: an origin refusing every refill no longer spirals
A connection that ended in error without delivering a single byte of its generation was routed through the no-connection reposition branch of readPersistent. That branch reconnects via seekReconnect, which clears the unproductive streak and applies no backoff, so the entire failure ladder (status accounting, Retry-After, exponential backoff, the .reconnecting phase, the bounded give-up) was unreachable from there. Against a connection-capped Xtream-style panel answering 500 at a 32 MiB range boundary, that is roughly 15 reconnects a second: 925 generations on one offset in 60 s, ending only when the segment provider tore the demuxer down from outside.
Such connections now take the ladder. Planned ends keep the fast path: a range delivered in full, a backpressure end, and any generation that delivered at least one byte, so the #220 no-refetch behaviour is untouched and regression-tested.
The refusal itself is no longer silent either. Only the 200-ignored-Range case was logged before, which left the storm unexplained in the field capture:
[AVIOReader] pump gen=6 rejected response status=500 at offset 131554695
[AVIOReader] pump conn ended at offset 131554695 status=500, reconnecting (streak=3 retryAfter=0s)
Fixed: a pinned redirect target is dropped on a hard 5xx
The reader pins the post-redirect URL after the first 200/206 and used to drop that pin only for auth-expiry statuses (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 hammered the dead target forever instead of re-resolving through the source URL. Hard 5xx now drops the pin, as does a streak of zero-progress failures.
A metered origin keeps its pin. 429/503 says the origin is rate limiting, not that the target is dead, and re-resolving would spend a second request on the origin that is already refusing them, which on a max_connections=1 panel is the request there is no room for.
Fixed: an exhausted revive cap surfaces instead of parking
handleVODReadErrorExit logged "giving up" and returned with no terminal surface once the #169 revive gate was spent: no producer, no error, AVPlayer parked in waitingToPlay until the host's own timeout. The cap-reached arm now fires onVODSourceFailed like the produced-nothing arm, so a session that cannot be revived ends as an error rather than as a frozen picture.
Reported by @tschuegy in #307, with the fix, the tests and the deterministic loopback repro in the same report.
Changed: backpressure ends the connection, it never suspends the task
URLSessionDataTask.suspend() is gone from the persistent reader. Past winHighWater the connection is ended, and the low-water frontier refill re-requests exactly where delivery stopped. Nothing is discarded and nothing is re-fetched; the reader now either has an actively delivering connection or none at all.
The reason is what a task that actually parks does to the rest of the process. It holds a dormant established flow whose closed receive window sits unread for as long as the consumer takes to drain: roughly 100 s per cycle at 1 Mbps media rate, indefinitely while paused. On tvOS and iOS, where TCP for Network.framework flows runs inside the app process, that dormant state comes with 10 to 80 s episodes in which every nw flow in the process goes deaf at once. Established WebSockets time out with their sends unACKed, no new handshake completes, and raw BSD sockets from the same process keep working throughout, which is what places the fault inside the process rather than on the wire.
The dose-response is what ties it to the reader. Same fleet, same wire, dormant stretch set by window size over media rate:
| media rate | dormant stretch | episodes |
|---|---|---|
| ~1.2 Mbps h264 1080p | ~100 s | ~11 in 80 min |
| ~14 Mbps HEVC 4K | ~8.5 s | 1 in 54 min |
| ~88 Mbps HEVC 4K | ~1.4 s | 0 in 118 min |
Verified over 71 minutes on two Apple TV 4K against the worst-case title, the regime that previously produced 9 to 11 episodes in 80 minutes: zero episodes, zero WebSocket receive errors, zero connection-setup timeouts, roughly 110 end-and-rerequest cycles per device, and two deliberate three-minute pauses that held no connection at all.
Reported, fixed and field-verified by @rrgomes in #310.
What went with the suspend
-
winHardCapand its branch are removed. The window is now bounded by construction at high water plus one delivery's in-flight overshoot, well under the cap and the realloc-doubling peak it had to be sized against. -
The suspend machinery is gone: the flag, the balance-before-cancel paths, the post-suspend delivery accounting. The deliberate cancel's completion is no longer logged as a transport error.
-
The memprobe reports
Parked=(backpressure-ended, refill pending) in place ofSusp=andPostMB=.windowDiagnosticsandioWindowDiagnosticsare internal, so this is a log-shape change rather than an API change. -
Bytes that arrive after the end are counted and named once per generation when they pass the high water. Each mechanism in this area has failed the same way exactly once (delegate-blocking left the socket reading,
suspend()measured advisory with 911 MB arriving after it), so if a transport ignores the cancel too, it says so rather than showing up as a climbing window:[AVIOReader] pump gen=7 19MB delivered AFTER the backpressure end; the cancel is not stopping this transport
Cost and compatibility
One extra range request per drain cycle, measured at roughly one per 39 s at 1 Mbps and around 2 ms per connect on LAN. A paused player holds no connection. Public API is unchanged; hosts that render AetherEngine.state will now see a terminal error on a VOD source that cannot be revived where they previously saw a stalled item.
swift test: 1544 tests, 230 suites, green on macOS 26.