You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Software path: cold-start clock arming deadlocks when the selected audio stream first appears after the renderer fills (state=playing, currentTime=0 forever; one seek unwedges) #337
A software-path VOD session can come up with state == .playing, frames decoded, the layer isReadyForDisplay, hasFirstFrameReadyForDisplay == true — and currentTime pinned at 0 forever. No error, no timeout, nothing for a host to react to. One seek unwedges it and playback is normal from there.
Trigger: selectAudioTrack(index:) shortly after play(), selecting a track whose first packet sits later in the mux than the point where the video renderer fills. The audio-switch reload rebuilds the host with resumeAt = 0.00, and the rebuilt session deadlocks on its own cold-start clock arming.
Field case: Apple TV 4K (A15, tvOS 26), AV1+Opus MKV via Plex, host applies the viewer's preferred audio language ~20 ms after play() (engine default was track 1/jpn, preference was track 2/eng). Screen shows the first frame, position never moves; the viewer's first seek starts playback. Verbatim from the device log:
[AetherEngine] reload begin: audioStream=2 resumeAt=0.00s embeddedSub=-1 sidecar=nil
[AetherEngine] reload: loadSoftware done (414ms)
[AetherEngine] reload: state=.playing total=420ms
[SWHost] first video frame enqueued: pixfmt=0x34323076 size=1916x1072 pts=0.000s
[Renderer] enqueue #1: status=rendering ready=true error=nil
[SWHost] layer.isReadyForDisplay=true after 5 frames
... 19 s of state=playing with the host's progress reports stuck at time=0 ...
[AetherEngine] seek#1 programmatic began target=10.00
[AudioOutput] seekClock to=10.000 rate=1.0 <- first time the clock ever moves
[AetherEngine] seek#1 programmatic landed rendered=10.00 target=10.00
The deadlock
Four facts, each individually deliberate, that close a cycle (all refs 6.15.2, Native/SoftwarePlaybackHost.swift):
play() deliberately does not start the clock cold: "demux loop arms the clock on first decoded audio sample" (play(), ~716).
Arming happens in the demux loop's selected-stream audio branch, on the first decoded buffer (~1855).
The video branch back-pressures on renderer.isReadyForMoreMediaData (~1776). With the synchronizer at rate 0 anchored at 0, enqueued frames never drain, so once the renderer fills, the loop parks — upstream of every packet that could arm the clock.
Both escape hatches miss:
the video-branch fallback (~1807) needs audioDecoder == nil (it isn't — the decoder opened fine) or audioPacketsSeen >= 50 — but that counter only counts selected-stream packets, and zero of them have arrived;
the seek paths arm directly (seekClock + clockArmed = true, ~865-875), which is why the viewer's seek fixes it, and why nobody had seen it from a resume.
So: clock waits for selected audio → selected audio waits for the demux loop → the loop waits for the renderer → the renderer waits for the clock.
Why this mux hits it and most don't
The file is legal Matroska with per-track-grouped interleaving. First packet ordinal per stream (ffprobe, packet order):
stream
codec
first packet #
0 (video, AV1)
av01
18
1 (audio, jpn — the default)
opus
3
2 (audio, eng — the one selected)
opus
435
3 (audio, fre)
opus
11
An instrumented run shows the rebuilt session parking at packet #76 with zero stream-2 packets seen. The initial load never wedges because the default track arms at packet #3. A switch a few seconds in never wedges either, because the reload resumes at startPosition > 0, and videoDecoder.skipUntilPTS / renderer.setSkipThreshold keep the renderer empty until the target — the loop reads straight through to stream 2. Only the start-from-zero rebuild has a fillable renderer between play() and the first selected-audio packet.
Reproduction (headless, macOS, 6.15.2)
EngineLog.handler ={print($0)}AetherEngine.setForceSoftwarePathForTesting(true)letengine=tryAetherEngine()
_ =tryawait engine.load(url: url, options:LoadOptions(suppressDisplayCriteria:true))
engine.play()try?awaitTask.sleep(for:.milliseconds(20))
engine.selectAudioTrack(index:2) // a stream that first appears late in the mux
// currentTime stays 0.00 indefinitely; state stays .playing.
Observed against the field file: 15/15 one-second samples read state=playing current=0.00. Control run with the switch at +5000 ms instead of +20 ms: position resumes at 5.11 and advances normally — same file, same target stream. The wedged session's log shows video enqueues and layer.isReadyForDisplay=true after 6 frames, and never a [AudioOutput] seekClock line.
Any MKV whose selected track's first packet ordinal exceeds the renderer's fill point reproduces it; muxers that lag one track by a second or two produce this shape routinely. Happy to share a trimmed sample privately if useful.
What should the engine do?
The deadlock signature is precise: renderer refuses enqueues while the clock is unarmed. Any of these breaks the cycle; choosing is yours:
arm off the first video frame when the video branch would otherwise park unarmed (anchor via SWClockAnchorPolicy at the frame's PTS, same shape as the existing video-only fallback), and let the first real audio buffer re-anchor if needed — AU live tv #107's machinery looks built for exactly that;
or count every demuxed audio-type packet (not only the selected stream's) toward the >= 50 fallback, so a late selected stream degrades to video-anchored arming instead of deadlock;
or bound the back-pressure park while unarmed.
Also worth a thought regardless of the fix: while wedged, the session publishes .playing with a ready first frame — a host has no signal that transport is dead (#334's ceiling covers the remote-HLS bypass, not this). If arming can still be defeated by some future shape, a "clock never armed" diagnostic after N seconds of .playing at rate 0 would make the next one of these a one-log-line diagnosis.
What happened?
A software-path VOD session can come up with
state == .playing, frames decoded, the layerisReadyForDisplay,hasFirstFrameReadyForDisplay == true— andcurrentTimepinned at 0 forever. No error, no timeout, nothing for a host to react to. One seek unwedges it and playback is normal from there.Trigger:
selectAudioTrack(index:)shortly afterplay(), selecting a track whose first packet sits later in the mux than the point where the video renderer fills. The audio-switch reload rebuilds the host withresumeAt = 0.00, and the rebuilt session deadlocks on its own cold-start clock arming.Field case: Apple TV 4K (A15, tvOS 26), AV1+Opus MKV via Plex, host applies the viewer's preferred audio language ~20 ms after
play()(engine default was track 1/jpn, preference was track 2/eng). Screen shows the first frame, position never moves; the viewer's first seek starts playback. Verbatim from the device log:The deadlock
Four facts, each individually deliberate, that close a cycle (all refs 6.15.2,
Native/SoftwarePlaybackHost.swift):play()deliberately does not start the clock cold: "demux loop arms the clock on first decoded audio sample" (play(), ~716).renderer.isReadyForMoreMediaData(~1776). With the synchronizer at rate 0 anchored at 0, enqueued frames never drain, so once the renderer fills, the loop parks — upstream of every packet that could arm the clock.audioDecoder == nil(it isn't — the decoder opened fine) oraudioPacketsSeen >= 50— but that counter only counts selected-stream packets, and zero of them have arrived;seekClock+clockArmed = true, ~865-875), which is why the viewer's seek fixes it, and why nobody had seen it from a resume.So: clock waits for selected audio → selected audio waits for the demux loop → the loop waits for the renderer → the renderer waits for the clock.
Why this mux hits it and most don't
The file is legal Matroska with per-track-grouped interleaving. First packet ordinal per stream (ffprobe, packet order):
An instrumented run shows the rebuilt session parking at packet #76 with zero stream-2 packets seen. The initial load never wedges because the default track arms at packet #3. A switch a few seconds in never wedges either, because the reload resumes at
startPosition > 0, andvideoDecoder.skipUntilPTS/renderer.setSkipThresholdkeep the renderer empty until the target — the loop reads straight through to stream 2. Only the start-from-zero rebuild has a fillable renderer betweenplay()and the first selected-audio packet.Reproduction (headless, macOS, 6.15.2)
Observed against the field file: 15/15 one-second samples read
state=playing current=0.00. Control run with the switch at+5000 msinstead of+20 ms: position resumes at 5.11 and advances normally — same file, same target stream. The wedged session's log shows video enqueues andlayer.isReadyForDisplay=true after 6 frames, and never a[AudioOutput] seekClockline.Any MKV whose selected track's first packet ordinal exceeds the renderer's fill point reproduces it; muxers that lag one track by a second or two produce this shape routinely. Happy to share a trimmed sample privately if useful.
What should the engine do?
The deadlock signature is precise: renderer refuses enqueues while the clock is unarmed. Any of these breaks the cycle; choosing is yours:
SWClockAnchorPolicyat the frame's PTS, same shape as the existing video-only fallback), and let the first real audio buffer re-anchor if needed — AU live tv #107's machinery looks built for exactly that;>= 50fallback, so a late selected stream degrades to video-anchored arming instead of deadlock;Also worth a thought regardless of the fix: while wedged, the session publishes
.playingwith a ready first frame — a host has no signal that transport is dead (#334's ceiling covers the remote-HLS bypass, not this). If arming can still be defeated by some future shape, a "clock never armed" diagnostic after N seconds of.playingat rate 0 would make the next one of these a one-log-line diagnosis.Area
Software (VideoToolbox) playback path
Would you be willing to open a PR?
Maybe, with guidance