SDK version: 2.16.0 (also inspected on main)
Platform: iPadOS 18.7.9, iPad 7 (iPad7,11, A10 - 2-wide Swift cooperative pool)
Xcode / Swift: Xcode 26, Swift 6.2
Summary
The SDK executes synchronous blocking hops - webrtc::Thread::BlockingCall (e.g. via
AudioManager.startLocalRecording, Room.cleanUpRTC, DataChannelPair.reset) and
DispatchQueue.liveKitWebRTC.sync (RTC.swift, LocalParticipant.swift, VideoCapturer.swift,
RTCConfiguration.swift, ...) - on whatever thread runs its Swift async code. When the caller is
ordinary Swift Concurrency (the default task executor), that thread is a cooperative-pool
thread. The pool is as narrow as the core count and does not grow when a thread blocks, so a
blocked SDK call permanently removes a pool seat from the entire process. On a 2-core device,
two concurrent SDK blocks freeze every Swift-concurrency task in the host app.
This is a violation of the Swift Concurrency forward-progress contract (cooperative threads
must never be blocked), and unlike a slow call it is invisible to the app: no crash, no hang
report (the main thread stays responsive), just silent starvation of every actor and task.
Field incident (real device, production-shaped app)
LocalParticipant.setMicrophone(enabled: true) wedged inside
AudioManager.startLocalRecording (the device audio stack was stuck; old hardware does this).
Our app-side deadline cancelled the awaiting task and called Room.disconnect() for cleanup.
Result, from the process stackshot taken during the incident (watchdog crash report
0x8BADF00D, "failed to terminate gracefully", 0% app CPU):
thread 19 LocalAudioTrack.startCapture -> AudioManager.startLocalRecording
-> webrtc::Thread::BlockingCallImpl -> __psynch_cvwait [blocked forever]
(running inside SerialRunnerActor.run - a cooperative-pool thread)
thread 17 Room.cleanUpRTC -> DataChannelPair.reset
-> webrtc::Thread::BlockingCallImpl -> __psynch_cvwait [blocked forever]
(a second cooperative-pool thread)
thread 16 webrtc signaling_thread parked in BlockingCallImpl [cross-blocked]
thread 15 AURemoteIO::~AURemoteIO -> _dispatch_sync_f_slow [the audio-stack root]
Threads 17 + 19 were the ENTIRE cooperative pool on this A10 iPad. Every other Swift
Concurrency construct in the app - actors emitting network heartbeats, timers, AsyncStream
consumers - stopped silently for 10+ minutes until the process was killed. The main thread
(libdispatch) kept dispatching UI events the whole time, so the app looked alive.
Note the cascade: cancellation of the wedged setMicrophone task cannot help (the thread is
blocked in __psynch_cvwait, not suspended at an await), and the natural remediation -
Room.disconnect() - blocks a SECOND pool thread on the same wedge.
Suggested directions
- Route
webrtc::Thread::BlockingCall bridges and DispatchQueue.liveKitWebRTC.sync hops
through a dedicated SDK-owned thread/executor rather than executing them on the calling
thread, or replace the sync bridges with async continuations.
- At minimum: document that SDK entry points may block the calling thread indefinitely, so
integrators can fence them off the cooperative pool themselves.
Workaround (for other integrators)
We contained it app-side by running every SDK call under withTaskExecutorPreference
(SE-0417) on a concurrent-DispatchQueue-backed TaskExecutor: SDK code then blocks disposable
dispatch workers (libdispatch grows its pool when workers block) instead of cooperative-pool
seats. Residual gap: SDK-internal Task.detached escapes the preference.
SDK version: 2.16.0 (also inspected on main)
Platform: iPadOS 18.7.9, iPad 7 (iPad7,11, A10 - 2-wide Swift cooperative pool)
Xcode / Swift: Xcode 26, Swift 6.2
Summary
The SDK executes synchronous blocking hops -
webrtc::Thread::BlockingCall(e.g. viaAudioManager.startLocalRecording,Room.cleanUpRTC,DataChannelPair.reset) andDispatchQueue.liveKitWebRTC.sync(RTC.swift, LocalParticipant.swift, VideoCapturer.swift,RTCConfiguration.swift, ...) - on whatever thread runs its Swift async code. When the caller is
ordinary Swift Concurrency (the default task executor), that thread is a cooperative-pool
thread. The pool is as narrow as the core count and does not grow when a thread blocks, so a
blocked SDK call permanently removes a pool seat from the entire process. On a 2-core device,
two concurrent SDK blocks freeze every Swift-concurrency task in the host app.
This is a violation of the Swift Concurrency forward-progress contract (cooperative threads
must never be blocked), and unlike a slow call it is invisible to the app: no crash, no hang
report (the main thread stays responsive), just silent starvation of every actor and task.
Field incident (real device, production-shaped app)
LocalParticipant.setMicrophone(enabled: true)wedged insideAudioManager.startLocalRecording(the device audio stack was stuck; old hardware does this).Our app-side deadline cancelled the awaiting task and called
Room.disconnect()for cleanup.Result, from the process stackshot taken during the incident (watchdog crash report
0x8BADF00D, "failed to terminate gracefully", 0% app CPU):Threads 17 + 19 were the ENTIRE cooperative pool on this A10 iPad. Every other Swift
Concurrency construct in the app - actors emitting network heartbeats, timers,
AsyncStreamconsumers - stopped silently for 10+ minutes until the process was killed. The main thread
(libdispatch) kept dispatching UI events the whole time, so the app looked alive.
Note the cascade: cancellation of the wedged
setMicrophonetask cannot help (the thread isblocked in
__psynch_cvwait, not suspended at an await), and the natural remediation -Room.disconnect()- blocks a SECOND pool thread on the same wedge.Suggested directions
webrtc::Thread::BlockingCallbridges andDispatchQueue.liveKitWebRTC.synchopsthrough a dedicated SDK-owned thread/executor rather than executing them on the calling
thread, or replace the sync bridges with async continuations.
integrators can fence them off the cooperative pool themselves.
Workaround (for other integrators)
We contained it app-side by running every SDK call under
withTaskExecutorPreference(SE-0417) on a concurrent-DispatchQueue-backed
TaskExecutor: SDK code then blocks disposabledispatch workers (libdispatch grows its pool when workers block) instead of cooperative-pool
seats. Residual gap: SDK-internal
Task.detachedescapes the preference.