Skip to content

AudioPlayer.enqueue(_:) calls openReturningError: synchronously on calling thread #892

@CharlesWiltgen

Description

@CharlesWiltgen

(Hey @sbooth, I used AI to help summarize this issue, but I wanted to let you know that I'm personally submitting this, and that I believe that this is a real issue that you'd want to know about. I understand that the "Create a custom SFBInputSource" thing may be asking too much 🙃 and I'm happy to share mine with you directly I've shared mine with you via email.)

Description

AudioPlayer::enqueueDecoder (AudioPlayer.mm) calls [decoder openReturningError:] synchronously on the calling thread. When using a custom SFBInputSource subclass backed by network I/O (e.g., progressive HTTP streaming with blocking reads), this blocks the calling thread until data arrives from the network.

In a SwiftUI app with @MainActor isolation, enqueue() is typically called from the main thread for gapless pre-scheduling. If the decoder's openReturningError: performs any I/O through the input source (format probing, header reads), the main thread blocks. iOS kills the app after ~2 seconds of main thread blockage.

Reproduction

  1. Create a custom SFBInputSource subclass where readBytes:length:bytesRead:error: blocks until network data arrives (progressive HTTP streaming)
  2. Create an AudioDecoder from this input source
  3. Call audioPlayer.enqueue(decoder) from the main thread
  4. The main thread blocks inside openReturningError: while the decoder probes the stream format

Impact

I'm seeing 10+ watchdog kills per week from TestFlight users (via Sentry) from this issue before implementing workarounds. The blocking duration depends on:

  • Codec: MPEG decoder triggers mpg123_scan() when supportsSeeking == YES, reading the entire file (~12MB for a 5-minute 320kbps MP3)
  • Network speed: Each read through the input source blocks on an NSCondition until data arrives

Current Workarounds

  1. supportsSeeking returns NO until download completes — prevents mpg123_scan() full-file reads and testInputSource format probing. Seeking re-enables once the download finishes. This reduced blocking from 10-24 seconds to <10ms (header-only probes from pre-buffered data).

  2. Converted our controller to a Swift actor — moves AudioDecoder(inputSource:) init off the main thread. However, enqueue() itself is still called from @MainActor, so the openReturningError: inside enqueue() still runs on the main thread.

Suggestion

A few possible approaches:

  1. Document that enqueue() may perform blocking I/O — Callers would know to dispatch to a background thread.
  2. Move openReturningError: to the internal decoding dispatch queue — The format probing happens off the calling thread, and the caller gets back control immediately.
  3. Provide an async completion-based variantenqueue(_:completion:) could perform the open asynchronously and call back when ready.

Related: MPEG decoder full-file scan on supportsSeeking == YES

Lower priority, but worth noting: SFBMPEGDecoder.openReturningError: calls mpg123_scan() unconditionally when supportsSeeking returns YES. For progressive streaming sources, "supports seeking" means "can restart the HTTP download at a byte offset" — not "has fast random access to the entire file." The full-file scan reads the entire stream synchronously, which is catastrophic for network-backed sources.

Could the scan be deferred until the first actual seek request? Or could a flag opt out of eager scanning?

Environment

  • SFBAudioEngine (latest, via SPM)
  • iOS 26, Swift 6 strict concurrency
  • Custom SFBInputSource subclass with NSCondition-based blocking reads for progressive HTTP streaming

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions