Skip to content

fix(ios): skip AudioDeviceModule JS round-trips when no handler is registered#91

Merged
hiroshihorie merged 9 commits into
masterfrom
hiroshi/fix-adm-skip-js
Jun 18, 2026
Merged

fix(ios): skip AudioDeviceModule JS round-trips when no handler is registered#91
hiroshihorie merged 9 commits into
masterfrom
hiroshi/fix-adm-skip-js

Conversation

@hiroshihorie

@hiroshihorie hiroshihorie commented Jun 17, 2026

Copy link
Copy Markdown
Member

Summary

Builds on #90 (the bounded-wait deadlock fix) to remove the JS round-trip entirely for AudioDeviceModule engine hooks that have no registered handler, instead of only bounding the wait. For those hooks this closes the deadlock window rather than capping it at 2 seconds.

Stacked on #90. This PR targets the #90 branch, so it should land after #90 and the diff here is only the delta on top.

Background

#90 bounds each of the six RTCAudioDeviceModule delegate waits to 2 seconds so a stuck JS thread can no longer freeze the app forever. But a hook with no JS handler has nothing to wait for, so blocking it at all is pure risk with no benefit.

Change

Track per-hook handler registration with is-prefixed BOOL active flags. The JS layer pushes a flag to native whenever a handler is set or cleared. When a hook is inactive the observer returns 0 immediately without sending the event or waiting, so the unhandled hooks never enter the blocking path and cannot contribute to the deadlock.

In stock config this removes engineCreated, willStart, didStop and willRelease from the blocking path entirely, including willStart, which appeared in both reported freeze traces. The two hooks LiveKit registers by default (willEnable and didDisable) keep the bounded-wait and request-id safety net from #90.

The flags are written on the JS thread (handler registration) and read on the native audio thread (delegate callbacks), so they are declared atomic. The multi-field request-id state stays under @synchronized because it needs a true critical section.

Scope

Self-contained in this package. The active flags and request ids are internal to react-native-webrtc and never reach app handlers, so the public handler API is unchanged and no changes are needed in @livekit/react-native.

Testing

  • npm run lint (eslint and tsc) passes.
  • clang-format check passes.
  • iOS and Android native builds run in CI.

Refs #89, livekit/client-sdk-react-native#389.

@hiroshihorie hiroshihorie changed the title Hiroshi/fix adm skip js fix(ios): skip AudioDeviceModule JS round-trips when no handler is registered Jun 17, 2026
@hiroshihorie hiroshihorie marked this pull request as ready for review June 17, 2026 06:14
@hiroshihorie hiroshihorie requested a review from Copilot June 17, 2026 06:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR builds on the bounded-wait fix (#90) by eliminating JS round-trips for RTCAudioDeviceModule engine delegate hooks when no JS handler is registered, further reducing the deadlock surface area on iOS.

Changes:

  • Track per-hook handler “active” state in JS and push state changes to native.
  • Expose iOS native methods to update per-hook active flags on AudioDeviceModuleObserver.
  • Skip sending events / waiting on semaphores in AudioDeviceModuleObserver when the corresponding hook is inactive.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.

File Description
src/AudioDeviceModuleEvents.ts Sends “handler active” state changes to native when handlers are set/cleared.
ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m Adds exported native methods to set per-hook active flags on the observer.
ios/RCTWebRTC/AudioDeviceModuleObserver.m Adds isActive gating to skip JS round-trips for inactive hooks.
ios/RCTWebRTC/AudioDeviceModuleObserver.h Declares atomic per-hook active flags used by the observer.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/AudioDeviceModuleEvents.ts Outdated
Comment on lines +217 to +220
// Notify native about active state change to avoid unnecessary JS round trips
if (wasActive !== isActive && WebRTCModule) {
WebRTCModule.audioDeviceModuleSetEngineCreatedActive(isActive);
}
Comment thread src/AudioDeviceModuleEvents.ts Outdated
Comment on lines +233 to +235
if (wasActive !== isActive && WebRTCModule) {
WebRTCModule.audioDeviceModuleSetWillEnableEngineActive(isActive);
}
Comment thread src/AudioDeviceModuleEvents.ts Outdated
Comment on lines +248 to +250
if (wasActive !== isActive && WebRTCModule) {
WebRTCModule.audioDeviceModuleSetWillStartEngineActive(isActive);
}
Comment thread src/AudioDeviceModuleEvents.ts Outdated
Comment on lines +263 to +265
if (wasActive !== isActive && WebRTCModule) {
WebRTCModule.audioDeviceModuleSetDidStopEngineActive(isActive);
}
Comment thread src/AudioDeviceModuleEvents.ts Outdated
Comment on lines +278 to +280
if (wasActive !== isActive && WebRTCModule) {
WebRTCModule.audioDeviceModuleSetDidDisableEngineActive(isActive);
}
Comment thread src/AudioDeviceModuleEvents.ts Outdated
Comment on lines +293 to +295
if (wasActive !== isActive && WebRTCModule) {
WebRTCModule.audioDeviceModuleSetWillReleaseEngineActive(isActive);
}
Comment on lines +99 to +104
if (!isActive) {
// No handler registered, proceed immediately without JS round trip.
// This avoids the deadlock window entirely.
os_log(ADMObserverLog(), "Skipping JS round-trip for %{public}@ (no handler registered)", eventName);
return 0;
}
@hiroshihorie hiroshihorie force-pushed the hiroshi/fix-adm-skip-js branch from cefcd2c to 777d706 Compare June 17, 2026 07:44
Base automatically changed from hiroshi/fix-adm-indefinite-lock to master June 17, 2026 18:49
Track per-hook handler registration with is-prefixed BOOL active flags that
JS pushes to native whenever a handler is set or cleared. When a hook has no
registered handler the observer returns 0 immediately without a JS round
trip, so the unhandled hooks (engineCreated, willStart, didStop, willRelease
in stock config) never enter the blocking wait and cannot contribute to the
deadlock. Hooks with a registered handler (willEnable, didDisable in stock
config) keep the bounded-wait and request-id safety net from the previous
change.
The isXxxActive flags are written on the JS thread when a handler is
registered or cleared and read on the native audio thread in the delegate
callbacks. Declaring them atomic routes those accesses through synchronized
accessors, removing the data race (and the ThreadSanitizer warning) while
keeping the lighter accessor cost that suits independent single flags. The
multi-field requestId state stays under @synchronized(self) because it needs
a true critical section.
The native audioDeviceModuleSetXxxActive setters are iOS/macOS only, but the
per-handler push was guarded only by WebRTCModule presence. On Android
WebRTCModule is truthy while the method is undefined, so the first setXxxHandler
call threw a TypeError. Route all six setters through one pushHandlerActive
helper gated by Platform.OS !== 'android' (matching setupListeners) so the
Android guard cannot drift across copies. Flagged by Copilot and the re-review.
…ile on setup)

