Summary
Opening and dismissing a playback session both freeze the host SwiftUI app for several seconds on a typical HEVC + audio MKV served from a CDN-fronted source:
-
Open: ~10–20 s freeze. AetherEngine is @MainActor and load(url:)'s body is synchronous Swift — there are no real await checkpoints between the entry and the network/demuxer work. The two largest blocking calls inside load() are:
probe.open(url:) (line ~469) — opens a brief Demuxer to detect tracks/format/frame rate; runs the AVIO HTTP probe + avformat_open_input + avformat_find_stream_info synchronously. On a slow CDN this is ~6 s by itself.
try session.start() inside loadNative() (line ~686) — opens a second Demuxer for the actual session and generates the segment plan. Another ~1–3 s.
-
Dismiss: ~1.5 s freeze. SwiftUI releases the host's @State var player: AetherEngine on the main thread; HLSVideoEngine.deinit (line 941-943) calls stop() synchronously, which joins the segment producer thread with _ = p?.waitForFinish(timeout: 3.0). SwiftUI dismiss doesn't go through any explicit stop(), so the slow work hits the main thread via deinit.
Both stem from the same underlying issue: AetherEngine performs synchronous blocking I/O on the main actor (or on the deallocating thread).
Reproduction
Any HEVC source that goes through the native AVPlayer path, served from a CDN with non-trivial round-trip latency:
await player.load(url: ...) → main thread blocked ~10–20 s. SwiftUI parent unresponsive, no input or redraw during this window.
- SwiftUI parent removes the view holding
@State var player: AetherEngine → main thread blocked ~1.5 s in HLSVideoEngine.deinit → stop() → waitForFinish.
Why the host can't fix this alone
Because AetherEngine is @MainActor, even wrapping await player.load(...) in Task.detached { ... } re-hops back to the main actor before calling load. There's no escape hatch — the engine itself has to be the one that hops off-main for its blocking I/O. Same for the deinit: SwiftUI decides which thread releases the last reference, and for @State it's main.
Suggested fixes
In order of design quality:
-
Make the segment producer cancellable without a join. Replace the manual thread + waitForFinish with a Task (or a detached thread observing an atomic cancel flag) that exits on its own and requires no synchronous join. Then stop() and deinit are near-instant by construction, and the dismiss freeze disappears regardless of which thread releases the engine.
-
Make AetherEngine.load(url:) actually async. Move the heavy I/O off the main actor internally — either by making the engine an actor instead of @MainActor class, or by surgically dispatching the synchronous calls (probe.open, session.start, host.load) to a detached task within load() and hopping back to main only for @Published state mutations. The public signature stays the same; consumers don't change.
-
Expose an explicit async close(). Hosts that care about deterministic cleanup await engine.close() from a Task; deinit becomes a fast assertion / no-op (or the same async-dispatch fallback for hosts that forget to close).
Happy to open a PR.
Summary
Opening and dismissing a playback session both freeze the host SwiftUI app for several seconds on a typical HEVC + audio MKV served from a CDN-fronted source:
Open: ~10–20 s freeze.
AetherEngineis@MainActorandload(url:)'s body is synchronous Swift — there are no realawaitcheckpoints between the entry and the network/demuxer work. The two largest blocking calls insideload()are:probe.open(url:)(line ~469) — opens a briefDemuxerto detect tracks/format/frame rate; runs the AVIO HTTP probe +avformat_open_input+avformat_find_stream_infosynchronously. On a slow CDN this is ~6 s by itself.try session.start()insideloadNative()(line ~686) — opens a secondDemuxerfor the actual session and generates the segment plan. Another ~1–3 s.Dismiss: ~1.5 s freeze. SwiftUI releases the host's
@State var player: AetherEngineon the main thread;HLSVideoEngine.deinit(line 941-943) callsstop()synchronously, which joins the segment producer thread with_ = p?.waitForFinish(timeout: 3.0). SwiftUI dismiss doesn't go through any explicitstop(), so the slow work hits the main thread via deinit.Both stem from the same underlying issue: AetherEngine performs synchronous blocking I/O on the main actor (or on the deallocating thread).
Reproduction
Any HEVC source that goes through the native AVPlayer path, served from a CDN with non-trivial round-trip latency:
await player.load(url: ...)→ main thread blocked ~10–20 s. SwiftUI parent unresponsive, no input or redraw during this window.@State var player: AetherEngine→ main thread blocked ~1.5 s inHLSVideoEngine.deinit→stop()→waitForFinish.Why the host can't fix this alone
Because
AetherEngineis@MainActor, even wrappingawait player.load(...)inTask.detached { ... }re-hops back to the main actor before callingload. There's no escape hatch — the engine itself has to be the one that hops off-main for its blocking I/O. Same for the deinit: SwiftUI decides which thread releases the last reference, and for@Stateit's main.Suggested fixes
In order of design quality:
Make the segment producer cancellable without a join. Replace the manual thread +
waitForFinishwith aTask(or a detached thread observing an atomic cancel flag) that exits on its own and requires no synchronous join. Thenstop()anddeinitare near-instant by construction, and the dismiss freeze disappears regardless of which thread releases the engine.Make
AetherEngine.load(url:)actually async. Move the heavy I/O off the main actor internally — either by making the engine anactorinstead of@MainActor class, or by surgically dispatching the synchronous calls (probe.open,session.start,host.load) to a detached task withinload()and hopping back to main only for@Publishedstate mutations. The public signature stays the same; consumers don't change.Expose an explicit
async close(). Hosts that care about deterministic cleanupawait engine.close()from aTask;deinitbecomes a fast assertion / no-op (or the same async-dispatch fallback for hosts that forget to close).Happy to open a PR.