Skip to content

iOS: audio-engine delegate hooks block SDP calls for 2 s each when the ADM listeners were never registered (6 s on setLocalDescription(answer), reproducible in the simulator) #100

Description

@lau-sam

Related to #89, possibly a follow-up rather than a new issue

This is the same component as #89 ("AudioDeviceModuleObserver's DISPATCH_TIME_FOREVER waits can deadlock the JS thread"), which is still open pending a full fix. The 2 s bound we measure here is the temporary fix from #90 / #91 that replaced the unbounded waits.

The symptom is different: no deadlock, the JS thread stays responsive, and the cost is paid even when the app has registered nothing. But it comes from the same waits, and if maintainers would rather have this as a comment on #89, please close it as a duplicate; we filed separately only because the trigger and the fix surface differ.

We have not reproduced the room.connect() stall reported in the 144.1.1 follow-up comment on #89, and we are not claiming ours is the same thing.

Summary

AudioDeviceModuleObserver blocks the calling native thread on a semaphore while it waits for JS to answer each audio-engine delegate hook, with a 2 second timeout. Three of those hooks fire in a row when the audio engine starts, which is exactly when the second session description is applied. An app with no JS listener answering them pays 3 × 2 s = 6 s inside setLocalDescription(answer), plus roughly 22 s more across the engine teardown.

An app can be in that situation without doing anything unusual: the six hooks default to active natively, and the JS side only pushes the real flags from setupListeners(), whose sole caller is registerGlobals(). An app that imports RTCPeerConnection directly and never calls registerGlobals() (documented as installing browser globals, not as mandatory) has no listener at all to respond, so the timeout is not a risk but a certainty.

The JS thread is not blocked during any of this, since it is a native thread waiting on a semaphore. From the application's point of view the promise simply takes seconds to settle, with nothing to explain it.

Reproducible in the iOS simulator, in a single app, with two loopback peer connections: no network, no signalling server, no second device. Calling audioDeviceModuleEvents.setupListeners(), or registerGlobals() which calls it as its last line, removes the delay entirely (6.15 s to 0.10 s). That confirms the diagnosis, but it makes an undocumented call a silent performance requirement; see below.

Minimal reproduction

React Native + @livekit/react-native-webrtc 144.1.2, nothing else. Two RTCPeerConnection in the same app, iceServers: [], candidates copied across, one real audio track each:

const CONFIG = { iceServers: [], iceTransportPolicy: 'all', iceCandidatePoolSize: 1 };
const timed = async (name, fn) => {
  const t = Date.now(); await fn();
  console.log(`${name}: ${((Date.now() - t) / 1000).toFixed(2)} s`);
};

const pc1 = new RTCPeerConnection(CONFIG);
const pc2 = new RTCPeerConnection(CONFIG);
pc1.addEventListener('icecandidate', e => { if (e.candidate) pc2.addIceCandidate(e.candidate); });
pc2.addEventListener('icecandidate', e => { if (e.candidate) pc1.addIceCandidate(e.candidate); });

const s1 = await mediaDevices.getUserMedia({ audio: true, video: false });
s1.getTracks().forEach(t => pc1.addTrack(t, s1));
const s2 = await mediaDevices.getUserMedia({ audio: true, video: false });
s2.getTracks().forEach(t => pc2.addTrack(t, s2));

const offer = await pc1.createOffer({});
await timed('pc1.setLocalDescription(offer)',   () => pc1.setLocalDescription(offer));
await timed('pc2.setRemoteDescription(offer)',  () => pc2.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp: offer.sdp })));
const answer = await pc2.createAnswer();
await timed('pc2.setLocalDescription(answer)',  () => pc2.setLocalDescription(answer));
await timed('pc1.setRemoteDescription(answer)', () => pc1.setRemoteDescription(new RTCSessionDescription({ type: 'answer', sdp: answer.sdp })));

Output (iPhone 17 Pro simulator, iOS 26.4.1):

pc1.setLocalDescription(offer)   [1st desc]: 0.00 s
pc2.setRemoteDescription(offer)  [1st desc]: 0.01 s
pc2.setLocalDescription(answer)  [2nd desc]: 6.15 s   <--
pc1.setRemoteDescription(answer) [2nd desc]: 0.01 s

And on the same run, log stream --predicate 'subsystem == "com.livekit.react-native-webrtc"' --info:

00:21:31.887  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineCreated; returning default 0
00:21:33.888  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineWillEnable; returning default 0
00:21:36.019  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineWillStart; returning default 0

From 00:21:29.887 to 00:21:36.019, that is 6.13 s of expired semaphores, against 6.15 s measured in JS around setLocalDescription. The delay appears to be entirely accounted for.

pc1.setRemoteDescription(answer) is fast only in this loopback setup, because the audio engine already started during pc2's setLocalDescription in the same process. On two separate devices each side pays its own engine startup, which matches what we measure in the field (see below).

The engine teardown pays as much again. Continuing the same log:

DidStop, WillEnable, DidStop, WillEnable, DidStop, WillEnable, DidStop, WillEnable, DidStop, DidDisable, WillRelease

Eleven more timeouts, roughly 22 s. The full audio-engine lifecycle costs about 26 s of expired semaphores in this run. The DidStop / WillEnable alternation repeating five times looks odd to us; we are reporting it, not explaining it.

Skipping JS round-trip for … (no handler registered) does not appear once in these logs. We looked for it explicitly; the reason is below.

The simulator runs iOS 26.4.1 while our two physical devices run 26.6.1, so this does not look like a simulator artefact: it shows up on two iOS versions and on both simulated and real hardware.

What the code does

// ios/RCTWebRTC/AudioDeviceModuleObserver.m:21
static const int64_t kJSResponseTimeoutSeconds = 2;

// ios/RCTWebRTC/AudioDeviceModuleObserver.m:143-155
dispatch_time_t deadline = dispatch_time(DISPATCH_TIME_NOW, kJSResponseTimeoutSeconds * NSEC_PER_SEC);
if (dispatch_semaphore_wait(semaphore, deadline) != 0) {
    …
    os_log_error(ADMObserverLog(), "Timed out after %llds waiting for JS to respond to %{public}@; returning default 0", …);
    return 0;
}

Three hooks fire in sequence on engine startup, each with its own 2 s wait:

  • didCreateEngine, AudioDeviceModuleObserver.m:175-194
  • willEnableEngine, :196-235
  • willStartEngine, :237-264

There is a fast path meant to avoid the round trip entirely when the app has no handler (:117-127, the Skipping JS round-trip log). It is gated on per-hook isActive flags, which default to active natively:

// ios/RCTWebRTC/AudioDeviceModuleObserver.m:88-95
// Default every hook to active so a delegate callback that fires before JS
// has reconciled its handler state … still does the bounded round trip …
// JS reconciles these to the real handler state in setupListeners().
_isEngineCreatedActive = YES;
_isWillEnableEngineActive = YES;
_isWillStartEngineActive = YES;
…

The JS reconciliation does not appear to happen on a normal startup. reconcileActiveFlags() pushes the real handler state, and setupListeners() only calls it when it has already been set up:

// src/AudioDeviceModuleEvents.ts:64-76
public setupListeners() {
    if (Platform.OS !== 'android' && WebRTCModule) {
        if (this.listenersSetUp) {
            this.reconcileActiveFlags();
            return;                      // only on a second call
        }
        this.listenersSetUp = true;
        // … addListener(…) registrations, no flag pushed …

So the first (and usually only) call registers the listeners without pushing any flag, and every hook stays active natively. With listeners registered that is merely wasteful: each engine transition takes a JS round trip for a handler the app does not have.

Without listeners it costs the full 2 s per hook. setupListeners() has one caller in the package:

// src/index.ts:136-137
// Ensure audioDeviceModuleEvents is initialized and event listeners are registered
audioDeviceModuleEvents.setupListeners();

which is inside registerGlobals(). An app that constructs RTCPeerConnection by direct import and never calls registerGlobals() has no JS listener for audioDeviceModuleEngineCreated, …WillEnable or …WillStart; nothing can signal those semaphores, and every engine transition burns its full timeout. That is our case: no occurrence of registerGlobals in our codebase, no ADM handler registered, and the repro above is the result.

Field impact

Before finding this we chased it as a call-setup latency bug. On a 1:1 audio call between two real iPhones on the same Wi-Fi, 17 to 20 s elapsed between answering and the first audible audio, with the delay entirely inside the second session description on each side:

Call Duration JS heartbeat during the wait
callee: setLocalDescription(answer) 4.0 to 4.1 s 88 %
caller: setRemoteDescription(answer) 6.1 to 12.5 s 90 to 98 %

The heartbeat is a setInterval tick counter compared against ticks expected for elapsed wall-clock time. 88 to 98 % means the JS thread ran normally throughout, which is consistent with a native thread waiting on a semaphore.

Ruled out by measurement, in case they come up: TURN (identical with TURN removed entirely, and relay-to-relay heard audio 0.8 s after host-to-host), ICE gathering (complete at 0.6 s and 4.2 s, well before the wait ends), mDNS (no .local remote candidate), waiting for connectivity (13 to 14 remote candidates added before the second description, no gain), CallKit and getUserMedia (both at about 0.0 s), and the iOS audio session state (identical before and after each call).

Workaround, and why it may not be the right answer

Running the ADM listener setup before constructing the peer connections removes the delay. Same harness, same simulator, the initialisation line is the only variable:

Initialisation pc2.setLocalDescription(answer)
none 6.15 s
registerGlobals() 0.09 s
audioDeviceModuleEvents.setupListeners() alone 0.10 s

audioDeviceModuleEvents is publicly exported (src/index.ts:104) and typed (lib/typescript/AudioDeviceModuleEvents.d.ts:105, with setupListeners(): void at :32), so this is not reaching into internals.

Timings and log below are from the registerGlobals() run:

registerGlobals() called
pc1.setLocalDescription(offer)   [1st desc]: 0.00 s
pc2.setRemoteDescription(offer)  [1st desc]: 0.00 s
pc2.setLocalDescription(answer)  [2nd desc]: 0.09 s   (6.15 s without it)
pc1.setRemoteDescription(answer) [2nd desc]: 0.00 s

The log changes register completely. The thirteen audio-engine transitions go from a 2 s timeout each to an immediate skip, all within the same second:

00:24:05  Skipping JS round-trip for audioDeviceModuleEngineCreated
00:24:05  Skipping JS round-trip for audioDeviceModuleEngineWillEnable
00:24:05  Skipping JS round-trip for audioDeviceModuleEngineWillStart
00:24:05  Skipping JS round-trip for audioDeviceModuleEngineDidStop
…  (13 lines, all at 00:24:05)

Without the setup call, those same transitions spread over about 26 s.

The cost is also not paid per call, but for the whole lifetime of the app. On that same run, six more 2 s timeouts appear before registerGlobals() was called, during app startup:

00:23:37  Timed out … audioDeviceModuleEngineCreated
00:23:39  Timed out … audioDeviceModuleEngineWillEnable
00:23:41  Timed out … audioDeviceModuleEngineWillStart
00:23:43  Timed out … audioDeviceModuleEngineDidStop
00:23:45  Timed out … audioDeviceModuleEngineWillEnable
00:23:47  Timed out … audioDeviceModuleEngineDidStop

Any audio-engine transition, at any point in the app's life, costs 2 s until setupListeners() has run.

Note why the workaround works, because it may matter for the fix: it does not make the round trip fast, it removes it. The reconciliation flips the native flags to inactive, and the Skipping JS round-trip message is what shows it. The JS response path is therefore not exercised in the nominal case, only bypassed, so "registerGlobals fixes it" should not be read as "the round trip is fast when a handler exists". We have not measured that case.

Neither variant looks like something to ship as guidance. registerGlobals() does two unrelated things: it installs global.RTCPeerConnection, navigator.mediaDevices.getUserMedia and a dozen other browser globals, and then calls setupListeners() as its last line (src/index.ts:136-137). Only the last line matters for this defect. Telling a TypeScript app that imports RTCPeerConnection directly to call registerGlobals() makes it swallow a dozen globals it has no use for, with the collision risk that implies.

That coupling may be part of the problem: ADM initialisation sits inside a function whose stated job is installing browser globals. An app that imports the package directly, which is the natural way to use it from TypeScript, has no reason to call registerGlobals() and pays 2 to 12 s per call with no signal: no warning, no console.warn, and the only trace is an os_log_error invisible without Console.app.

Possible directions

We do not know the constraints behind the current design, and #89 clearly imposes some, so these are suggestions rather than recommendations.

The 2 s timeout itself is not what we are questioning: it is the safety ceiling introduced by #90 / #91 to replace the unbounded waits, and shortening it would only thin out the guard on a deadlock that is still open. The point is rather that the ceiling is reached systematically. A hook with no subscriber should not wait at all.

  1. Do not wait when nothing can answer. The fast path already exists (AudioDeviceModuleObserver.m:117-127, the Skipping JS round-trip log); it is simply never reached, because the flags default to active and nothing reconciles them until setupListeners() runs. Making a hook inactive until JS declares a handler, or gating the wait on whether the module actually has listeners subscribed, would remove this case entirely without touching the iOS: AudioDeviceModuleObserver's DISPATCH_TIME_FOREVER waits can deadlock the JS thread (total UI freeze) under default config #89 guard. This is the inverse of the choice documented at AudioDeviceModuleObserver.m:88-95, so we may be missing the case that choice protects.
  2. Push the flags on the first setupListeners() call. reconcileActiveFlags() currently only runs on a second invocation (src/AudioDeviceModuleEvents.ts:64-76) while the only call site runs once, so even apps with listeners registered take a JS round trip per engine transition for handlers they do not have.
  3. Run setupListeners() from module import rather than only from registerGlobals(), so the reconciliation does not depend on a call whose documented purpose is unrelated.
  4. At minimum, document that registerGlobals() (or audioDeviceModuleEvents.setupListeners()) is required rather than a convenience for browser globals.

Happy to supply the full log file and the repro harness, or to test a patch.

Environment

@livekit/react-native-webrtc : 144.1.2   (exact resolved version)
react-native                 : 0.83.10
react                        : 19.2.0
expo                         : ~55.0.28
@config-plugins/react-native-webrtc : 14.0.0
expo-callkit-telecom         : ^0.4.0    (system calls on iOS, field measurements only)

Repro : iPhone 17 Pro simulator, iOS 26.4.1 (build 23E254a, arm64), New Architecture,
        no network, no signalling, single app
Field : iPhone 17 Pro Max (iOS 26.6.1) and iPhone 12 (iOS 26.6.1), same Wi-Fi

Audio only, no video, one audio track per peer.

Full log

Both runs back to back: without the setup call (2 s timeouts, 00:21:31 to 00:21:58 and 00:23:37 to 00:23:47), then the restart, then with it (thirteen Skipping JS round-trip lines, all at 00:24:05).

log stream --predicate 'subsystem == "com.livekit.react-native-webrtc"' --info
2026-09-02 00:21:31.887199  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineCreated; returning default 0
2026-09-02 00:21:33.888170  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineWillEnable; returning default 0
2026-09-02 00:21:36.019755  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineWillStart; returning default 0
2026-09-02 00:21:38.054177  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineDidStop; returning default 0
2026-09-02 00:21:40.059678  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineWillEnable; returning default 0
2026-09-02 00:21:42.066641  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineDidStop; returning default 0
2026-09-02 00:21:44.070353  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineWillEnable; returning default 0
2026-09-02 00:21:46.075657  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineDidStop; returning default 0
2026-09-02 00:21:48.077635  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineWillEnable; returning default 0
2026-09-02 00:21:50.081701  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineDidStop; returning default 0
2026-09-02 00:21:52.084848  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineWillEnable; returning default 0
2026-09-02 00:21:54.091033  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineDidStop; returning default 0
2026-09-02 00:21:56.096765  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineDidDisable; returning default 0
2026-09-02 00:21:58.106885  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineWillRelease; returning default 0
2026-09-02 00:23:37.200685  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineCreated; returning default 0
2026-09-02 00:23:39.206092  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineWillEnable; returning default 0
2026-09-02 00:23:41.216829  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineWillStart; returning default 0
2026-09-02 00:23:43.238513  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineDidStop; returning default 0
2026-09-02 00:23:45.243613  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineWillEnable; returning default 0
2026-09-02 00:23:47.247539  Timed out after 2s waiting for JS to respond to audioDeviceModuleEngineDidStop; returning default 0
2026-09-02 00:24:05.836723  Skipping JS round-trip for audioDeviceModuleEngineCreated (no handler registered)
2026-09-02 00:24:05.836737  Skipping JS round-trip for audioDeviceModuleEngineWillEnable (no handler registered)
2026-09-02 00:24:05.922818  Skipping JS round-trip for audioDeviceModuleEngineWillStart (no handler registered)
2026-09-02 00:24:05.930998  Skipping JS round-trip for audioDeviceModuleEngineDidStop (no handler registered)
2026-09-02 00:24:05.931010  Skipping JS round-trip for audioDeviceModuleEngineWillEnable (no handler registered)
2026-09-02 00:24:05.931082  Skipping JS round-trip for audioDeviceModuleEngineDidStop (no handler registered)
2026-09-02 00:24:05.931086  Skipping JS round-trip for audioDeviceModuleEngineWillEnable (no handler registered)
2026-09-02 00:24:05.931142  Skipping JS round-trip for audioDeviceModuleEngineDidStop (no handler registered)
2026-09-02 00:24:05.931145  Skipping JS round-trip for audioDeviceModuleEngineWillEnable (no handler registered)
2026-09-02 00:24:05.931184  Skipping JS round-trip for audioDeviceModuleEngineDidStop (no handler registered)
2026-09-02 00:24:05.931199  Skipping JS round-trip for audioDeviceModuleEngineWillEnable (no handler registered)
2026-09-02 00:24:05.931783  Skipping JS round-trip for audioDeviceModuleEngineDidStop (no handler registered)
2026-09-02 00:24:05.931818  Skipping JS round-trip for audioDeviceModuleEngineDidDisable (no handler registered)
2026-09-02 00:24:05.932536  Skipping JS round-trip for audioDeviceModuleEngineWillRelease (no handler registered)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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