The active flags defaulted to NO and were pushed only on a handler set/clear
transition, with nothing reconciling native state to JS state. A delegate
callback that fired before reconciliation (e.g. an observer recreated while the
JS handler singleton persisted) would take the skip path and silently drop the
handler's veto, letting the engine proceed when the app wanted it aborted.

Default every native flag to YES so the worst case is one harmless bounded round
trip absorbed by the timeout, not a dropped veto, and push the current handler
state for all six hooks in setupListeners() so a fresh observer is reconciled.
The skip path logged via os_log at default level on the realtime audio worker
thread for every inactive hook. With all hooks defaulting to active and reconciled
to NO in the common no-handler case, this fired on the latency-sensitive path for
every engine lifecycle event. Use os_log_debug so it is not persisted in
production logs while staying available when debug logging is enabled. Flagged by
Copilot and the re-review.
addListener appends subscriptions without de-duping, so a second registerGlobals()
left two live listeners per blocking event and invoked each app handler twice per
round. Guard setupListeners with a listenersSetUp flag so repeat calls are no-ops.
…ost veto

The active-flag optimization skips the JS round trip when a hook is
inactive. The setters published the handler to the JS field before
pushing the native active flag, so a delegate callback racing
registration could read the flag as still inactive and skip a handler
that was already installed, silently dropping its veto.

Push the flag active before publishing the handler on activation (and
clear the handler before pushing inactive on deactivation) via a shared
applyHandlerActive helper. The worst case becomes an extra JS round trip
whose listener resolves 0, never a skipped handler.
The idempotency guard returned before the active-flag reconcile, so a
second setupListeners() (e.g. a recreated native observer behind a
surviving JS singleton) would never re-sync the flags, leaving the
native defaults (all active) unreconciled.

Guard only the addListener registration; extract the reconcile into
reconcileActiveFlags() and run it on every call so the native observer
always matches the current handler state.
The entry "waiting for JS response" log was already gated on isActive,
but the exit "JS returned" log was not, so the skip path emitted a
misleading "JS returned: 0" alongside the internal "Skipping JS
round-trip" debug line. Gate the exit log (and willEnable's session
category diagnostic) on isActive too, so the per-callback round-trip
narration appears only when a JS round trip actually happens.
@hiroshihorie hiroshihorie force-pushed the hiroshi/fix-adm-skip-js branch from 04ca4c6 to 4acbcdc Compare June 17, 2026 18:53
@hiroshihorie hiroshihorie merged commit a95d57d into master Jun 18, 2026
9 checks passed
@hiroshihorie hiroshihorie deleted the hiroshi/fix-adm-skip-js branch June 18, 2026 07:20
@davidliu davidliu mentioned this pull request Jun 18, 2026
davidliu added a commit that referenced this pull request Jun 18, 2026
a95d57d fix(ios): skip AudioDeviceModule JS round-trips when no handler
is registered (#91) ( Hiroshi Horie 2026-06-18 16:20:29 +0900)
c751bd6 ios: bump WebRTC-SDK to 144.7559.10 (#92) ( Hiroshi Horie
2026-06-18 16:11:31 +0900)
196cbb3 fix(ios): bound AudioDeviceModuleObserver JS waits to break the
deadlock (#90) ( Hiroshi Horie 2026-06-18 03:49:07 +0900)
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.

3 participants