Skip to content

AVPlayerWrapper.state setter calls the delegate inside the stateQueue barrier (main-thread App Hang) #104

Description

@xtamtamx

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions