Summary
AVPlayerWrapper.state's setter invokes delegate?.AVWrapper(didChangeState:) inside the stateQueue .barrier block. Since stateQueue is a .concurrent queue and the state getter reads through stateQueue.sync, any concurrent state read — including the ones triggered by AVPlayerItem KVO on the main thread — blocks until the barrier (and the arbitrary delegate work it runs) completes. Under buffering load this stalls the main thread for seconds → an iOS App Hang / watchdog termination.
Observed in SwiftAudioEx 1.1.0 (via react-native-track-player 4.1.2), iOS 26.5.
Affected code
Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift:
fileprivate let stateQueue = DispatchQueue(
label: "AVPlayerWrapper.stateQueue",
attributes: .concurrent
)
var state: AVPlayerWrapperState {
get {
var state: AVPlayerWrapperState!
stateQueue.sync { state = _state }
return state
}
set {
stateQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
let currentState = self._state
if (currentState != newValue) {
self._state = newValue
self.delegate?.AVWrapper(didChangeState: newValue) // ← runs INSIDE the barrier
}
}
}
}
AVWrapper(didChangeState:) (in AudioPlayer) is not cheap — it calls updateNowPlayingPlaybackValues() (MPNowPlayingInfoCenter) and event.stateChange.emit(...). All of that runs while the concurrent stateQueue is held exclusively by the barrier.
Why it hangs
AVPlayerItemObserver / AVPlayerObserver KVO callbacks (duration, loadedTimeRanges, isPlaybackLikelyToKeepUp) are delivered on the main thread, and their delegate chain reads wrapper.state → stateQueue.sync. A concurrent-queue .barrier block excludes all concurrent .sync reads, so while the setter's barrier runs the delegate work, the main thread blocks in stateQueue.sync. When state changes are frequent — e.g. buffering churn over a slow network connection, which is also exactly when loadedTimeRanges KVO fires hardest — the main thread stalls for ≥2 s.
Real production App Hang stack trace (iOS 26.5), main thread:
__ulock_wait
_dlock_wait
__DISPATCH_WAIT_FOR_QUEUE__
_dispatch_sync_f_slow
<SwiftAudioEx KVO observer frames>
NSKeyValueNotifyObserver
NSKeyValueDidChange
-[AVPlayerItem didChangeValueForKey:]
__avplayeritem_fpItemNotificationCallback_block_invoke
_dispatch_main_queue_drain
Suggested fix
The barrier block should only mutate _state. Invoke the delegate after the barrier is released:
set {
stateQueue.async(flags: .barrier) { [weak self] in
guard let self else { return }
guard self._state != newValue else { return }
self._state = newValue
DispatchQueue.main.async {
self.delegate?.AVWrapper(didChangeState: newValue)
}
}
}
General principle: never invoke an arbitrary delegate while holding a lock that another thread — here the main thread, via AVPlayerItem KVO — can be blocked waiting on.
Happy to open a PR.
Summary
AVPlayerWrapper.state's setter invokesdelegate?.AVWrapper(didChangeState:)inside thestateQueue.barrierblock. SincestateQueueis a.concurrentqueue and thestategetter reads throughstateQueue.sync, any concurrentstateread — including the ones triggered byAVPlayerItemKVO on the main thread — blocks until the barrier (and the arbitrary delegate work it runs) completes. Under buffering load this stalls the main thread for seconds → an iOS App Hang / watchdog termination.Observed in SwiftAudioEx 1.1.0 (via react-native-track-player 4.1.2), iOS 26.5.
Affected code
Sources/SwiftAudioEx/AVPlayerWrapper/AVPlayerWrapper.swift:AVWrapper(didChangeState:)(inAudioPlayer) is not cheap — it callsupdateNowPlayingPlaybackValues()(MPNowPlayingInfoCenter) andevent.stateChange.emit(...). All of that runs while the concurrentstateQueueis held exclusively by the barrier.Why it hangs
AVPlayerItemObserver/AVPlayerObserverKVO callbacks (duration,loadedTimeRanges,isPlaybackLikelyToKeepUp) are delivered on the main thread, and their delegate chain readswrapper.state→stateQueue.sync. A concurrent-queue.barrierblock excludes all concurrent.syncreads, so while the setter's barrier runs the delegate work, the main thread blocks instateQueue.sync. When state changes are frequent — e.g. buffering churn over a slow network connection, which is also exactly whenloadedTimeRangesKVO fires hardest — the main thread stalls for ≥2 s.Real production App Hang stack trace (iOS 26.5), main thread:
Suggested fix
The barrier block should only mutate
_state. Invoke the delegate after the barrier is released:General principle: never invoke an arbitrary delegate while holding a lock that another thread — here the main thread, via
AVPlayerItemKVO — can be blocked waiting on.Happy to open a PR.