From e7d005c645473f3561cdd1fc7da84f4c2576f44d Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:40:52 +0800 Subject: [PATCH 1/9] fix(ios): skip JS round trips when no audio engine handler is registered 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. --- ios/RCTWebRTC/AudioDeviceModuleObserver.h | 9 ++ ios/RCTWebRTC/AudioDeviceModuleObserver.m | 85 ++++++++++++++----- .../WebRTCModule+RTCAudioDeviceModule.m | 32 +++++++ src/AudioDeviceModuleEvents.ts | 43 ++++++++++ 4 files changed, 147 insertions(+), 22 deletions(-) diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.h b/ios/RCTWebRTC/AudioDeviceModuleObserver.h index 16f61851d..752f8b4bc 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.h +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.h @@ -7,6 +7,15 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)initWithWebRTCModule:(WebRTCModule *)module; +// Tracks whether each JS handler is registered. When NO, the observer returns +// immediately without a JS round trip, avoiding the deadlock window entirely. +@property(nonatomic, assign) BOOL isEngineCreatedActive; +@property(nonatomic, assign) BOOL isWillEnableEngineActive; +@property(nonatomic, assign) BOOL isWillStartEngineActive; +@property(nonatomic, assign) BOOL isDidStopEngineActive; +@property(nonatomic, assign) BOOL isDidDisableEngineActive; +@property(nonatomic, assign) BOOL isWillReleaseEngineActive; + // Methods to receive results from JS. requestId echoes the id sent with the // corresponding event so stale responses from timed-out rounds can be dropped. - (void)resolveEngineCreatedWithRequestId:(NSInteger)requestId result:(NSInteger)result; diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.m b/ios/RCTWebRTC/AudioDeviceModuleObserver.m index a12b59772..0759eee7d 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.m +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.m @@ -87,11 +87,22 @@ - (instancetype)initWithWebRTCModule:(WebRTCModule *)module { // already given up). No legitimate signal for *this* round can exist at drain // time because the event has not been sent yet. // -// Returns the JS-provided result on success, or 0 on timeout. +// If isActive is NO, returns 0 immediately without a JS round trip, avoiding the +// deadlock window when no handler is registered. +// +// Returns the JS-provided result on success, or 0 on timeout or when inactive. - (NSInteger)sendEventAndWaitWithName:(NSString *)eventName body:(NSDictionary *)body semaphore:(dispatch_semaphore_t)semaphore - resultBlock:(NSInteger (^)(void))resultBlock { + resultBlock:(NSInteger (^)(void))resultBlock + isActive:(BOOL)isActive { + 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; + } + NSInteger requestId; @synchronized(self) { requestId = ++self.requestIdSeq; @@ -139,14 +150,19 @@ - (void)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule } - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule didCreateEngine:(AVAudioEngine *)engine { - RCTLog(@"[AudioDeviceModuleObserver] Engine created - waiting for JS response"); + BOOL isActive = self.isEngineCreatedActive; + + if (isActive) { + RCTLog(@"[AudioDeviceModuleObserver] Engine created - waiting for JS response"); + } NSInteger result = [self sendEventAndWaitWithName:kEventAudioDeviceModuleEngineCreated body:@{} semaphore:self.engineCreatedSemaphore resultBlock:^NSInteger { return self.engineCreatedResult; - }]; + } + isActive:isActive]; RCTLog(@"[AudioDeviceModuleObserver] Engine created - JS returned: %ld", (long)result); return result; @@ -156,9 +172,13 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule willEnableEngine:(AVAudioEngine *)engine isPlayoutEnabled:(BOOL)isPlayoutEnabled isRecordingEnabled:(BOOL)isRecordingEnabled { - RCTLog(@"[AudioDeviceModuleObserver] Engine will enable - playout: %d, recording: %d - waiting for JS response", - isPlayoutEnabled, - isRecordingEnabled); + BOOL isActive = self.isWillEnableEngineActive; + + if (isActive) { + RCTLog(@"[AudioDeviceModuleObserver] Engine will enable - playout: %d, recording: %d - waiting for JS response", + isPlayoutEnabled, + isRecordingEnabled); + } NSInteger result = [self sendEventAndWaitWithName:kEventAudioDeviceModuleEngineWillEnable body:@{ @@ -168,7 +188,8 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule semaphore:self.willEnableEngineSemaphore resultBlock:^NSInteger { return self.willEnableEngineResult; - }]; + } + isActive:isActive]; RCTLog(@"[AudioDeviceModuleObserver] Engine will enable - JS returned: %ld", (long)result); @@ -182,9 +203,13 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule willStartEngine:(AVAudioEngine *)engine isPlayoutEnabled:(BOOL)isPlayoutEnabled isRecordingEnabled:(BOOL)isRecordingEnabled { - RCTLog(@"[AudioDeviceModuleObserver] Engine will start - playout: %d, recording: %d - waiting for JS response", - isPlayoutEnabled, - isRecordingEnabled); + BOOL isActive = self.isWillStartEngineActive; + + if (isActive) { + RCTLog(@"[AudioDeviceModuleObserver] Engine will start - playout: %d, recording: %d - waiting for JS response", + isPlayoutEnabled, + isRecordingEnabled); + } NSInteger result = [self sendEventAndWaitWithName:kEventAudioDeviceModuleEngineWillStart body:@{ @@ -194,7 +219,8 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule semaphore:self.willStartEngineSemaphore resultBlock:^NSInteger { return self.willStartEngineResult; - }]; + } + isActive:isActive]; RCTLog(@"[AudioDeviceModuleObserver] Engine will start - JS returned: %ld", (long)result); return result; @@ -204,9 +230,13 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule didStopEngine:(AVAudioEngine *)engine isPlayoutEnabled:(BOOL)isPlayoutEnabled isRecordingEnabled:(BOOL)isRecordingEnabled { - RCTLog(@"[AudioDeviceModuleObserver] Engine did stop - playout: %d, recording: %d - waiting for JS response", - isPlayoutEnabled, - isRecordingEnabled); + BOOL isActive = self.isDidStopEngineActive; + + if (isActive) { + RCTLog(@"[AudioDeviceModuleObserver] Engine did stop - playout: %d, recording: %d - waiting for JS response", + isPlayoutEnabled, + isRecordingEnabled); + } NSInteger result = [self sendEventAndWaitWithName:kEventAudioDeviceModuleEngineDidStop body:@{ @@ -216,7 +246,8 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule semaphore:self.didStopEngineSemaphore resultBlock:^NSInteger { return self.didStopEngineResult; - }]; + } + isActive:isActive]; RCTLog(@"[AudioDeviceModuleObserver] Engine did stop - JS returned: %ld", (long)result); return result; @@ -226,9 +257,13 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule didDisableEngine:(AVAudioEngine *)engine isPlayoutEnabled:(BOOL)isPlayoutEnabled isRecordingEnabled:(BOOL)isRecordingEnabled { - RCTLog(@"[AudioDeviceModuleObserver] Engine did disable - playout: %d, recording: %d - waiting for JS response", - isPlayoutEnabled, - isRecordingEnabled); + BOOL isActive = self.isDidDisableEngineActive; + + if (isActive) { + RCTLog(@"[AudioDeviceModuleObserver] Engine did disable - playout: %d, recording: %d - waiting for JS response", + isPlayoutEnabled, + isRecordingEnabled); + } NSInteger result = [self sendEventAndWaitWithName:kEventAudioDeviceModuleEngineDidDisable body:@{ @@ -238,21 +273,27 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule semaphore:self.didDisableEngineSemaphore resultBlock:^NSInteger { return self.didDisableEngineResult; - }]; + } + isActive:isActive]; RCTLog(@"[AudioDeviceModuleObserver] Engine did disable - JS returned: %ld", (long)result); return result; } - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule willReleaseEngine:(AVAudioEngine *)engine { - RCTLog(@"[AudioDeviceModuleObserver] Engine will release - waiting for JS response"); + BOOL isActive = self.isWillReleaseEngineActive; + + if (isActive) { + RCTLog(@"[AudioDeviceModuleObserver] Engine will release - waiting for JS response"); + } NSInteger result = [self sendEventAndWaitWithName:kEventAudioDeviceModuleEngineWillRelease body:@{} semaphore:self.willReleaseEngineSemaphore resultBlock:^NSInteger { return self.willReleaseEngineResult; - }]; + } + isActive:isActive]; RCTLog(@"[AudioDeviceModuleObserver] Engine will release - JS returned: %ld", (long)result); return result; diff --git a/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m b/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m index 0aa296bb9..56895a5bb 100644 --- a/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m +++ b/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m @@ -271,4 +271,36 @@ @implementation WebRTCModule (RTCAudioDeviceModule) return nil; } +#pragma mark - Handler Active State + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleSetEngineCreatedActive : (BOOL)isActive) { + self.audioDeviceModuleObserver.isEngineCreatedActive = isActive; + return nil; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleSetWillEnableEngineActive : (BOOL)isActive) { + self.audioDeviceModuleObserver.isWillEnableEngineActive = isActive; + return nil; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleSetWillStartEngineActive : (BOOL)isActive) { + self.audioDeviceModuleObserver.isWillStartEngineActive = isActive; + return nil; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleSetDidStopEngineActive : (BOOL)isActive) { + self.audioDeviceModuleObserver.isDidStopEngineActive = isActive; + return nil; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleSetDidDisableEngineActive : (BOOL)isActive) { + self.audioDeviceModuleObserver.isDidDisableEngineActive = isActive; + return nil; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleSetWillReleaseEngineActive : (BOOL)isActive) { + self.audioDeviceModuleObserver.isWillReleaseEngineActive = isActive; + return nil; +} + @end diff --git a/src/AudioDeviceModuleEvents.ts b/src/AudioDeviceModuleEvents.ts index f830a1a46..7d3c5496c 100644 --- a/src/AudioDeviceModuleEvents.ts +++ b/src/AudioDeviceModuleEvents.ts @@ -209,7 +209,15 @@ class AudioDeviceModuleEventEmitter { * This handler blocks the native thread until it returns, throw to cancel audio engine's operation */ setEngineCreatedHandler(handler: AudioEngineEventNoParamsHandler | null) { + const wasActive = this.engineCreatedHandler !== null; + this.engineCreatedHandler = handler; + const isActive = this.engineCreatedHandler !== null; + + // Notify native about active state change to avoid unnecessary JS round trips + if (wasActive !== isActive && WebRTCModule) { + WebRTCModule.audioDeviceModuleSetEngineCreatedActive(isActive); + } } /** @@ -217,7 +225,14 @@ class AudioDeviceModuleEventEmitter { * This handler blocks the native thread until it returns, throw to cancel audio engine's operation */ setWillEnableEngineHandler(handler: AudioEngineEventHandler | null) { + const wasActive = this.willEnableEngineHandler !== null; + this.willEnableEngineHandler = handler; + const isActive = this.willEnableEngineHandler !== null; + + if (wasActive !== isActive && WebRTCModule) { + WebRTCModule.audioDeviceModuleSetWillEnableEngineActive(isActive); + } } /** @@ -225,7 +240,14 @@ class AudioDeviceModuleEventEmitter { * This handler blocks the native thread until it returns, throw to cancel audio engine's operation */ setWillStartEngineHandler(handler: AudioEngineEventHandler | null) { + const wasActive = this.willStartEngineHandler !== null; + this.willStartEngineHandler = handler; + const isActive = this.willStartEngineHandler !== null; + + if (wasActive !== isActive && WebRTCModule) { + WebRTCModule.audioDeviceModuleSetWillStartEngineActive(isActive); + } } /** @@ -233,7 +255,14 @@ class AudioDeviceModuleEventEmitter { * This handler blocks the native thread until it returns, throw to cancel audio engine's operation */ setDidStopEngineHandler(handler: AudioEngineEventHandler | null) { + const wasActive = this.didStopEngineHandler !== null; + this.didStopEngineHandler = handler; + const isActive = this.didStopEngineHandler !== null; + + if (wasActive !== isActive && WebRTCModule) { + WebRTCModule.audioDeviceModuleSetDidStopEngineActive(isActive); + } } /** @@ -241,7 +270,14 @@ class AudioDeviceModuleEventEmitter { * This handler blocks the native thread until it returns, throw to cancel audio engine's operation */ setDidDisableEngineHandler(handler: AudioEngineEventHandler | null) { + const wasActive = this.didDisableEngineHandler !== null; + this.didDisableEngineHandler = handler; + const isActive = this.didDisableEngineHandler !== null; + + if (wasActive !== isActive && WebRTCModule) { + WebRTCModule.audioDeviceModuleSetDidDisableEngineActive(isActive); + } } /** @@ -249,7 +285,14 @@ class AudioDeviceModuleEventEmitter { * This handler blocks the native thread until it returns, throw to cancel audio engine's operation */ setWillReleaseEngineHandler(handler: AudioEngineEventNoParamsHandler | null) { + const wasActive = this.willReleaseEngineHandler !== null; + this.willReleaseEngineHandler = handler; + const isActive = this.willReleaseEngineHandler !== null; + + if (wasActive !== isActive && WebRTCModule) { + WebRTCModule.audioDeviceModuleSetWillReleaseEngineActive(isActive); + } } } From 61f3a5a64c4d66e1bf19f34c29efb97ff13dab3d Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:44:23 +0800 Subject: [PATCH 2/9] fix(ios): make handler active flags atomic for cross-thread access 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. --- ios/RCTWebRTC/AudioDeviceModuleObserver.h | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.h b/ios/RCTWebRTC/AudioDeviceModuleObserver.h index 752f8b4bc..1a3702549 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.h +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.h @@ -9,12 +9,14 @@ NS_ASSUME_NONNULL_BEGIN // Tracks whether each JS handler is registered. When NO, the observer returns // immediately without a JS round trip, avoiding the deadlock window entirely. -@property(nonatomic, assign) BOOL isEngineCreatedActive; -@property(nonatomic, assign) BOOL isWillEnableEngineActive; -@property(nonatomic, assign) BOOL isWillStartEngineActive; -@property(nonatomic, assign) BOOL isDidStopEngineActive; -@property(nonatomic, assign) BOOL isDidDisableEngineActive; -@property(nonatomic, assign) BOOL isWillReleaseEngineActive; +// Atomic because they are written on the JS thread (handler registration) and +// read on the native audio thread (delegate callbacks). +@property(atomic, assign) BOOL isEngineCreatedActive; +@property(atomic, assign) BOOL isWillEnableEngineActive; +@property(atomic, assign) BOOL isWillStartEngineActive; +@property(atomic, assign) BOOL isDidStopEngineActive; +@property(atomic, assign) BOOL isDidDisableEngineActive; +@property(atomic, assign) BOOL isWillReleaseEngineActive; // Methods to receive results from JS. requestId echoes the id sent with the // corresponding event so stale responses from timed-out rounds can be dropped. From 1f468118e9d5a5ff45b275751a02fec080eb07ba Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:36:46 +0800 Subject: [PATCH 3/9] fix(js): gate audio engine active-flag push to non-Android platforms 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. --- src/AudioDeviceModuleEvents.ts | 36 ++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/AudioDeviceModuleEvents.ts b/src/AudioDeviceModuleEvents.ts index 7d3c5496c..928ba1dc0 100644 --- a/src/AudioDeviceModuleEvents.ts +++ b/src/AudioDeviceModuleEvents.ts @@ -204,6 +204,17 @@ class AudioDeviceModuleEventEmitter { removeListener(listener); } + /** + * Push a handler's active state to native. The native active-flag setters are + * iOS/macOS only, so this is gated like setupListeners() to avoid a TypeError + * on Android, where these methods do not exist. + */ + private pushHandlerActive(method: string, isActive: boolean) { + if (Platform.OS !== 'android' && WebRTCModule) { + WebRTCModule[method](isActive); + } + } + /** * Set handler for engine created delegate - MUST return 0 for success or error code * This handler blocks the native thread until it returns, throw to cancel audio engine's operation @@ -214,9 +225,8 @@ class AudioDeviceModuleEventEmitter { this.engineCreatedHandler = handler; const isActive = this.engineCreatedHandler !== null; - // Notify native about active state change to avoid unnecessary JS round trips - if (wasActive !== isActive && WebRTCModule) { - WebRTCModule.audioDeviceModuleSetEngineCreatedActive(isActive); + if (wasActive !== isActive) { + this.pushHandlerActive('audioDeviceModuleSetEngineCreatedActive', isActive); } } @@ -230,8 +240,8 @@ class AudioDeviceModuleEventEmitter { this.willEnableEngineHandler = handler; const isActive = this.willEnableEngineHandler !== null; - if (wasActive !== isActive && WebRTCModule) { - WebRTCModule.audioDeviceModuleSetWillEnableEngineActive(isActive); + if (wasActive !== isActive) { + this.pushHandlerActive('audioDeviceModuleSetWillEnableEngineActive', isActive); } } @@ -245,8 +255,8 @@ class AudioDeviceModuleEventEmitter { this.willStartEngineHandler = handler; const isActive = this.willStartEngineHandler !== null; - if (wasActive !== isActive && WebRTCModule) { - WebRTCModule.audioDeviceModuleSetWillStartEngineActive(isActive); + if (wasActive !== isActive) { + this.pushHandlerActive('audioDeviceModuleSetWillStartEngineActive', isActive); } } @@ -260,8 +270,8 @@ class AudioDeviceModuleEventEmitter { this.didStopEngineHandler = handler; const isActive = this.didStopEngineHandler !== null; - if (wasActive !== isActive && WebRTCModule) { - WebRTCModule.audioDeviceModuleSetDidStopEngineActive(isActive); + if (wasActive !== isActive) { + this.pushHandlerActive('audioDeviceModuleSetDidStopEngineActive', isActive); } } @@ -275,8 +285,8 @@ class AudioDeviceModuleEventEmitter { this.didDisableEngineHandler = handler; const isActive = this.didDisableEngineHandler !== null; - if (wasActive !== isActive && WebRTCModule) { - WebRTCModule.audioDeviceModuleSetDidDisableEngineActive(isActive); + if (wasActive !== isActive) { + this.pushHandlerActive('audioDeviceModuleSetDidDisableEngineActive', isActive); } } @@ -290,8 +300,8 @@ class AudioDeviceModuleEventEmitter { this.willReleaseEngineHandler = handler; const isActive = this.willReleaseEngineHandler !== null; - if (wasActive !== isActive && WebRTCModule) { - WebRTCModule.audioDeviceModuleSetWillReleaseEngineActive(isActive); + if (wasActive !== isActive) { + this.pushHandlerActive('audioDeviceModuleSetWillReleaseEngineActive', isActive); } } } From d0b33fd39bdf22bef97d07acfb423676372f4516 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:38:00 +0800 Subject: [PATCH 4/9] fix: make handler active flags authoritative (default active + reconcile 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. --- ios/RCTWebRTC/AudioDeviceModuleObserver.m | 11 +++++++++++ src/AudioDeviceModuleEvents.ts | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.m b/ios/RCTWebRTC/AudioDeviceModuleObserver.m index 0759eee7d..945142440 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.m +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.m @@ -69,6 +69,17 @@ - (instancetype)initWithWebRTCModule:(WebRTCModule *)module { _didStopEngineSemaphore = dispatch_semaphore_create(0); _didDisableEngineSemaphore = dispatch_semaphore_create(0); _willReleaseEngineSemaphore = dispatch_semaphore_create(0); + + // Default every hook to active so a delegate callback that fires before JS + // has reconciled its handler state (e.g. a recreated observer) still does the + // bounded round trip rather than silently skipping a handler's veto. JS + // reconciles these to the real handler state in setupListeners(). + _isEngineCreatedActive = YES; + _isWillEnableEngineActive = YES; + _isWillStartEngineActive = YES; + _isDidStopEngineActive = YES; + _isDidDisableEngineActive = YES; + _isWillReleaseEngineActive = YES; } return self; } diff --git a/src/AudioDeviceModuleEvents.ts b/src/AudioDeviceModuleEvents.ts index 928ba1dc0..0d62f560c 100644 --- a/src/AudioDeviceModuleEvents.ts +++ b/src/AudioDeviceModuleEvents.ts @@ -173,6 +173,23 @@ class AudioDeviceModuleEventEmitter { WebRTCModule.audioDeviceModuleResolveWillReleaseEngine(requestId, result); }); + + // Reconcile native active flags with the current handler state. Native + // defaults every hook to active, so pushing the real state here makes a + // fresh or recreated observer match the handlers registered now instead + // of depending on a set/clear transition that may have already happened. + const activeFlags: [string, boolean][] = [ + [ 'audioDeviceModuleSetEngineCreatedActive', this.engineCreatedHandler !== null ], + [ 'audioDeviceModuleSetWillEnableEngineActive', this.willEnableEngineHandler !== null ], + [ 'audioDeviceModuleSetWillStartEngineActive', this.willStartEngineHandler !== null ], + [ 'audioDeviceModuleSetDidStopEngineActive', this.didStopEngineHandler !== null ], + [ 'audioDeviceModuleSetDidDisableEngineActive', this.didDisableEngineHandler !== null ], + [ 'audioDeviceModuleSetWillReleaseEngineActive', this.willReleaseEngineHandler !== null ], + ]; + + for (const [ method, isActive ] of activeFlags) { + this.pushHandlerActive(method, isActive); + } } } From 06eaf3c6e475f9481d1228df04198cb8712048fd Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:40:17 +0800 Subject: [PATCH 5/9] fix(ios): demote inactive-hook skip log to debug level 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. --- ios/RCTWebRTC/AudioDeviceModuleObserver.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.m b/ios/RCTWebRTC/AudioDeviceModuleObserver.m index 945142440..eaadc06c4 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.m +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.m @@ -110,7 +110,7 @@ - (NSInteger)sendEventAndWaitWithName:(NSString *)eventName 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); + os_log_debug(ADMObserverLog(), "Skipping JS round-trip for %{public}@ (no handler registered)", eventName); return 0; } From 46cf0fbde251946949587a8155a09f0f15297a51 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:41:19 +0800 Subject: [PATCH 6/9] fix(js): make setupListeners idempotent to avoid double-registration 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. --- src/AudioDeviceModuleEvents.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/AudioDeviceModuleEvents.ts b/src/AudioDeviceModuleEvents.ts index 0d62f560c..04f7461f9 100644 --- a/src/AudioDeviceModuleEvents.ts +++ b/src/AudioDeviceModuleEvents.ts @@ -59,8 +59,18 @@ class AudioDeviceModuleEventEmitter { private didDisableEngineHandler: AudioEngineEventHandler | null = null; private willReleaseEngineHandler: AudioEngineEventNoParamsHandler | null = null; + private listenersSetUp = false; + public setupListeners() { if (Platform.OS !== 'android' && WebRTCModule) { + // addListener appends without de-duping, so guard against a second + // registerGlobals() double-registering listeners and double-invoking handlers. + if (this.listenersSetUp) { + return; + } + + this.listenersSetUp = true; + // Setup handlers for blocking delegate methods addListener(this, 'audioDeviceModuleEngineCreated', async (event: unknown) => { const { requestId } = event as EngineEventPayload; From d0a2dc91575bcf344b7955a4512907a78de97ce1 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:10:11 +0800 Subject: [PATCH 7/9] fix(js): push native active flag before publishing handler to avoid lost 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. --- src/AudioDeviceModuleEvents.ts | 119 ++++++++++++++++++++------------- 1 file changed, 71 insertions(+), 48 deletions(-) diff --git a/src/AudioDeviceModuleEvents.ts b/src/AudioDeviceModuleEvents.ts index 04f7461f9..f738820ee 100644 --- a/src/AudioDeviceModuleEvents.ts +++ b/src/AudioDeviceModuleEvents.ts @@ -243,33 +243,56 @@ class AudioDeviceModuleEventEmitter { } /** - * Set handler for engine created delegate - MUST return 0 for success or error code - * This handler blocks the native thread until it returns, throw to cancel audio engine's operation + * Apply a handler change while keeping the native active flag ordered so a + * delegate callback racing registration can never skip a handler that exists. + * + * On activation (null to non-null) the flag is pushed active *before* the + * handler is published, so the worst case is a JS round trip whose listener + * sees the not-yet-published handler and resolves 0, never a skipped veto. On + * deactivation the handler is cleared first, then the flag pushed inactive, so + * the same safe ordering holds in reverse. No push happens when active state + * is unchanged. */ - setEngineCreatedHandler(handler: AudioEngineEventNoParamsHandler | null) { - const wasActive = this.engineCreatedHandler !== null; + private applyHandlerActive(method: string, wasActive: boolean, isActive: boolean, assign: () => void) { + if (isActive && !wasActive) { + this.pushHandlerActive(method, true); + } - this.engineCreatedHandler = handler; - const isActive = this.engineCreatedHandler !== null; + assign(); - if (wasActive !== isActive) { - this.pushHandlerActive('audioDeviceModuleSetEngineCreatedActive', isActive); + if (!isActive && wasActive) { + this.pushHandlerActive(method, false); } } + /** + * Set handler for engine created delegate - MUST return 0 for success or error code + * This handler blocks the native thread until it returns, throw to cancel audio engine's operation + */ + setEngineCreatedHandler(handler: AudioEngineEventNoParamsHandler | null) { + this.applyHandlerActive( + 'audioDeviceModuleSetEngineCreatedActive', + this.engineCreatedHandler !== null, + handler !== null, + () => { + this.engineCreatedHandler = handler; + }, + ); + } + /** * Set handler for will enable engine delegate - MUST return 0 for success or error code * This handler blocks the native thread until it returns, throw to cancel audio engine's operation */ setWillEnableEngineHandler(handler: AudioEngineEventHandler | null) { - const wasActive = this.willEnableEngineHandler !== null; - - this.willEnableEngineHandler = handler; - const isActive = this.willEnableEngineHandler !== null; - - if (wasActive !== isActive) { - this.pushHandlerActive('audioDeviceModuleSetWillEnableEngineActive', isActive); - } + this.applyHandlerActive( + 'audioDeviceModuleSetWillEnableEngineActive', + this.willEnableEngineHandler !== null, + handler !== null, + () => { + this.willEnableEngineHandler = handler; + }, + ); } /** @@ -277,14 +300,14 @@ class AudioDeviceModuleEventEmitter { * This handler blocks the native thread until it returns, throw to cancel audio engine's operation */ setWillStartEngineHandler(handler: AudioEngineEventHandler | null) { - const wasActive = this.willStartEngineHandler !== null; - - this.willStartEngineHandler = handler; - const isActive = this.willStartEngineHandler !== null; - - if (wasActive !== isActive) { - this.pushHandlerActive('audioDeviceModuleSetWillStartEngineActive', isActive); - } + this.applyHandlerActive( + 'audioDeviceModuleSetWillStartEngineActive', + this.willStartEngineHandler !== null, + handler !== null, + () => { + this.willStartEngineHandler = handler; + }, + ); } /** @@ -292,14 +315,14 @@ class AudioDeviceModuleEventEmitter { * This handler blocks the native thread until it returns, throw to cancel audio engine's operation */ setDidStopEngineHandler(handler: AudioEngineEventHandler | null) { - const wasActive = this.didStopEngineHandler !== null; - - this.didStopEngineHandler = handler; - const isActive = this.didStopEngineHandler !== null; - - if (wasActive !== isActive) { - this.pushHandlerActive('audioDeviceModuleSetDidStopEngineActive', isActive); - } + this.applyHandlerActive( + 'audioDeviceModuleSetDidStopEngineActive', + this.didStopEngineHandler !== null, + handler !== null, + () => { + this.didStopEngineHandler = handler; + }, + ); } /** @@ -307,14 +330,14 @@ class AudioDeviceModuleEventEmitter { * This handler blocks the native thread until it returns, throw to cancel audio engine's operation */ setDidDisableEngineHandler(handler: AudioEngineEventHandler | null) { - const wasActive = this.didDisableEngineHandler !== null; - - this.didDisableEngineHandler = handler; - const isActive = this.didDisableEngineHandler !== null; - - if (wasActive !== isActive) { - this.pushHandlerActive('audioDeviceModuleSetDidDisableEngineActive', isActive); - } + this.applyHandlerActive( + 'audioDeviceModuleSetDidDisableEngineActive', + this.didDisableEngineHandler !== null, + handler !== null, + () => { + this.didDisableEngineHandler = handler; + }, + ); } /** @@ -322,14 +345,14 @@ class AudioDeviceModuleEventEmitter { * This handler blocks the native thread until it returns, throw to cancel audio engine's operation */ setWillReleaseEngineHandler(handler: AudioEngineEventNoParamsHandler | null) { - const wasActive = this.willReleaseEngineHandler !== null; - - this.willReleaseEngineHandler = handler; - const isActive = this.willReleaseEngineHandler !== null; - - if (wasActive !== isActive) { - this.pushHandlerActive('audioDeviceModuleSetWillReleaseEngineActive', isActive); - } + this.applyHandlerActive( + 'audioDeviceModuleSetWillReleaseEngineActive', + this.willReleaseEngineHandler !== null, + handler !== null, + () => { + this.willReleaseEngineHandler = handler; + }, + ); } } From 313ecf3e6faa3d26ab2409765643a84b80955435 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:11:24 +0800 Subject: [PATCH 8/9] fix(js): reconcile active flags on every setupListeners call 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. --- src/AudioDeviceModuleEvents.ts | 46 +++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/src/AudioDeviceModuleEvents.ts b/src/AudioDeviceModuleEvents.ts index f738820ee..a93365574 100644 --- a/src/AudioDeviceModuleEvents.ts +++ b/src/AudioDeviceModuleEvents.ts @@ -63,9 +63,13 @@ class AudioDeviceModuleEventEmitter { public setupListeners() { if (Platform.OS !== 'android' && WebRTCModule) { - // addListener appends without de-duping, so guard against a second - // registerGlobals() double-registering listeners and double-invoking handlers. + // Reconcile on every call so a recreated native observer (which defaults + // every hook to active) is re-synced to the current handler state even + // when JS listeners are already registered. addListener appends without + // de-duping, so the registration below must run only once. if (this.listenersSetUp) { + this.reconcileActiveFlags(); + return; } @@ -184,22 +188,7 @@ class AudioDeviceModuleEventEmitter { WebRTCModule.audioDeviceModuleResolveWillReleaseEngine(requestId, result); }); - // Reconcile native active flags with the current handler state. Native - // defaults every hook to active, so pushing the real state here makes a - // fresh or recreated observer match the handlers registered now instead - // of depending on a set/clear transition that may have already happened. - const activeFlags: [string, boolean][] = [ - [ 'audioDeviceModuleSetEngineCreatedActive', this.engineCreatedHandler !== null ], - [ 'audioDeviceModuleSetWillEnableEngineActive', this.willEnableEngineHandler !== null ], - [ 'audioDeviceModuleSetWillStartEngineActive', this.willStartEngineHandler !== null ], - [ 'audioDeviceModuleSetDidStopEngineActive', this.didStopEngineHandler !== null ], - [ 'audioDeviceModuleSetDidDisableEngineActive', this.didDisableEngineHandler !== null ], - [ 'audioDeviceModuleSetWillReleaseEngineActive', this.willReleaseEngineHandler !== null ], - ]; - - for (const [ method, isActive ] of activeFlags) { - this.pushHandlerActive(method, isActive); - } + this.reconcileActiveFlags(); } } @@ -265,6 +254,27 @@ class AudioDeviceModuleEventEmitter { } } + /** + * Push the current handler state for every hook to native. Native defaults + * each hook to active, so this re-syncs a fresh or recreated observer to the + * handlers registered now rather than relying on a set/clear transition that + * may have already happened. + */ + private reconcileActiveFlags() { + const activeFlags: [string, boolean][] = [ + [ 'audioDeviceModuleSetEngineCreatedActive', this.engineCreatedHandler !== null ], + [ 'audioDeviceModuleSetWillEnableEngineActive', this.willEnableEngineHandler !== null ], + [ 'audioDeviceModuleSetWillStartEngineActive', this.willStartEngineHandler !== null ], + [ 'audioDeviceModuleSetDidStopEngineActive', this.didStopEngineHandler !== null ], + [ 'audioDeviceModuleSetDidDisableEngineActive', this.didDisableEngineHandler !== null ], + [ 'audioDeviceModuleSetWillReleaseEngineActive', this.willReleaseEngineHandler !== null ], + ]; + + for (const [ method, isActive ] of activeFlags) { + this.pushHandlerActive(method, isActive); + } + } + /** * Set handler for engine created delegate - MUST return 0 for success or error code * This handler blocks the native thread until it returns, throw to cancel audio engine's operation From 4acbcdc9bacf6b5b9f04ea219d492e6575b957ba Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:28:00 +0800 Subject: [PATCH 9/9] fix(ios): gate JS-returned log on isActive for skip-path symmetry 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. --- ios/RCTWebRTC/AudioDeviceModuleObserver.m | 28 ++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.m b/ios/RCTWebRTC/AudioDeviceModuleObserver.m index eaadc06c4..8b42cf11a 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.m +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.m @@ -175,7 +175,9 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule didCrea } isActive:isActive]; - RCTLog(@"[AudioDeviceModuleObserver] Engine created - JS returned: %ld", (long)result); + if (isActive) { + RCTLog(@"[AudioDeviceModuleObserver] Engine created - JS returned: %ld", (long)result); + } return result; } @@ -202,10 +204,12 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule } isActive:isActive]; - RCTLog(@"[AudioDeviceModuleObserver] Engine will enable - JS returned: %ld", (long)result); + if (isActive) { + RCTLog(@"[AudioDeviceModuleObserver] Engine will enable - JS returned: %ld", (long)result); - AVAudioSession *audioSession = [AVAudioSession sharedInstance]; - RCTLog(@"[AudioDeviceModuleObserver] Audio session category: %@", audioSession.category); + AVAudioSession *audioSession = [AVAudioSession sharedInstance]; + RCTLog(@"[AudioDeviceModuleObserver] Audio session category: %@", audioSession.category); + } return result; } @@ -233,7 +237,9 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule } isActive:isActive]; - RCTLog(@"[AudioDeviceModuleObserver] Engine will start - JS returned: %ld", (long)result); + if (isActive) { + RCTLog(@"[AudioDeviceModuleObserver] Engine will start - JS returned: %ld", (long)result); + } return result; } @@ -260,7 +266,9 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule } isActive:isActive]; - RCTLog(@"[AudioDeviceModuleObserver] Engine did stop - JS returned: %ld", (long)result); + if (isActive) { + RCTLog(@"[AudioDeviceModuleObserver] Engine did stop - JS returned: %ld", (long)result); + } return result; } @@ -287,7 +295,9 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule } isActive:isActive]; - RCTLog(@"[AudioDeviceModuleObserver] Engine did disable - JS returned: %ld", (long)result); + if (isActive) { + RCTLog(@"[AudioDeviceModuleObserver] Engine did disable - JS returned: %ld", (long)result); + } return result; } @@ -306,7 +316,9 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule willRel } isActive:isActive]; - RCTLog(@"[AudioDeviceModuleObserver] Engine will release - JS returned: %ld", (long)result); + if (isActive) { + RCTLog(@"[AudioDeviceModuleObserver] Engine will release - JS returned: %ld", (long)result); + } return result; }