Skip to content

Synchronous blocking calls from Swift Concurrency contexts can starve the cooperative thread pool and freeze a host app (stackshot attached inline) #1100

Description

@FewzY

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

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

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