diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.h b/ios/RCTWebRTC/AudioDeviceModuleObserver.h index 16f61851d..1a3702549 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.h +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.h @@ -7,6 +7,17 @@ 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. +// 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. - (void)resolveEngineCreatedWithRequestId:(NSInteger)requestId result:(NSInteger)result; diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.m b/ios/RCTWebRTC/AudioDeviceModuleObserver.m index a12b59772..8b42cf11a 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; } @@ -87,11 +98,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_debug(ADMObserverLog(), "Skipping JS round-trip for %{public}@ (no handler registered)", eventName); + return 0; + } + NSInteger requestId; @synchronized(self) { requestId = ++self.requestIdSeq; @@ -139,16 +161,23 @@ - (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); + if (isActive) { + RCTLog(@"[AudioDeviceModuleObserver] Engine created - JS returned: %ld", (long)result); + } return result; } @@ -156,9 +185,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,12 +201,15 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule semaphore:self.willEnableEngineSemaphore resultBlock:^NSInteger { return self.willEnableEngineResult; - }]; + } + 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; } @@ -182,9 +218,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,9 +234,12 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule semaphore:self.willStartEngineSemaphore resultBlock:^NSInteger { return self.willStartEngineResult; - }]; + } + 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; } @@ -204,9 +247,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,9 +263,12 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule semaphore:self.didStopEngineSemaphore resultBlock:^NSInteger { return self.didStopEngineResult; - }]; + } + 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; } @@ -226,9 +276,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,23 +292,33 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule semaphore:self.didDisableEngineSemaphore resultBlock:^NSInteger { return self.didDisableEngineResult; - }]; + } + 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; } - (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); + if (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..a93365574 100644 --- a/src/AudioDeviceModuleEvents.ts +++ b/src/AudioDeviceModuleEvents.ts @@ -59,8 +59,22 @@ class AudioDeviceModuleEventEmitter { private didDisableEngineHandler: AudioEngineEventHandler | null = null; private willReleaseEngineHandler: AudioEngineEventNoParamsHandler | null = null; + private listenersSetUp = false; + public setupListeners() { if (Platform.OS !== 'android' && WebRTCModule) { + // 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; + } + + this.listenersSetUp = true; + // Setup handlers for blocking delegate methods addListener(this, 'audioDeviceModuleEngineCreated', async (event: unknown) => { const { requestId } = event as EngineEventPayload; @@ -173,6 +187,8 @@ class AudioDeviceModuleEventEmitter { WebRTCModule.audioDeviceModuleResolveWillReleaseEngine(requestId, result); }); + + this.reconcileActiveFlags(); } } @@ -204,12 +220,74 @@ 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); + } + } + + /** + * 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. + */ + private applyHandlerActive(method: string, wasActive: boolean, isActive: boolean, assign: () => void) { + if (isActive && !wasActive) { + this.pushHandlerActive(method, true); + } + + assign(); + + if (!isActive && wasActive) { + this.pushHandlerActive(method, false); + } + } + + /** + * 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 */ setEngineCreatedHandler(handler: AudioEngineEventNoParamsHandler | null) { - this.engineCreatedHandler = handler; + this.applyHandlerActive( + 'audioDeviceModuleSetEngineCreatedActive', + this.engineCreatedHandler !== null, + handler !== null, + () => { + this.engineCreatedHandler = handler; + }, + ); } /** @@ -217,7 +295,14 @@ class AudioDeviceModuleEventEmitter { * This handler blocks the native thread until it returns, throw to cancel audio engine's operation */ setWillEnableEngineHandler(handler: AudioEngineEventHandler | null) { - this.willEnableEngineHandler = handler; + this.applyHandlerActive( + 'audioDeviceModuleSetWillEnableEngineActive', + this.willEnableEngineHandler !== null, + handler !== null, + () => { + this.willEnableEngineHandler = handler; + }, + ); } /** @@ -225,7 +310,14 @@ class AudioDeviceModuleEventEmitter { * This handler blocks the native thread until it returns, throw to cancel audio engine's operation */ setWillStartEngineHandler(handler: AudioEngineEventHandler | null) { - this.willStartEngineHandler = handler; + this.applyHandlerActive( + 'audioDeviceModuleSetWillStartEngineActive', + this.willStartEngineHandler !== null, + handler !== null, + () => { + this.willStartEngineHandler = handler; + }, + ); } /** @@ -233,7 +325,14 @@ class AudioDeviceModuleEventEmitter { * This handler blocks the native thread until it returns, throw to cancel audio engine's operation */ setDidStopEngineHandler(handler: AudioEngineEventHandler | null) { - this.didStopEngineHandler = handler; + this.applyHandlerActive( + 'audioDeviceModuleSetDidStopEngineActive', + this.didStopEngineHandler !== null, + handler !== null, + () => { + this.didStopEngineHandler = handler; + }, + ); } /** @@ -241,7 +340,14 @@ class AudioDeviceModuleEventEmitter { * This handler blocks the native thread until it returns, throw to cancel audio engine's operation */ setDidDisableEngineHandler(handler: AudioEngineEventHandler | null) { - this.didDisableEngineHandler = handler; + this.applyHandlerActive( + 'audioDeviceModuleSetDidDisableEngineActive', + this.didDisableEngineHandler !== null, + handler !== null, + () => { + this.didDisableEngineHandler = handler; + }, + ); } /** @@ -249,7 +355,14 @@ class AudioDeviceModuleEventEmitter { * This handler blocks the native thread until it returns, throw to cancel audio engine's operation */ setWillReleaseEngineHandler(handler: AudioEngineEventNoParamsHandler | null) { - this.willReleaseEngineHandler = handler; + this.applyHandlerActive( + 'audioDeviceModuleSetWillReleaseEngineActive', + this.willReleaseEngineHandler !== null, + handler !== null, + () => { + this.willReleaseEngineHandler = handler; + }, + ); } }