Skip to content

AetherEngine.load() and HLSVideoEngine.deinit block the main thread, freezing the host UI on open and dismiss #10

@Delarkz

Description

@Delarkz

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:

  1. await player.load(url: ...) → main thread blocked ~10–20 s. SwiftUI parent unresponsive, no input or redraw during this window.
  2. SwiftUI parent removes the view holding @State var player: AetherEngine → main thread blocked ~1.5 s in HLSVideoEngine.deinitstop()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:

  1. 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.

  2. 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.

  3. 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.

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