You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
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:
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:
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:21staticconstint64_tkJSResponseTimeoutSeconds = 2;
// ios/RCTWebRTC/AudioDeviceModuleObserver.m:143-155dispatch_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", …);
return0;
}
Three hooks fire in sequence on engine startup, each with its own 2 s wait:
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-76publicsetupListeners(){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 registeredaudioDeviceModuleEvents.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 beforeregisterGlobals() 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.
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.
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.
Run setupListeners() from module import rather than only from registerGlobals(), so the reconciliation does not depend on a call whose documented purpose is unrelated.
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).
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)
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
AudioDeviceModuleObserverblocks 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 insidesetLocalDescription(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 isregisterGlobals(). An app that importsRTCPeerConnectiondirectly and never callsregisterGlobals()(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(), orregisterGlobals()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-webrtc144.1.2, nothing else. TwoRTCPeerConnectionin the same app,iceServers: [], candidates copied across, one real audio track each:Output (iPhone 17 Pro simulator, iOS 26.4.1):
And on the same run,
log stream --predicate 'subsystem == "com.livekit.react-native-webrtc"' --info: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'ssetLocalDescriptionin 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:
Eleven more timeouts, roughly 22 s. The full audio-engine lifecycle costs about 26 s of expired semaphores in this run. The
DidStop/WillEnablealternation 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
Three hooks fire in sequence on engine startup, each with its own 2 s wait:
didCreateEngine,AudioDeviceModuleObserver.m:175-194willEnableEngine,:196-235willStartEngine,:237-264There is a fast path meant to avoid the round trip entirely when the app has no handler (
:117-127, theSkipping JS round-triplog). It is gated on per-hookisActiveflags, which default to active natively:The JS reconciliation does not appear to happen on a normal startup.
reconcileActiveFlags()pushes the real handler state, andsetupListeners()only calls it when it has already been set up: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:which is inside
registerGlobals(). An app that constructsRTCPeerConnectionby direct import and never callsregisterGlobals()has no JS listener foraudioDeviceModuleEngineCreated,…WillEnableor…WillStart; nothing can signal those semaphores, and every engine transition burns its full timeout. That is our case: no occurrence ofregisterGlobalsin 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:
setLocalDescription(answer)setRemoteDescription(answer)The heartbeat is a
setIntervaltick 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
.localremote candidate), waiting for connectivity (13 to 14 remote candidates added before the second description, no gain), CallKit andgetUserMedia(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:
pc2.setLocalDescription(answer)registerGlobals()audioDeviceModuleEvents.setupListeners()aloneaudioDeviceModuleEventsis publicly exported (src/index.ts:104) and typed (lib/typescript/AudioDeviceModuleEvents.d.ts:105, withsetupListeners(): voidat:32), so this is not reaching into internals.Timings and log below are from the
registerGlobals()run: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:
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: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-tripmessage 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 installsglobal.RTCPeerConnection,navigator.mediaDevices.getUserMediaand a dozen other browser globals, and then callssetupListeners()as its last line (src/index.ts:136-137). Only the last line matters for this defect. Telling a TypeScript app that importsRTCPeerConnectiondirectly to callregisterGlobals()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, noconsole.warn, and the only trace is anos_log_errorinvisible 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.
AudioDeviceModuleObserver.m:117-127, theSkipping JS round-triplog); it is simply never reached, because the flags default to active and nothing reconciles them untilsetupListeners()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 atAudioDeviceModuleObserver.m:88-95, so we may be missing the case that choice protects.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.setupListeners()from module import rather than only fromregisterGlobals(), so the reconciliation does not depend on a call whose documented purpose is unrelated.registerGlobals()(oraudioDeviceModuleEvents.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
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-triplines, all at 00:24:05).log stream --predicate 'subsystem == "com.livekit.react-native-webrtc"' --info