Skip to content

Make AdmProxy worker-thread-affine#1212

Open
hiroshihorie wants to merge 7 commits into
mainfrom
hiroshi/adm-proxy-worker-thread
Open

Make AdmProxy worker-thread-affine#1212
hiroshihorie wants to merge 7 commits into
mainfrom
hiroshi/adm-proxy-worker-thread

Conversation

@hiroshihorie

@hiroshihorie hiroshihorie commented Jul 2, 2026

Copy link
Copy Markdown
Member

What

Rewrites AdmProxy (webrtc-sys) so that every touch of the platform ADM, the synthetic ADM, and all proxy state happens on the WebRTC worker thread. Every public method marshals once at the boundary (RunOnWorker, a BlockingCall that runs inline when the caller is already the worker), all mutable state is worker-owned and annotated RTC_GUARDED_BY(worker_thread_), and both mutexes are removed. Mode transitions are now plain sequential worker code.

Why

Platform ADM implementations (Windows CoreAudio2, iOS, and the upcoming Apple AudioEngine device) bind a sequence checker to their construction thread and RTC_DCHECK_RUN_ON every control method. Previously the proxy called the platform ADM directly on whatever thread the Rust FFI caller was on, guarded only by a mutex — violating that contract and racing with WebRTC's own worker-thread calls. This is a prerequisite for switching the Apple platform ADM to the AudioEngine device (follow-up branch hiroshi/audioengine-platformaudio, depends on webrtc-sdk/webrtc#260).

Making the proxy thread-affine removes by construction the races a mutex couldn't prevent: stale mode snapshots interleaving transitions, the synthetic ADM being driven from caller threads, and Terminate() racing mode switches.

This mirrors upstream WebRTC's own pattern: RTCPeerConnectionFactory constructs the ADM inside _workerThread->BlockingCall(...), and WebRtcVoiceEngine/AudioState guard every ADM call with worker-thread sequence checkers.

Also fixed along the way

  • Android: lazily created ADM never received the audio transport. WebRTC registers its AudioTransport at factory init, before the Android ADM exists; EnsurePlatformAdmCreated now passes it (and any pre-acquire device selection) to the fresh ADM. Previously platform recording delivered no audio to the pipeline.
  • Shutdown race: AudioDeviceController now holds a shared_ptr<RtcRuntime> (same pattern as AudioTrack/VideoTrack) so the worker thread outlives every proxy reference reachable from Rust.
  • Removed dead state: write-only playout_initialized_/recording_initialized_ flags and never-read device GUID tracking.
  • docs/ADM_PROXY_DESIGN.md updated to match the actual lifecycle (eager idle ADM, release does not terminate) and to document the threading model.

Threading contract

  • The proxy must be constructed on the worker thread (the factory already does this via BlockingCall); the eagerly created platform ADM binds its sequence checker there.
  • Destruction may happen on any thread; the destructor hops to the worker for teardown.
  • Calls from WebRTC internals (AudioState, voice engine) are already on the worker and run inline — no added hops on those paths.
  • Rust FFI calls (control plane, low frequency) each pay one blocking hop to the worker, replacing the previous mutex acquisition.
  • Realtime audio callbacks (AudioTransport data) are NOT marshaled — they flow directly through the sub-ADMs' own I/O threads, matching the platform ADMs' design (separate io/render thread checkers).

Behavior parity

Apart from the marshaling and the fixes listed above, behavior is unchanged: all no-platform-ADM defaults, state-update ordering, and gate semantics match the previous implementation method-for-method.

Testing

  • cargo check -p webrtc-sys and cargo build -p livekit pass.
  • Reviewed method-by-method against the previous implementation for semantic drift, plus lifetime/deadlock analysis of the BlockingCall boundary (factory teardown ordering, dtor-on-any-thread, SyntheticAudioDevice task queue). Threading contract verified against the ADM implementations in webrtc-sdk (Windows/iOS/AudioEngine sequence checkers, voice engine and AudioState worker guards).
  • New runtime exerciser (included in this PR): cargo run -p livekit --example platform_audio — runs offline, no LiveKit server. Passing on macOS (arm64):
    • lifecycle, device enumeration/selection, hot-swap while recording, AEC/AGC/NS reconfiguration
    • 16 threads x 50 iterations of concurrent enumeration/getters/recording churn/acquire-release churn: 0 errors, no deadlock
    • 5 asserted full runtime teardown/reacquire cycles (factory + AdmProxy destroyed and rebuilt, half dropped mid-recording)
  • Not covered offline: platform playout under a live room (playing_ = true switch branch) and DCHECK-level thread validation (release prebuilt compiles RTC_DCHECK out; needs a debug webrtc via LK_CUSTOM_WEBRTC).

Platform ADM implementations (Windows CoreAudio2, iOS, the upcoming
Apple AudioEngine device) bind a sequence checker to their construction
thread and expect every control call on it. Previously AdmProxy called
the platform ADM directly on whatever thread the caller was on, guarded
only by a mutex, which violates that contract and races with WebRTC's
own worker-thread calls.

AdmProxy now owns all of its state on the worker thread. Every public
method marshals once at the boundary via RunOnWorker (inline when the
caller is already the worker, which covers all WebRTC-internal calls)
and mode transitions execute as plain sequential worker code. This
removes the mutex entirely along with the races it couldn't prevent:
stale mode snapshots interleaving transitions, and the synthetic ADM
being driven from caller threads.

The proxy must be constructed on the worker thread (the factory already
does this) so the eagerly created platform ADM binds to it. Destruction
may happen on any thread and hops to the worker for teardown.
On Android the platform ADM is created lazily on first acquire, after
WebRTC has already registered its audio transport with the proxy and
after the app may have selected devices. Pass the stored transport and
selection along to the newly created ADM. Previously the transport was
never registered, so platform recording delivered no audio to the
pipeline.
Rust fetches the controller per call, so a controller copy can briefly
be the last reference to the AdmProxy on a Rust thread during factory
teardown. The proxy marshals onto the runtime's worker thread, so the
runtime must outlive every reference to the proxy that Rust can reach.
Follows the existing pattern used by AudioTrack and VideoTrack.
The doc described a lazy create/terminate-at-ref-0 lifecycle and an
in-proxy stub playout task that no longer exist. Document the actual
design: eager platform ADM creation kept until proxy destruction,
SyntheticAudioDevice as a separate sub ADM, and the worker-thread-affine
threading model.
The selected device GUIDs were captured on every SetPlayoutDevice and
SetRecordingDevice call but never read anywhere. They were meant for
restoring selection across ADM re-creation, which no longer happens,
and the one re-creation path left (Android lazy creation) re-applies
by index. Keep only the indices and document the index 0 caveat.
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Changeset

The following package versions will be affected by this PR:

Package Bump
livekit patch
webrtc-sys patch

Demonstrates the PlatformAudio API and doubles as an offline regression
check for the worker-thread-affine AdmProxy: lifecycle and full runtime
teardown/reacquire cycles, device enumeration/selection/hot-swap,
recording on real hardware, audio processing configuration, and heavy
concurrent access from many threads. Runs without a LiveKit server,
needs audio hardware and mic permission so it lives as an example
rather than a CI test.
@hiroshihorie hiroshihorie marked this pull request as ready for review July 2, 2026 15:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant