6.19.0 - An origin where only byte zero is real
Six contributions from @tschuegy, out of one stretch of IPTV work on a real Apple TV. One new LoadOptions pair, otherwise drop-in from 6.18.1.
An origin where only byte zero is real
Timeshift and catch-up archives commonly fabricate their range answers. Range: bytes=X- comes back as a plausible 206, and the body actually sits on a coarse internal chunk boundary somewhere else. Nothing in the response says so, so every mechanism built on ranges quietly returned the wrong content instead of an error: the 32 MB range rotations spliced misplaced audio into each reconnect (heard as a once-a-minute desync), the tail-read duration estimate turned a 135 minute window into 9.5 hours, and the static segment plan's uniform EXTINF was wrong for any archive whose GOP cadence does not divide the cut target.
Headers cannot expose the lie, so the caller declares it:
try await player.load(url: archiveURL, options: LoadOptions(
sequentialOrigin: true,
declaredDurationSeconds: 8100
))The reader then runs one long-lived unranged GET with no ranged probes, no byte-offset reconnects and no tail read, the demuxer's pb is non-seekable, and a dropped connection surfaces as EIO rather than EOF so the host can re-request instead of seeing end-of-media. The declared duration takes precedence over the container's, such a source keeps the native path instead of being forced to software, and the session serves an append-only EVENT playlist carrying the durations actually muxed, completed with ENDLIST at true source EOF.
Seeking is unavailable by construction: re-request the archive with a shifted start timestamp. That refusal covers every producer reposition, not just the readError revive it was first written for. performRestart's demuxer seek cannot land anywhere on a non-seekable pb and does not report that as a failure, so a scrub-driven or deadline-driven restart would have kept reading wherever the stream stood and labelled those bytes as the target segment: the same fabricated-position content the declaration exists to keep out, only silent.
aetherctl play gains --sequential-origin / --declared-duration for reproducing one from macOS. (#346)
Live sessions that stop zombifying
Four separate ways a live session could end up alive-but-dead, each with a different owner:
A rotating muxer that starts un-primed. E-AC-3 builds its mp4 sample entry from a parsed packet, so a muxer allocated mid-session (a same-PID parameter-set change after a reconnect join, or an SSAI program switch) had nothing to build from if its first segment was cut before any post-seam audio packet was muxed, and died with muxerFailed. The AE#222 exit scan could not rescue it, because a raw source frame cannot prime a bridge-encoded track. The producer now retains the last audio frame a muxer accepted and primes every later allocation with it. (#340)
A pump death with no recovery arm at all. The provider kept serving a frozen playlist, AVPlayer parked on it waiting for buffer that never came, and nothing surfaced to the host. The live arm now rebuilds the producer in place on the same connection at the live continuation point (a reopen would double-connect against a healthy socket), bounded by progress rather than per session, and halts production plus asks the host to retune once the budget is spent. (#341)
A watchdog with one shot. The stall re-engage watchdog was edge-triggered on a state only one edge produces, so a player that drained its remaining tail segments and then parked on a frozen playlist was unreachable: playbackStalled does not re-fire while the forward buffer is non-empty, a waiting player never posts failedToPlayToEndTime, and the producer-side wedge detector died with the pump. It now re-baselines and keeps watching for up to a minute, the stage-2 reload carries a budget that spans stall events, and a session whose clock has not advanced after the reload publishes liveSourceReset. The final rung asks whether the clock advanced, not whether the value changed: with an in-place swap, "different" is not "further along". (#342)
An escalation only one transport reached. Both reopen-exhaustion sites escalated only for the custom-factory transport #199 introduced, so the far more common URL session reached the same dead end with its provider left un-halted: it kept advertising blocking reloads it could never answer (-15410 on any held ?_HLS_msn=, and on an item reload against it), the playlist stayed frozen, and the host was never asked to retune. Every in-engine transport now halts production and publishes liveSourceReset on exhaustion; a source with no in-engine transport still delegates at the pump exit and is deliberately not signalled twice. (#343)
The software path on a chunked archive
Timelines that restart every chunk. A chunked archive restarts its timestamps at PTS ~0 on every chunk (device trace: an 89 s chunk ending at raw 2717.9 s, the next opening at 0.04 s). FFmpeg's 33-bit wrap correction read that -2718 s jump as +92726 s, the renderer waited 25 hours for the frame's display time, and the video queue died with FigVideoQueueRemote -12080: picture and sound frozen about 90 s into every session. The discontinuity fold was gated on isLive; it now also covers a forward-only source, which is served as-is and offers no seek-based recovery either. Seekable VOD keeps its trusted container timeline untouched.
Audio paced by the video queue. The combined demux loop paced everything on the video renderer's ~10 frame queue, so interleaved audio could never build more than ~0.3 s of lead over the synchronizer clock. Any decode or deinterlace jitter beyond that starved the audio renderer, the clock leapt to the next sample's PTS, and the queued video was suddenly late enough for the layer to drop it, seen as a forward skip (a 1080i50 archive replay measured periodic +0.25 s clock leaps and 17 % renderer drops). Video packets now park in a bounded FIFO drained at the renderer's pace while audio decodes ahead of the clock up to AudioLookaheadPolicy.targetLeadSeconds, and a genuine underrun pauses the clock for a rebuffer instead of letting it free-run, the same policy the DVR feeder arm uses. Live keeps its lockstep pacing, and the sessions that need a pump get it from the DVR arm on its own thread. (#347)
[SWDiag]. Software sessions had nothing between the 30 s memprobe and a device capture, which is too coarse to see the clock leaps and layer-drop bursts a stuttering session is made of. There is now a 1 Hz line carrying clock and clock delta, decoded audio lead, parked FIFO depth, rebuffer state, the display layer's own drop counter and accumulated render delay with per-second deltas, queue-target status, surface state and isReadyForDisplay. The native path's [LagDiag] equivalent is unchanged.
Follow-ups carried in the same release
Each contribution landed with a hardening commit on top, most of them the same class of defect twice:
- A fix written while building a new path tends to reach only that path. #343's escalation, #346's reposition refusal and #347's #337 unarmed-clock exit each covered one call site of two or three.
- A policy constant that only appears in a comment bounds nothing. #347's read gate named
targetLeadSecondsand never read it, so the effective audio lead was however many seconds of video fit in a 256 packet FIFO, a figure that moves with frame rate. - Waiting on a consumer that only the waiting thread can restart is a deadlock, not latency. All three of the software loop's renderer waits could sit under a rebuffer hold that stops the synchronizer, and the synchronizer is what drains the renderer. The DVR arm may wait there because its pump runs on a second thread.
Verification
Suite green at 1712 tests in 252 suites; tvOS, iOS, macOS and visionOS Simulator builds green. The device work behind every item here (a tvOS 26.6 Apple TV against a live IPTV origin and its catch-up archive) is @tschuegy's, as is the whole series. Thanks.