Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions ios/RCTWebRTC/AudioDeviceModuleObserver.h
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
279 changes: 206 additions & 73 deletions ios/RCTWebRTC/AudioDeviceModuleObserver.m
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
#import "AudioDeviceModuleObserver.h"
#import <React/RCTLog.h>
#import <os/log.h>

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;
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -192,34 +286,73 @@ - (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();
// 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);
}
}

- (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
Expand Down
Loading