6.20.2 - A teardown that hands its selection to the reload
Drop-in from 6.20.1. One snapshot moved to the point where the state it captures still exists, and the #357 subtitle silence goes with it.
A teardown that hands its selection to the reload
Every reload path snapshots the state it intends to restore immediately before its own stopInternal: the #170 subtitle carryover, the audio pick, the disc title. That contract holds exactly as long as teardown and reload are the same call, which is true for the AirPlay LAN swap it was written for.
The paused-background teardown is not that call. It runs stopInternal when the app goes to sleep, and reloadAtCurrentPosition runs on foreground return, in the reported session 38 seconds later. By then the teardown has already run
subtitleDrainTargets.removeAll()
activeSubtitleTrackIndex = nil
isSubtitleActive = false
activeAudioTrackIndex = nil
activeDiscTitleID = nilso the reload snapshotted an empty session and restored nothing.
For subtitles that is not a lost checkbox. With no selection there is no drain target, subtitleDrainTick returns at its first guard, and nothing is decoded, published or logged. That is why the delivery statement, the resolution tick and the per-cue line all fell silent in the same instant, on a session whose PGS stream is present in the reopened demuxer: nothing ever asked for it. The audio pick and the disc title went through the same gap, less visibly, because audio auto-selection usually lands on the same default track.
Why only an explicitly picked track died
hostExplicitSubtitleAction is the one piece of subtitle state the teardown leaves standing, and it is the guard on the preferred-language auto-selection. So a track the host picked explicitly stayed gone, while a track that arrived through LoadOptions.preferredSubtitleLanguages came back on the reload's re-run auto-selection. The flag that blocks the recovery survived; the state it protects did not.
The fix
Both teardown paths, the grace expiry and the synchronous assertion backstop, park a BackgroundTeardownSelection before stopInternal, and reloadAtCurrentPosition claims it once, custom-source branch included.
The merge rule keeps the live read authoritative for everything that genuinely survives a teardown (the external track registry, its ordinal counter, the host's subtitle authority) and for any selection made after it, which is newer intent and wins, the same rule pendingNativeRenderingRequest already follows across a reload. The snapshot fills only the fields the teardown wiped. Any other load() and stop() drop it, so a parked selection cannot follow a different item.
What hosts have to do
Nothing, if the host already calls reloadAtCurrentPosition() on foreground return. A host that instead calls load() itself with the same URL is indistinguishable from a deliberate new session, the parked selection is dropped by design, and that host has to re-select after its load.
Reproducing it
Two things make this easy to miss, both worth knowing before writing it off as fixed:
- Pause before backgrounding. With playback running,
canStartPictureInPictureAutomaticallyFromInlinestarts PiP,shouldKeepVideoAliveisenabled && (pipActive || state == .playing), and no teardown ever happens. On tvOS the Home button is enough, there is no grace window there. - Seek past the drain lead afterwards. A host that mirrors
subtitleCueskeeps its own copy of the window the drainer had already filled, and the lead is 60 seconds. Subtitles therefore keep appearing, correctly timed, for up to a minute after a reload that delivers nothing. A seek beyond that window is what makes the failure visible, which is exactly how it was found.
What did not change
No behaviour on any live session, no API surface, no change to the drain, the gate or the reconstruction pass. BackgroundTeardownSelection is internal.
8 tests over the teardown-to-reload handoff, the re-armed drain target, the merge rule, single consumption and expiry. Suite 1745 green, tvOS and iOS build clean, device-verified on iOS in both directions.
Found and localized by @rrgomes, who instrumented his host to log the engine's published cue window, retracted an earlier reading on his own data, and then pinned the real trigger. Thank you.