From 763127535491c341d0238cab28f7ba0a48ba8f82 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:52:30 +0800 Subject: [PATCH 1/2] fix(ios): bound AudioDeviceModuleObserver JS waits to break the deadlock The six RTCAudioDeviceModule delegate callbacks blocked the native audio thread on dispatch_semaphore_wait(..., DISPATCH_TIME_FOREVER) waiting for a JS reply. If the JS thread is parked inside a blocking-synchronous bridge call (e.g. peerConnectionAddTransceiver -> dispatch_sync(workerQueue) -> libwebrtc -> worker thread -> this delegate), the reply never arrives and the app freezes permanently (livekit/client-sdk-react-native#389, #89). Bound each wait to 2s; on timeout log via os_log and return the default 0 so the engine operation degrades gracefully instead of deadlocking. To avoid a late resolve from a timed-out round being misattributed to the next round (cross-round response aliasing), stamp every event with a monotonic requestId that JS echoes back; the observer only accepts a resolve whose id matches the in-flight round. A pre-send drain backstops the narrow case where a matching resolve signals just past its round's deadline. --- ios/RCTWebRTC/AudioDeviceModuleObserver.h | 15 +- ios/RCTWebRTC/AudioDeviceModuleObserver.m | 273 +++++++++++++----- .../WebRTCModule+RTCAudioDeviceModule.m | 36 ++- src/AudioDeviceModuleEvents.ts | 38 ++- 4 files changed, 258 insertions(+), 104 deletions(-) diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.h b/ios/RCTWebRTC/AudioDeviceModuleObserver.h index a1f415909..16f61851d 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.h +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.h @@ -7,13 +7,14 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)initWithWebRTCModule:(WebRTCModule *)module; -// Methods to receive results from JS -- (void)resolveEngineCreatedWithResult:(NSInteger)result; -- (void)resolveWillEnableEngineWithResult:(NSInteger)result; -- (void)resolveWillStartEngineWithResult:(NSInteger)result; -- (void)resolveDidStopEngineWithResult:(NSInteger)result; -- (void)resolveDidDisableEngineWithResult:(NSInteger)result; -- (void)resolveWillReleaseEngineWithResult:(NSInteger)result; +// 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; +- (void)resolveWillEnableEngineWithRequestId:(NSInteger)requestId result:(NSInteger)result; +- (void)resolveWillStartEngineWithRequestId:(NSInteger)requestId result:(NSInteger)result; +- (void)resolveDidStopEngineWithRequestId:(NSInteger)requestId result:(NSInteger)result; +- (void)resolveDidDisableEngineWithRequestId:(NSInteger)requestId result:(NSInteger)result; +- (void)resolveWillReleaseEngineWithRequestId:(NSInteger)requestId result:(NSInteger)result; @end diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.m b/ios/RCTWebRTC/AudioDeviceModuleObserver.m index b619a5c37..539fed6bb 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.m +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.m @@ -1,8 +1,34 @@ #import "AudioDeviceModuleObserver.h" #import +#import NS_ASSUME_NONNULL_BEGIN +// Upper bound on how long a delegate callback parks the native audio thread +// waiting for JS to respond. The wait used to be DISPATCH_TIME_FOREVER, which +// deadlocks the app if the JS thread is itself blocked inside a synchronous +// bridge call (e.g. peerConnectionAddTransceiver) that is transitively waiting +// on this same audio operation. Bounding the wait turns that permanent freeze +// into a recoverable stall: on timeout we return the default 0 ("proceed") so +// the engine operation degrades gracefully. +// +// Caveat for willEnableEngine: returning 0 here lets the engine proceed even +// though the JS handler (which configures/activates the AVAudioSession) never +// completed. libwebrtc re-validates the session *category* after this callback +// but not that it was *activated*, so a timeout on willEnableEngine can start +// the engine against an unconfigured session. That degraded-but-recoverable +// outcome is deliberately preferred over the unrecoverable deadlock. +static const int64_t kJSResponseTimeoutSeconds = 2; + +static os_log_t ADMObserverLog(void) { + static os_log_t log; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + log = os_log_create("com.livekit.react-native-webrtc", "AudioDeviceModuleObserver"); + }); + return log; +} + @interface AudioDeviceModuleObserver () @property(weak, nonatomic) WebRTCModule *module; @@ -20,6 +46,15 @@ @interface AudioDeviceModuleObserver () @property(nonatomic, assign) NSInteger didDisableEngineResult; @property(nonatomic, assign) NSInteger willReleaseEngineResult; +// Monotonic id stamped on every event sent to JS, and the id of the round +// currently being awaited (0 = none). JS echoes the id back when it resolves; +// the observer only accepts a resolve whose id matches the in-flight round, so a +// late resolve from a round that already timed out cannot be misattributed to +// the next round. Both are guarded by @synchronized(self) since the send side +// runs on the native audio thread and the resolve side on the JS thread. +@property(nonatomic, assign) NSInteger requestIdSeq; +@property(nonatomic, assign) NSInteger awaitingRequestId; + @end @implementation AudioDeviceModuleObserver @@ -38,6 +73,57 @@ - (instancetype)initWithWebRTCModule:(WebRTCModule *)module { return self; } +#pragma mark - Bounded JS round-trip + +// Sends an event to JS and blocks the calling (native audio) thread until JS +// resolves the matching semaphore or kJSResponseTimeoutSeconds elapses. +// +// Each round is tagged with a unique requestId that JS echoes back on resolve; +// -resolveRequestId:... drops any resolve whose id does not match the in-flight +// round, so a late resolve from a previously timed-out round cannot signal this +// round's semaphore. The pre-send drain below is the remaining safety net: it +// clears a stray signal from the narrow case where a matching resolve raced the +// previous round past its timeout deadline (signalling after that round had +// 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. +- (NSInteger)sendEventAndWaitWithName:(NSString *)eventName + body:(NSDictionary *)body + semaphore:(dispatch_semaphore_t)semaphore + resultBlock:(NSInteger (^)(void))resultBlock { + NSInteger requestId; + @synchronized(self) { + requestId = ++self.requestIdSeq; + self.awaitingRequestId = requestId; + } + + while (dispatch_semaphore_wait(semaphore, DISPATCH_TIME_NOW) == 0) { + // Drain a stray signal left by a resolve that raced the previous round's timeout. + } + + NSMutableDictionary *payload = [body mutableCopy]; + payload[@"requestId"] = @(requestId); + [self.module sendEventWithName:eventName body:payload]; + + dispatch_time_t deadline = dispatch_time(DISPATCH_TIME_NOW, kJSResponseTimeoutSeconds * NSEC_PER_SEC); + if (dispatch_semaphore_wait(semaphore, deadline) != 0) { + @synchronized(self) { + // Stop accepting this round's resolve; if it arrives now it is stale. + if (self.awaitingRequestId == requestId) { + self.awaitingRequestId = 0; + } + } + os_log_error(ADMObserverLog(), + "Timed out after %llds waiting for JS to respond to %{public}@; returning default 0", + (long long)kJSResponseTimeoutSeconds, + eventName); + return 0; + } + + return resultBlock(); +} + #pragma mark - RTCAudioDeviceModuleDelegate - (void)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule @@ -55,13 +141,15 @@ - (void)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule didCreateEngine:(AVAudioEngine *)engine { RCTLog(@"[AudioDeviceModuleObserver] Engine created - waiting for JS response"); - [self.module sendEventWithName:kEventAudioDeviceModuleEngineCreated body:@{}]; - - // Wait indefinitely for JS to respond - dispatch_semaphore_wait(self.engineCreatedSemaphore, DISPATCH_TIME_FOREVER); + NSInteger result = [self sendEventAndWaitWithName:kEventAudioDeviceModuleEngineCreated + body:@{} + semaphore:self.engineCreatedSemaphore + resultBlock:^NSInteger { + return self.engineCreatedResult; + }]; - RCTLog(@"[AudioDeviceModuleObserver] Engine created - JS returned: %ld", (long)self.engineCreatedResult); - return self.engineCreatedResult; + RCTLog(@"[AudioDeviceModuleObserver] Engine created - JS returned: %ld", (long)result); + return result; } - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule @@ -72,21 +160,22 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule isPlayoutEnabled, isRecordingEnabled); - [self.module sendEventWithName:kEventAudioDeviceModuleEngineWillEnable - body:@{ - @"isPlayoutEnabled" : @(isPlayoutEnabled), - @"isRecordingEnabled" : @(isRecordingEnabled), - }]; + NSInteger result = [self sendEventAndWaitWithName:kEventAudioDeviceModuleEngineWillEnable + body:@{ + @"isPlayoutEnabled" : @(isPlayoutEnabled), + @"isRecordingEnabled" : @(isRecordingEnabled), + } + semaphore:self.willEnableEngineSemaphore + resultBlock:^NSInteger { + return self.willEnableEngineResult; + }]; - // Wait indefinitely for JS to respond - dispatch_semaphore_wait(self.willEnableEngineSemaphore, DISPATCH_TIME_FOREVER); - - RCTLog(@"[AudioDeviceModuleObserver] Engine will enable - JS returned: %ld", (long)self.willEnableEngineResult); + RCTLog(@"[AudioDeviceModuleObserver] Engine will enable - JS returned: %ld", (long)result); AVAudioSession *audioSession = [AVAudioSession sharedInstance]; RCTLog(@"[AudioDeviceModuleObserver] Audio session category: %@", audioSession.category); - return self.willEnableEngineResult; + return result; } - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule @@ -97,17 +186,18 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule isPlayoutEnabled, isRecordingEnabled); - [self.module sendEventWithName:kEventAudioDeviceModuleEngineWillStart - body:@{ - @"isPlayoutEnabled" : @(isPlayoutEnabled), - @"isRecordingEnabled" : @(isRecordingEnabled), - }]; - - // Wait indefinitely for JS to respond - dispatch_semaphore_wait(self.willStartEngineSemaphore, DISPATCH_TIME_FOREVER); - - RCTLog(@"[AudioDeviceModuleObserver] Engine will start - JS returned: %ld", (long)self.willStartEngineResult); - return self.willStartEngineResult; + NSInteger result = [self sendEventAndWaitWithName:kEventAudioDeviceModuleEngineWillStart + body:@{ + @"isPlayoutEnabled" : @(isPlayoutEnabled), + @"isRecordingEnabled" : @(isRecordingEnabled), + } + semaphore:self.willStartEngineSemaphore + resultBlock:^NSInteger { + return self.willStartEngineResult; + }]; + + RCTLog(@"[AudioDeviceModuleObserver] Engine will start - JS returned: %ld", (long)result); + return result; } - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule @@ -118,17 +208,18 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule isPlayoutEnabled, isRecordingEnabled); - [self.module sendEventWithName:kEventAudioDeviceModuleEngineDidStop - body:@{ - @"isPlayoutEnabled" : @(isPlayoutEnabled), - @"isRecordingEnabled" : @(isRecordingEnabled), - }]; - - // Wait indefinitely for JS to respond - dispatch_semaphore_wait(self.didStopEngineSemaphore, DISPATCH_TIME_FOREVER); - - RCTLog(@"[AudioDeviceModuleObserver] Engine did stop - JS returned: %ld", (long)self.didStopEngineResult); - return self.didStopEngineResult; + NSInteger result = [self sendEventAndWaitWithName:kEventAudioDeviceModuleEngineDidStop + body:@{ + @"isPlayoutEnabled" : @(isPlayoutEnabled), + @"isRecordingEnabled" : @(isRecordingEnabled), + } + semaphore:self.didStopEngineSemaphore + resultBlock:^NSInteger { + return self.didStopEngineResult; + }]; + + RCTLog(@"[AudioDeviceModuleObserver] Engine did stop - JS returned: %ld", (long)result); + return result; } - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule @@ -139,29 +230,32 @@ - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule isPlayoutEnabled, isRecordingEnabled); - [self.module sendEventWithName:kEventAudioDeviceModuleEngineDidDisable - body:@{ - @"isPlayoutEnabled" : @(isPlayoutEnabled), - @"isRecordingEnabled" : @(isRecordingEnabled), - }]; - - // Wait indefinitely for JS to respond - dispatch_semaphore_wait(self.didDisableEngineSemaphore, DISPATCH_TIME_FOREVER); - - RCTLog(@"[AudioDeviceModuleObserver] Engine did disable - JS returned: %ld", (long)self.didDisableEngineResult); - return self.didDisableEngineResult; + NSInteger result = [self sendEventAndWaitWithName:kEventAudioDeviceModuleEngineDidDisable + body:@{ + @"isPlayoutEnabled" : @(isPlayoutEnabled), + @"isRecordingEnabled" : @(isRecordingEnabled), + } + semaphore:self.didDisableEngineSemaphore + resultBlock:^NSInteger { + return self.didDisableEngineResult; + }]; + + 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"); - [self.module sendEventWithName:kEventAudioDeviceModuleEngineWillRelease body:@{}]; - - // Wait indefinitely for JS to respond - dispatch_semaphore_wait(self.willReleaseEngineSemaphore, DISPATCH_TIME_FOREVER); + NSInteger result = [self sendEventAndWaitWithName:kEventAudioDeviceModuleEngineWillRelease + body:@{} + semaphore:self.willReleaseEngineSemaphore + resultBlock:^NSInteger { + return self.willReleaseEngineResult; + }]; - RCTLog(@"[AudioDeviceModuleObserver] Engine will release - JS returned: %ld", (long)self.willReleaseEngineResult); - return self.willReleaseEngineResult; + RCTLog(@"[AudioDeviceModuleObserver] Engine will release - JS returned: %ld", (long)result); + return result; } - (NSInteger)audioDeviceModule:(RTCAudioDeviceModule *)audioDeviceModule @@ -192,34 +286,67 @@ - (void)audioDeviceModuleDidUpdateDevices:(RTCAudioDeviceModule *)audioDeviceMod #pragma mark - Resolve methods from JS -- (void)resolveEngineCreatedWithResult:(NSInteger)result { - self.engineCreatedResult = result; - dispatch_semaphore_signal(self.engineCreatedSemaphore); +// Applies a JS response only if its requestId matches the round currently being +// awaited. A non-matching id means the round already timed out and moved on, so +// the response is dropped without touching the result or signalling — preventing +// a stale value from being handed to a later round. +- (void)resolveRequestId:(NSInteger)requestId store:(void (^)(void))store semaphore:(dispatch_semaphore_t)semaphore { + @synchronized(self) { + if (requestId != self.awaitingRequestId) { + return; + } + self.awaitingRequestId = 0; + store(); + } + dispatch_semaphore_signal(semaphore); +} + +- (void)resolveEngineCreatedWithRequestId:(NSInteger)requestId result:(NSInteger)result { + [self resolveRequestId:requestId + store:^{ + self.engineCreatedResult = result; + } + semaphore:self.engineCreatedSemaphore]; } -- (void)resolveWillEnableEngineWithResult:(NSInteger)result { - self.willEnableEngineResult = result; - dispatch_semaphore_signal(self.willEnableEngineSemaphore); +- (void)resolveWillEnableEngineWithRequestId:(NSInteger)requestId result:(NSInteger)result { + [self resolveRequestId:requestId + store:^{ + self.willEnableEngineResult = result; + } + semaphore:self.willEnableEngineSemaphore]; } -- (void)resolveWillStartEngineWithResult:(NSInteger)result { - self.willStartEngineResult = result; - dispatch_semaphore_signal(self.willStartEngineSemaphore); +- (void)resolveWillStartEngineWithRequestId:(NSInteger)requestId result:(NSInteger)result { + [self resolveRequestId:requestId + store:^{ + self.willStartEngineResult = result; + } + semaphore:self.willStartEngineSemaphore]; } -- (void)resolveDidStopEngineWithResult:(NSInteger)result { - self.didStopEngineResult = result; - dispatch_semaphore_signal(self.didStopEngineSemaphore); +- (void)resolveDidStopEngineWithRequestId:(NSInteger)requestId result:(NSInteger)result { + [self resolveRequestId:requestId + store:^{ + self.didStopEngineResult = result; + } + semaphore:self.didStopEngineSemaphore]; } -- (void)resolveDidDisableEngineWithResult:(NSInteger)result { - self.didDisableEngineResult = result; - dispatch_semaphore_signal(self.didDisableEngineSemaphore); +- (void)resolveDidDisableEngineWithRequestId:(NSInteger)requestId result:(NSInteger)result { + [self resolveRequestId:requestId + store:^{ + self.didDisableEngineResult = result; + } + semaphore:self.didDisableEngineSemaphore]; } -- (void)resolveWillReleaseEngineWithResult:(NSInteger)result { - self.willReleaseEngineResult = result; - dispatch_semaphore_signal(self.willReleaseEngineSemaphore); +- (void)resolveWillReleaseEngineWithRequestId:(NSInteger)requestId result:(NSInteger)result { + [self resolveRequestId:requestId + store:^{ + self.willReleaseEngineResult = result; + } + semaphore:self.willReleaseEngineSemaphore]; } @end diff --git a/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m b/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m index fad0f7c3f..0aa296bb9 100644 --- a/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m +++ b/ios/RCTWebRTC/WebRTCModule+RTCAudioDeviceModule.m @@ -229,33 +229,45 @@ @implementation WebRTCModule (RTCAudioDeviceModule) #pragma mark - Observer Delegate Response Methods -RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleResolveEngineCreated : (NSInteger)result) { - [self.audioDeviceModuleObserver resolveEngineCreatedWithResult:result]; +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleResolveEngineCreated + : (NSInteger)requestId result + : (NSInteger)result) { + [self.audioDeviceModuleObserver resolveEngineCreatedWithRequestId:requestId result:result]; return nil; } -RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleResolveWillEnableEngine : (NSInteger)result) { - [self.audioDeviceModuleObserver resolveWillEnableEngineWithResult:result]; +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleResolveWillEnableEngine + : (NSInteger)requestId result + : (NSInteger)result) { + [self.audioDeviceModuleObserver resolveWillEnableEngineWithRequestId:requestId result:result]; return nil; } -RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleResolveWillStartEngine : (NSInteger)result) { - [self.audioDeviceModuleObserver resolveWillStartEngineWithResult:result]; +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleResolveWillStartEngine + : (NSInteger)requestId result + : (NSInteger)result) { + [self.audioDeviceModuleObserver resolveWillStartEngineWithRequestId:requestId result:result]; return nil; } -RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleResolveDidStopEngine : (NSInteger)result) { - [self.audioDeviceModuleObserver resolveDidStopEngineWithResult:result]; +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleResolveDidStopEngine + : (NSInteger)requestId result + : (NSInteger)result) { + [self.audioDeviceModuleObserver resolveDidStopEngineWithRequestId:requestId result:result]; return nil; } -RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleResolveDidDisableEngine : (NSInteger)result) { - [self.audioDeviceModuleObserver resolveDidDisableEngineWithResult:result]; +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleResolveDidDisableEngine + : (NSInteger)requestId result + : (NSInteger)result) { + [self.audioDeviceModuleObserver resolveDidDisableEngineWithRequestId:requestId result:result]; return nil; } -RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleResolveWillReleaseEngine : (NSInteger)result) { - [self.audioDeviceModuleObserver resolveWillReleaseEngineWithResult:result]; +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(audioDeviceModuleResolveWillReleaseEngine + : (NSInteger)requestId result + : (NSInteger)result) { + [self.audioDeviceModuleObserver resolveWillReleaseEngineWithRequestId:requestId result:result]; return nil; } diff --git a/src/AudioDeviceModuleEvents.ts b/src/AudioDeviceModuleEvents.ts index 5c63c212e..f830a1a46 100644 --- a/src/AudioDeviceModuleEvents.ts +++ b/src/AudioDeviceModuleEvents.ts @@ -15,6 +15,18 @@ export interface EngineStateEventData { isRecordingEnabled: boolean; } +/** + * Raw native event payload. Every engine event carries a `requestId` that must + * be echoed back to the matching resolve call so the native side can drop a + * response from a round that already timed out. This id is internal and is not + * surfaced to app-registered handlers. + */ +interface EngineEventPayload { + requestId: number; +} + +interface EngineStateEventPayload extends EngineEventPayload, EngineStateEventData {} + export type AudioDeviceModuleEventType = | 'speechActivity' | 'devicesUpdated'; @@ -50,7 +62,8 @@ class AudioDeviceModuleEventEmitter { public setupListeners() { if (Platform.OS !== 'android' && WebRTCModule) { // Setup handlers for blocking delegate methods - addListener(this, 'audioDeviceModuleEngineCreated', async () => { + addListener(this, 'audioDeviceModuleEngineCreated', async (event: unknown) => { + const { requestId } = event as EngineEventPayload; let result = 0; if (this.engineCreatedHandler) { @@ -62,14 +75,14 @@ class AudioDeviceModuleEventEmitter { } } - WebRTCModule.audioDeviceModuleResolveEngineCreated(result); + WebRTCModule.audioDeviceModuleResolveEngineCreated(requestId, result); }); addListener( this, 'audioDeviceModuleEngineWillEnable', async (event: unknown) => { - const { isPlayoutEnabled, isRecordingEnabled } = event as EngineStateEventData; + const { requestId, isPlayoutEnabled, isRecordingEnabled } = event as EngineStateEventPayload; let result = 0; if (this.willEnableEngineHandler) { @@ -81,7 +94,7 @@ class AudioDeviceModuleEventEmitter { } } - WebRTCModule.audioDeviceModuleResolveWillEnableEngine(result); + WebRTCModule.audioDeviceModuleResolveWillEnableEngine(requestId, result); }, ); @@ -89,7 +102,7 @@ class AudioDeviceModuleEventEmitter { this, 'audioDeviceModuleEngineWillStart', async (event: unknown) => { - const { isPlayoutEnabled, isRecordingEnabled } = event as EngineStateEventData; + const { requestId, isPlayoutEnabled, isRecordingEnabled } = event as EngineStateEventPayload; let result = 0; if (this.willStartEngineHandler) { @@ -101,7 +114,7 @@ class AudioDeviceModuleEventEmitter { } } - WebRTCModule.audioDeviceModuleResolveWillStartEngine(result); + WebRTCModule.audioDeviceModuleResolveWillStartEngine(requestId, result); }, ); @@ -109,7 +122,7 @@ class AudioDeviceModuleEventEmitter { this, 'audioDeviceModuleEngineDidStop', async (event: unknown) => { - const { isPlayoutEnabled, isRecordingEnabled } = event as EngineStateEventData; + const { requestId, isPlayoutEnabled, isRecordingEnabled } = event as EngineStateEventPayload; let result = 0; if (this.didStopEngineHandler) { @@ -121,7 +134,7 @@ class AudioDeviceModuleEventEmitter { } } - WebRTCModule.audioDeviceModuleResolveDidStopEngine(result); + WebRTCModule.audioDeviceModuleResolveDidStopEngine(requestId, result); }, ); @@ -129,7 +142,7 @@ class AudioDeviceModuleEventEmitter { this, 'audioDeviceModuleEngineDidDisable', async (event: unknown) => { - const { isPlayoutEnabled, isRecordingEnabled } = event as EngineStateEventData; + const { requestId, isPlayoutEnabled, isRecordingEnabled } = event as EngineStateEventPayload; let result = 0; if (this.didDisableEngineHandler) { @@ -141,11 +154,12 @@ class AudioDeviceModuleEventEmitter { } } - WebRTCModule.audioDeviceModuleResolveDidDisableEngine(result); + WebRTCModule.audioDeviceModuleResolveDidDisableEngine(requestId, result); }, ); - addListener(this, 'audioDeviceModuleEngineWillRelease', async () => { + addListener(this, 'audioDeviceModuleEngineWillRelease', async (event: unknown) => { + const { requestId } = event as EngineEventPayload; let result = 0; if (this.willReleaseEngineHandler) { @@ -157,7 +171,7 @@ class AudioDeviceModuleEventEmitter { } } - WebRTCModule.audioDeviceModuleResolveWillReleaseEngine(result); + WebRTCModule.audioDeviceModuleResolveWillReleaseEngine(requestId, result); }); } } From c1b16c5c7088c9b1d88bfd2f176933faad34f9ad Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:34:29 +0800 Subject: [PATCH 2/2] fix(ios): signal resolve semaphore inside the lock to prevent cross-round misattribution dispatch_semaphore_signal ran after the @synchronized block in resolveRequestId. A resolve preempted between releasing the lock and signalling could, across a round boundary, wake the next round and hand it the previous round's result (and drop the previous round's real result in favor of the timeout default). Moving the signal inside the lock guarantees it is posted before the next round's send-side critical section starts, so the pre-send drain reliably clears any stray signal and a stale result can never be misattributed to a later round. --- ios/RCTWebRTC/AudioDeviceModuleObserver.m | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ios/RCTWebRTC/AudioDeviceModuleObserver.m b/ios/RCTWebRTC/AudioDeviceModuleObserver.m index 539fed6bb..a12b59772 100644 --- a/ios/RCTWebRTC/AudioDeviceModuleObserver.m +++ b/ios/RCTWebRTC/AudioDeviceModuleObserver.m @@ -297,8 +297,14 @@ - (void)resolveRequestId:(NSInteger)requestId store:(void (^)(void))store semaph } self.awaitingRequestId = 0; store(); + // Signal inside the lock so this signal is always posted before the next + // round's send-side @synchronized block can start. Otherwise a resolve + // preempted between releasing the lock and signalling could, across a round + // boundary, wake the next round and hand it this round's result. Keeping it + // inside the lock also lets the next round's pre-send drain reliably clear + // any stray signal left by a resolve that raced its own timeout. + dispatch_semaphore_signal(semaphore); } - dispatch_semaphore_signal(semaphore); } - (void)resolveEngineCreatedWithRequestId:(NSInteger)requestId result:(NSInteger)result {