Rewritten after fixing this on the host side and re-measuring on device. My original report
attributed an ~18-second main-actor stall to the engine's uncapped drain tick. That attribution was
wrong and I retract it. The same media on the same code path no longer freezes after a change
entirely in my own host code — memoizing a per-cue text conversion my cue consumer was re-running over
the whole cumulative snapshot on every publication. The engine tick still decodes the same ~3,132
packets; it was not what wedged the actor. What is below is what remains measurable from the host side.
What should the engine do?
$subtitleCues is published once per decoded subtitle packet, and each publication carries the
entire cumulative cue array. subtitleDrainTick calls applySubtitleEvent inside its packet loop
(AetherEngine+Subtitles.swift:297-305), and applyEventMutations performs a full get/set of the
@Published property per event. Any consumer that walks the array therefore does O(n) work per packet,
which is O(n²) per drain window — and there are three consumers today (the host's libass presenter, the
legacy overlay, and the engine's own software PiP compositor sink).
Ask: bind the channel's cue array once per tick, apply every event of the window to it, and publish
once at the end. applyEventMutations already takes inout [SubtitleCue], so this is hoisting the
binding out of the loop.
Measured, on device
Typeset ASS track (stream 5 of an MKV with font attachments), engine 6.4.0 (b43f17df), native AVPlayer
path, iPhone. One second of steady-state playback, host telemetry:
overlay: driver=active ticks=62 … | pub=104 scan=608608 add=0 build=0 ingestMs=43.4/4.4
main: beats=4 windowMs=1078.8 maxLateMs=46.9 clockMs=135400 inFlight=idle
- 104 publications per second, each carrying ~5,852 retained cues → 608,608 cue visits per second
in one consumer alone.
- The host spends 43 ms/s just walking them, with main-actor lateness of 35–66 ms.
add=0 — none of those visits found new work. The walk is pure overhead.
A host cannot avoid the walk: a cumulative snapshot does not say which elements are new. On this file,
publishing once per tick instead of once per packet would take 104 publications/s to roughly 2/s.
For scale on window size, ffprobe over the window one reset tick covered (playhead 114.9 s, backscan
15, lead 60, so 99.9–174.9 s): 3132 subtitle packets, 240 sharing PTS 107.680, 303 on the densest
single PTS.
Two further items, labelled by how I know them
Code-derived, not observed as the freeze cause. subtitleDrainTick is a synchronous @MainActor
function whose window is bounded in seconds of content (backscan 15 + lead 60) and never in packets or
wall time, and its loop is unbounded (:297-305). The OCR drainer treats the same shape as needing a
bound — entries.prefix(Self.subtitleOCRMaxPacketsPerTick) (AetherEngine+SubtitleOCR.swift:82). With
cheap consumers this no longer stalls anything I can measure, but one dense window still runs to
completion with no suspension point, so the ceiling is set by file density rather than by the engine.
One constraint if that cap is ported as-is: the cursor is a bare PTS advanced by lastDecodedPts.nextUp
(SubtitleOverlayDrainer.swift:48), so truncating a batch inside a same-PTS run would skip the
remainder rather than resume it. Harmless in the OCR worker (one composition per PTS on bitmap tracks);
data loss here, where a dense ASS track deliberately keeps every distinct payload on a shared timestamp.
A batch boundary has to fall on a PTS boundary, or the cursor needs an intra-PTS index.
Latent, and no longer reachable on this sample. drainPlan compares the live playhead against
cursor.lastPlayhead, which is captured at tick start (:251, 310-313), against
subtitleDrainJumpThresholdSeconds of 2.5. Any tick lasting longer than that makes the next tick see a
discontinuity and reset onto a fresh, disjoint 75-second window instead of decoding forward from the
cursor. That is a positive feedback loop for any slow tick, whatever the cause. Re-reading sourceTime
at tick end, or comparing the jump against elapsed wall time, would keep self-inflicted drift from
reading as a seek.
Area
Performance
Host app / integration
Custom
Would you be willing to open a PR?
Maybe, with guidance
What should the engine do?
$subtitleCuesis published once per decoded subtitle packet, and each publication carries theentire cumulative cue array.
subtitleDrainTickcallsapplySubtitleEventinside its packet loop(
AetherEngine+Subtitles.swift:297-305), andapplyEventMutationsperforms a full get/set of the@Publishedproperty per event. Any consumer that walks the array therefore does O(n) work per packet,which is O(n²) per drain window — and there are three consumers today (the host's libass presenter, the
legacy overlay, and the engine's own software PiP compositor sink).
Ask: bind the channel's cue array once per tick, apply every event of the window to it, and publish
once at the end.
applyEventMutationsalready takesinout [SubtitleCue], so this is hoisting thebinding out of the loop.
Measured, on device
Typeset ASS track (stream 5 of an MKV with font attachments), engine 6.4.0 (
b43f17df), native AVPlayerpath, iPhone. One second of steady-state playback, host telemetry:
in one consumer alone.
add=0— none of those visits found new work. The walk is pure overhead.A host cannot avoid the walk: a cumulative snapshot does not say which elements are new. On this file,
publishing once per tick instead of once per packet would take 104 publications/s to roughly 2/s.
For scale on window size,
ffprobeover the window one reset tick covered (playhead 114.9 s, backscan15, lead 60, so 99.9–174.9 s): 3132 subtitle packets, 240 sharing PTS 107.680, 303 on the densest
single PTS.
Two further items, labelled by how I know them
Code-derived, not observed as the freeze cause.
subtitleDrainTickis a synchronous@MainActorfunction whose window is bounded in seconds of content (backscan 15 + lead 60) and never in packets or
wall time, and its loop is unbounded (
:297-305). The OCR drainer treats the same shape as needing abound —
entries.prefix(Self.subtitleOCRMaxPacketsPerTick)(AetherEngine+SubtitleOCR.swift:82). Withcheap consumers this no longer stalls anything I can measure, but one dense window still runs to
completion with no suspension point, so the ceiling is set by file density rather than by the engine.
One constraint if that cap is ported as-is: the cursor is a bare PTS advanced by
lastDecodedPts.nextUp(
SubtitleOverlayDrainer.swift:48), so truncating a batch inside a same-PTS run would skip theremainder rather than resume it. Harmless in the OCR worker (one composition per PTS on bitmap tracks);
data loss here, where a dense ASS track deliberately keeps every distinct payload on a shared timestamp.
A batch boundary has to fall on a PTS boundary, or the cursor needs an intra-PTS index.
Latent, and no longer reachable on this sample.
drainPlancompares the live playhead againstcursor.lastPlayhead, which is captured at tick start (:251, 310-313), againstsubtitleDrainJumpThresholdSecondsof 2.5. Any tick lasting longer than that makes the next tick see adiscontinuity and reset onto a fresh, disjoint 75-second window instead of decoding forward from the
cursor. That is a positive feedback loop for any slow tick, whatever the cause. Re-reading
sourceTimeat tick end, or comparing the jump against elapsed wall time, would keep self-inflicted drift from
reading as a seek.
Area
Performance
Host app / integration
Custom
Would you be willing to open a PR?
Maybe, with guidance