Skip to content

6.12.0 - a dead source connection is noticed on wall-clock time

Choose a tag to compare

@superuser404notfound superuser404notfound released this 07 Aug 11:29
· 926 commits to main since this release

One field report, one layer: the persistent HTTP reader, and what it does when a transport stops delivering without saying so. Drop-in from 6.11.0, no source change on the consumer side.

Fixed: a connection that dies silently is detected on wall-clock time

connStallTimeout was evaluated in exactly one place, the forward wait inside readPersistent. While the sliding window could still serve reads, no read ever waited, so nothing looked at the clock: bytesFetched sat frozen for 4.5 minutes across a pause in the reporting session, and the reconnect fired only after a resumed consumer had drained the window, one 20 s stall wait later.

The transport does not cover that gap, by design and now by measurement. The persistent request is issued with request.timeoutInterval = 0 ("long-lived; stalls handled by the reader"). Against an origin that answers 206 headers and then goes silent with the socket held open, a request with timeoutInterval = 5 fails -1001 after 5.0 s, while the same request at 0 was still running after 100 s despite the session configuration carrying the default 60 s timeoutIntervalForRequest. A per-request 0 disables the idle timer and overrides the session value, so there was no backstop at any duration.

A generation that has an installed transfer and no delivery for connStallTimeout is now ended by a delivery-gap watchdog, whether or not a read is waiting on it. Same threshold and same action as the read-side wait; what is removed is the accidental exemption for a well-buffered reader. One pending check per generation, which either ends the connection or re-arms for the remaining gap, so a healthy transfer costs one timestamp comparison per connStallTimeout and an ended generation arms nothing.

It only ENDS. Opening connections stays with the read thread, which keeps two invariants a reconnecting watchdog would have broken: a paused player holds no flow at all (6.11.0), and a parked consumer cannot be driven into a timer-paced reconnect loop (#307, where a reconnect path off the failure ladder produced 925 generations on one offset). The gap gets its own line, since a frozen fetch counter had no witness:

[AVIOReader] pump gen=5 no delivery for 20.0s at offset 54640640 (14832KB read-ahead held, had delivered data); ending it

Fixed: a faulted connection is replaced while read-ahead remains

Detection alone would not have changed what the report measured. The frontier refill fired only for PLANNED ends, a range delivered in full or a high-water end. A generation that ended in fault was replaced only once the window reached EMPTY, so the reader spent its entire read-ahead before asking for a replacement, and playback rejoined the clock with a burst: +17 MB in one interval and 389 dropped frames in the field trace.

The condition is now "no flow installed and the consumer has drawn down to low water", for every reason there is no flow. The reason still selects the policy. A planned end costs nothing. A fault pays the failure ladder (status accounting, pin invalidation, bounded give-up) with two differences from the empty-window path:

  • No backoff sleep. It runs on the demux thread with megabytes still resident, and sleeping there would starve the demuxer of exactly the read-ahead that replacing early exists to protect. The wait is a next-attempt timestamp, so reads keep being served at full speed between attempts.
  • It never fails the read. A window that can still serve must not kill a session holding seconds of playback. At the cap it stops attempting and leaves termination to the empty-window ladder, where it has always been. It emits no .reconnecting phase while the window serves either: playback is uninterrupted there, and a phase flapping per read would describe bookkeeping rather than what the viewer sees.

Reported by @rrgomes in #309.

The trade this makes

An origin that pauses for longer than connStallTimeout mid-body while its socket stays alive now loses that connection, where a well-buffered reader used to ride the pause out. That is already the behaviour whenever the window is thin, which is the common case on a slow link, so this makes the policy consistent rather than new, and for a live source a 20 s delivery gap means the content is gone from that connection anyway. The replacement is requested at low water rather than immediately, so a hiccup shorter than the remaining runway costs one range request and no visible interruption.

The threshold stays at 20 s and stays out of LoadOptions: #272 measured that a shorter one is worse under CPU starvation, because it redials exactly when there are no cycles to spare. It is an AVIOReader init parameter now, alongside chunkRequestTimeout, rather than a process-wide hook.

Verification

The witness is a loopback origin that stops writing mid-body without closing the socket and without a FIN, which reproduces the reader-observable state of the report. Both halves were verified by falsification: with the watchdog disarmed the dead flow stays installed past six times the threshold, and with the runway refill disabled the replacement is asked for with 0 KB of read-ahead left. The runway is read from the ORIGIN's thread at the moment it receives that request, because a loopback origin answers in microseconds and no wall clock could express it.

On real media, aetherctl play on a 365 MB moov-at-end MP4 through a Range-logging origin paced at 4 MB/s with a 60 ms header delay produced an identical request sequence to 6.11.0 (six ranges, same offsets, roughly 9 s apart) and no watchdog activity.

swift test: 1548 tests, 231 suites, green on macOS 26, including three consecutive runs on a box saturated to ~690% of 800%.