Skip to content

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

Description

@edde746

What happened?

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):

  1. play() deliberately does not start the clock cold: "demux loop arms the clock on first decoded audio sample" (play(), ~716).
  2. Arming happens in the demux loop's selected-stream audio branch, on the first decoded buffer (~1855).
  3. 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.
  4. 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)
let engine = try AetherEngine()
_ = try await engine.load(url: url, options: LoadOptions(suppressDisplayCriteria: true))
engine.play()
try? await Task.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.

Area

Software (VideoToolbox) playback path

Would you be willing to open a PR?

Maybe, with guidance

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions