(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
- Create a custom
SFBInputSource subclass where readBytes:length:bytesRead:error: blocks until network data arrives (progressive HTTP streaming)
- Create an
AudioDecoder from this input source
- Call
audioPlayer.enqueue(decoder) from the main thread
- 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
-
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).
-
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:
- Document that
enqueue() may perform blocking I/O — Callers would know to dispatch to a background thread.
- Move
openReturningError: to the internal decoding dispatch queue — The format probing happens off the calling thread, and the caller gets back control immediately.
- Provide an async completion-based variant —
enqueue(_: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
(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 directlyI've shared mine with you via email.)Description
AudioPlayer::enqueueDecoder(AudioPlayer.mm) calls[decoder openReturningError:]synchronously on the calling thread. When using a customSFBInputSourcesubclass 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
@MainActorisolation,enqueue()is typically called from the main thread for gapless pre-scheduling. If the decoder'sopenReturningError: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
SFBInputSourcesubclass wherereadBytes:length:bytesRead:error:blocks until network data arrives (progressive HTTP streaming)AudioDecoderfrom this input sourceaudioPlayer.enqueue(decoder)from the main threadopenReturningError:while the decoder probes the stream formatImpact
I'm seeing 10+ watchdog kills per week from TestFlight users (via Sentry) from this issue before implementing workarounds. The blocking duration depends on:
mpg123_scan()whensupportsSeeking == YES, reading the entire file (~12MB for a 5-minute 320kbps MP3)NSConditionuntil data arrivesCurrent Workarounds
supportsSeekingreturnsNOuntil download completes — preventsmpg123_scan()full-file reads andtestInputSourceformat probing. Seeking re-enables once the download finishes. This reduced blocking from 10-24 seconds to <10ms (header-only probes from pre-buffered data).Converted our controller to a Swift actor — moves
AudioDecoder(inputSource:)init off the main thread. However,enqueue()itself is still called from@MainActor, so theopenReturningError:insideenqueue()still runs on the main thread.Suggestion
A few possible approaches:
enqueue()may perform blocking I/O — Callers would know to dispatch to a background thread.openReturningError:to the internal decoding dispatch queue — The format probing happens off the calling thread, and the caller gets back control immediately.enqueue(_:completion:)could perform the open asynchronously and call back when ready.Related: MPEG decoder full-file scan on
supportsSeeking == YESLower priority, but worth noting:
SFBMPEGDecoder.openReturningError:callsmpg123_scan()unconditionally whensupportsSeekingreturnsYES. 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
SFBInputSourcesubclass withNSCondition-based blocking reads for progressive HTTP streaming