Skip to content

5.23.2

Choose a tag to compare

@superuser404notfound superuser404notfound released this 25 Jul 11:43

A VOD seek on a slow source no longer reverts the clock (#216)

On a high-latency source, the report was Dolby Vision Profile 7 streamed over SMB from a NAS, a VOD seek can miss its 8 second deadline while the producer is still genuinely serving the target. The recovery then reverted the reported clock to the frozen pre-seek position: the scrubber visibly jumped back to the old spot, and the session parked flapping paused to playing and back for roughly 40 seconds while the orphaned old-position segment drained.

Why the old metric could not tell slow from wedged

bufferedEnd and seekIsWedged measure only the buffer contiguous with AVPlayer's playhead, and a pending zero-tolerance seek pins that playhead at the pre-seek position. AVPlayer buffers the target region into media that is not contiguous with it, so at the target the metric structurally reads 0. A slow-but-working seek and a genuinely wedged one produce the same reading, and the recovery ran on both.

The new bufferedSecondsAtTarget measures what the producer has actually served at the target, referencing no playhead at all. That is deliberate: bufferedEnd and avPlayerBufferAheadSeconds() are both measured from item.currentTime(), while the deadline loop's frozen position is renderedTime, and during a buffering landing those two legitimately diverge (#123). Any figure derived by subtracting one from the other is meaningless in exactly the case this decision has to judge. The target is an absolute playlist time, so measuring against it needs neither.

What changes

  1. The deadline extends while progress is real. Media buffered at the target must be above a floor and still growing between windows. Presence alone is a single observation and cannot distinguish a producer still filling from one that served four seconds and then died, which would otherwise buy the whole budget. A target the producer's march cannot reach (AE#141) is never granted an extension however healthy the buffer looks, since it rides serve timeouts into item death.
  2. The fallthrough holds the clock at the target instead of reverting it. The producer is re-anchored there once, the seek re-issued so AVPlayer abandons the old-position buffer, and the loop waits a bounded number of windows. It reconciles forward to the target, never back, and never flaps the transport.
  3. A forward overshoot counts as a landing. A zero-tolerance seek lands at the target and a playing item then advances, so by the time the deadline continuation samples the position a forward seek can sit a GOP past it. Reading that as "still pending" triggered a backward correction that dragged a playing playhead back and re-stalled it. The pinned pre-seek playhead sits far on the opposite side, so the directional check cannot mistake it for a landing.
  4. The landing is edge-detected at AVPlayer's own 100 ms cadence, so the host's loading state clears when playback actually resumes rather than up to a full window later, which left a spinner over already-playing video. The poll reads the published renderedTime rather than avPlayer.currentTime(), which is a synchronous XPC read (#134) this loop would perform hundreds of times on the main actor. It requires position evidence and deliberately does not accept "the seek completion ran": reengageStalledConsumer calls cancelPendingSeeks() without bumping the seek generation, so a cancelled seek would otherwise report as a landing and silently retire the recovery target (#93).

Bounded throughout: at most 4 deadline extensions, one re-anchor, and 4 post-re-anchor waits, so await seek(to:) stays suspended for at most about 44 seconds on a source that keeps making measurable progress and never lands. Both counters are monotone and never reset. The terminal give-up holds the clock at the target rather than reverting, but clears the programmatic-seek gate and reports .rebuffering or .stalled instead of staying .seeking: the permanent-spinner class this change exists to remove must not simply relocate to the give-up.

Scoped to the native VOD deadline branch. Live, software and audio-host paths are untouched, and #65, #122 and #123 are preserved.

Follow-up hardening

Three edges the new wait windows opened, none of them wrong in the original reasoning:

  • The loop could be finalized underneath it. The $renderedTime sink accepts a landing within 5 seconds of the target, the loop's poll wants 0.75 on the near side. A landing a few seconds short therefore finalized through the sink while the loop still read "pending" and went on to re-anchor and re-seek backward onto an item already reported as playing, reintroducing the very yank point 3 removes. The loop now guards on programmaticSeekInFlight, the authoritative "this seek is still ours" latch.
  • The 30 second measurement window only protects a far backward seek. A backward seek shorter than the window leaves the abandoned playhead's own forward buffer inside it, where a still-full old buffer reads as media served at the target. The loop now passes the frozen playhead as an exclusion bound on backward seeks.
  • Cancellation. The wait sleeps on the caller's task, so a cancelled task made every remaining window return in microseconds: the loop burned its whole budget inside one runloop turn and still restarted the producer on the way through. It now terminates on the give-up contract.

Credits

Contributed by Brandon Moore (#216), including the device work that isolated it. Thank you.

Covered by: 1066 tests green across 171 suites, macOS plus tvOS and iOS Simulator builds clean, no new warnings. Device-tested by the contributor on an Apple TV 4K (3rd generation) running tvOS 27 against Dolby Vision Profile 7 over SMB, with fast local HTTP exercised to confirm seeks that land in budget are unchanged.