Skip to content

6.17.1 - A clock the picture can start itself

Choose a tag to compare

@superuser404notfound superuser404notfound released this 09 Aug 16:32
· 881 commits to main since this release

One fix on the software path, for a session that comes up playing and never moves. Drop-in from 6.17.0, no API change.

The deadlock

A software-path session could publish state == .playing, with frames decoded and a first frame on screen, and hold currentTime at 0 forever. No error, no timeout, nothing a host could react to. One seek unwedged it and playback was normal from there.

Four deliberate facts closed a cycle:

  • play() does not start the clock cold; the demux loop arms it on the first decoded sample of the selected audio stream.
  • The video branch back-pressures on renderer.isReadyForMoreMediaData.
  • The renderer only drains while the synchronizer clock runs.
  • The loop is the single reader, so once it parks on that gate, every packet that could arm the clock is behind the park.

Clock waits for the selected audio, the selected audio waits for the loop, the loop waits for the renderer, the renderer waits for the clock.

Both existing escapes miss it. The video-branch fallback needs a nil audio decoder or 50 packets of the selected stream with no buffers out of them, and zero of those packets have arrived. The seek paths arm the clock directly, which is why a seek fixes it and why a resume never showed it.

What reaches it

Any source whose selected audio stream's first packet lies past the point where the video renderer fills. The reported case is a host applying a viewer's language preference a few milliseconds after play(): the audio-switch reload rebuilds the session at resumeAt = 0, and a track grouped late in the mux then has nothing in the read window that can arm the rebuilt session's clock. A switch a few seconds in never wedges, because the reload resumes past zero and the skip thresholds keep the renderer empty until the target.

The live feeder loop has the same gate. There a second thread keeps the ring filling and the look-ahead pump can still arm from it, so the terminal condition is different: the pump having spent its pre-arm budget without a decoded buffer, which is a declared audio track that never decodes. That closed the same way.

The fix

SWClockAnchorPolicy.shouldArmFromParkedVideo decides whether a park is terminal, and it is a state test rather than a timeout: unarmed, playing, renderer full, and nothing left that could still arm. When it is, the gate anchors on the video the renderer is already holding, through the same SWClockAnchorPolicy.resolve the video-only fallback uses, so a zero-based load keeps its anchor and a mid-stream-joined source re-anchors as it does today. One release-visible line names the stream that never arrived:

[SWHost] clock unarmed at the video gate: the selected audio stream (index 2) has
produced nothing by the renderer's fill point (audioPacketsSeen=0); anchoring on video

Frames are not over-enqueued past the gate to buy time instead: the post-tvOS-18 renderer answers an over-enqueue with FigVideoQueueRemote -12080.

Measured

40 s MKV, 1280x720 H.264, three Opus tracks. First packet ordinal per stream: video 17, jpn 1, eng 1562, fre 2. Software path forced, host switches audio 20 ms after playback starts.

Run Before After
--switch-audio 2@20 (eng, ordinal 1562) 15/15 samples state=playing cur=0.00 clock at 1x from the first tick
--switch-audio 3@20 (fre, ordinal 2) plays plays
--switch-audio 2@5000 wedges plays
no switch plays plays

Same file in every row: the discriminator is where the selected stream's first packet sits, not the file. The healthy runs never take the new path.

The switched track's audio joins as soon as its packets are read (abufs 4 → 167 at the tick its first packet lands, audio lead back to +0.5 s). Note the trade this makes: the clock now starts from the frame on screen, so samples of the newly selected track that are already behind it when they arrive are dropped by the audio renderer, and that track can start a beat late. A wedge cannot be traded for anything cheaper without holding undecodable-yet video packets instead of parking; if the late start is audible in the field, that is the next step.

aetherctl play grew --switch-audio <index>[@ms] for this, so the shape is reproducible from the CLI rather than only from a host. Paired with --audio-stats it re-installs the tap after the switch, because the tap is bound to the host the switch replaces (#356).

Test suite: 1684 tests in 246 suites.

Thanks to @edde746, who traced the whole cycle before filing, including which escape hatches miss and why a resume never sees it, and listed the possible exits without insisting on one.

Issues: #337.