Skip to content

A VOD outlives the end AVPlayer calls while its own seekable range runs on (#287) - #291

Merged
superuser404notfound merged 1 commit into
mainfrom
fix/premature-end-of-item-287
Aug 3, 2026
Merged

A VOD outlives the end AVPlayer calls while its own seekable range runs on (#287)#291
superuser404notfound merged 1 commit into
mainfrom
fix/premature-end-of-item-287

Conversation

@superuser404notfound

Copy link
Copy Markdown
Owner

Fixes the ~53 s early stop reported in #287.

What happens

An MKV whose selected audio track outruns its video track ends tens of seconds early. AVPlayer fires didPlayToEndTime the moment its video renderer runs dry, and the engine forwards that as organic completion. .ended is terminal (#63/#164), so the audio-only tail is unreachable for the rest of the session, and a host that rebuilds the session at the boundary gets the same instant end again.

Measured, not inferred

Reproduced deterministically on macOS with aetherctl play on a synthesized MKV (60 s HEVC video, 113 s AAC audio):

measured
container duration 113.023 s
video ends 59.93 s
playlist EXTINF sum 113.023 s (14 x 4.004 + 56.967)
didPlayToEndTime 60.03 s, final segment already fetched whole
53 s / 8 s / 2 s tail all three end at 60.03 s

Two things this rules out. The playlist is not video-anchored: its EXTINF sum equals the container duration to the millisecond, so the presentation the engine advertises is self-consistent. And the tail length is irrelevant, so the trigger is the video exhaustion itself.

The fix

The engine cannot talk AVPlayer out of that call, so it stops repeating it. An end that lands more than a second inside the range AVPlayer itself still reports as seekable is refused; the item is re-seeked to the SAME position and resumed. That re-arms the renderers, and the tail plays out to an organic end at the real duration with nothing dropped.

The witness is the seekable range, not the loaded one that carries #169. At the instant of the premature end AVPlayer has already trimmed loadedTimeRanges back to the exhaustion point ([22.34, 60.01] against a playhead of 60.03), so the loaded range corroborates the mistake instead of refuting it, while seekableTimeRanges still reports [0, 113.02]. play() without the seek was measured too: it leaves the clock frozen at the boundary indefinitely.

Bounds: recoveries are capped at 3 per item and each must have moved the playhead, so a source that genuinely cannot continue costs one re-seek and then completes exactly as it does today. A live session fails the guard twice over (explicit flag, and its seekable end is the live edge). A real serve failure never reaches this path, failing the item with failedToPlayToEndTime / -12889 instead.

The transient pause AVPlayer takes across the re-seek is swallowed, so the engine never bounces through .paused for something the viewer must not notice.

Verification

fixture before after
53 s tail (dur 113.02) ended 60.03 112.97, organic
8 s tail (dur 68.02) ended 60.03 67.98, organic
2 s tail (dur 62.02) ended 60.03 61.98, organic
equal-length A/V (dur 60.02) ended 59.97 59.97, recovery never armed
resume at the boundary (60.03) - plays to 112.97

Each recovering fixture takes exactly one attempt.

  • 1462 tests in 217 suites green (11 new in PrematureEndOfItemTests, written red first)
  • strict Swift concurrency clean
  • tvOS and iOS device builds green

aetherctl play gains --start-position, the resume anchor the reporter's hard-park arm needs to be reproducible at all.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HX5zV3Fcf7Nzq4DYGdXvQE

…able range runs on

An MKV whose selected audio track outruns its video track ends tens of seconds
early: AVPlayer fires didPlayToEndTime the moment the video renderer runs dry,
and the engine forwards that as organic completion. `.ended` is terminal, so the
audio-only tail is unreachable for the rest of the session and a host retry loop
that rebuilds at the boundary can never escape it (AE#287).

Reproduced deterministically on macOS with a synthesized 60 s-video / 113 s-audio
MKV: the end fires at 60.03 s of a 113.02 s presentation, and identically for a
2 s, an 8 s and a 53 s tail, so the trigger is the video exhaustion and not the
length of the tail. The playlist is not the culprit: its EXTINF sum equals the
container duration to the millisecond (14 x 4.004 + 56.967 = 113.023).

The engine cannot talk AVPlayer out of that call, so it stops repeating it. An
end that lands more than a second inside the range AVPlayer itself still reports
as seekable is refused, and the item is re-seeked to the SAME position and
resumed. That re-arms the renderers and the tail plays out to an organic end at
the real duration with nothing dropped; all three fixtures now finish at their
true end after exactly one recovery, and an equal-length file never enters the
path.

The witness is the seekable range, not the loaded one that carries #169: at the
instant of the premature end AVPlayer has already trimmed loadedTimeRanges back
to the exhaustion point ([22.34, 60.01] against a playhead of 60.03), so it
corroborates the mistake, while seekableTimeRanges still reports [0, 113.02].
`play()` without the seek was measured too and leaves the clock frozen at the
boundary indefinitely. Recoveries are capped and each one must move the playhead,
so a source that genuinely cannot continue costs one re-seek and then completes
exactly as it does today.

`aetherctl play` gains `--start-position`, the resume anchor the reporter's hard
park needs to be reproducible at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HX5zV3Fcf7Nzq4DYGdXvQE
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant