Repository navigation
fix(ios): harden peer connection disposal against queued callbacks #72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,10 @@ @interface WebRTCModule () | |
|
|
||
| @property(nonatomic, strong) AudioDeviceModuleObserver *rtcAudioDeviceModuleObserver; | ||
|
|
||
| // Accessed only on the worker queue. Lifecycle requests wait for both teardown phases. | ||
| @property(nonatomic, strong) NSMutableArray<dispatch_block_t> *pendingFactoryOperations; | ||
| @property(nonatomic, assign) BOOL factoryDisposalPending; | ||
|
|
||
| @end | ||
|
|
||
| @implementation WebRTCModule | ||
|
|
@@ -120,6 +124,7 @@ - (instancetype)init { | |
| _peerConnections = [NSMutableDictionary new]; | ||
| _localStreams = [NSMutableDictionary new]; | ||
| _localTracks = [NSMutableDictionary new]; | ||
| _pendingFactoryOperations = [NSMutableArray new]; | ||
|
|
||
| dispatch_queue_attr_t attributes = | ||
| dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INITIATED, -1); | ||
|
|
@@ -201,42 +206,97 @@ - (dispatch_queue_t)methodQueue { | |
| : (RCTPromiseRejectBlock)reject) { | ||
| BOOL bypassVoiceProcessing = [options[@"bypassVoiceProcessing"] boolValue]; | ||
|
|
||
| // This makes default factory being disposed in a proper sequence. | ||
| if ([self.factoryRegistry isBareForkDefaultLive]) { | ||
| RCTLogInfo(@"createCallFactory(): tearing down stale bare-fork default (ordered) before " | ||
| "creating the call factory"); | ||
| [self disposeCurrentFactoryOrdered]; | ||
| } | ||
|
|
||
| PeerConnectionFactoryProvider *factory = [self.factoryRegistry create:bypassVoiceProcessing]; | ||
| if (factory == nil) { | ||
| reject(@"E_FACTORY_CREATE", @"Failed to create call factory: registry is disposed", nil); | ||
| return; | ||
| } | ||
| resolve(nil); | ||
| [self runFactoryOperation:^{ | ||
| void (^create)(void) = ^{ | ||
| PeerConnectionFactoryProvider *factory = [self.factoryRegistry create:bypassVoiceProcessing]; | ||
| if (factory == nil) { | ||
| reject(@"E_FACTORY_CREATE", @"Failed to create call factory: registry is disposed", nil); | ||
| return; | ||
| } | ||
| resolve(nil); | ||
| }; | ||
|
|
||
| // Tear a stale bare-fork default down in order first. The teardown is two-phase, so the new | ||
| // factory must be built from the completion — building it inline would create it while the | ||
| // old factory's PeerConnections were still alive. | ||
| if ([self.factoryRegistry isBareForkDefaultLive]) { | ||
| RCTLogInfo(@"createCallFactory(): tearing down stale bare-fork default (ordered) before " | ||
| "creating the call factory"); | ||
| [self disposeCurrentFactoryOrdered:^(BOOL disposed) { | ||
| create(); | ||
| }]; | ||
| } else { | ||
| create(); | ||
| } | ||
| }]; | ||
| } | ||
|
|
||
| RCT_EXPORT_METHOD(disposeCallFactory | ||
| : (RCTPromiseResolveBlock)resolve rejecter | ||
| : (RCTPromiseRejectBlock)reject) { | ||
| resolve(@([self disposeCurrentFactoryOrdered])); | ||
| [self runFactoryOperation:^{ | ||
| [self disposeCurrentFactoryOrdered:^(BOOL disposed) { | ||
| resolve(@(disposed)); | ||
| }]; | ||
| }]; | ||
| } | ||
|
|
||
| // Must be called on the worker queue (the module's methodQueue). | ||
| - (void)runFactoryOperation:(dispatch_block_t)operation { | ||
| [self.pendingFactoryOperations addObject:operation]; | ||
| [self drainFactoryOperations]; | ||
| } | ||
|
|
||
| - (void)drainFactoryOperations { | ||
| while (!self.factoryDisposalPending && self.pendingFactoryOperations.count > 0) { | ||
| dispatch_block_t operation = self.pendingFactoryOperations.firstObject; | ||
| [self.pendingFactoryOperations removeObjectAtIndex:0]; | ||
| operation(); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Disposes the live factory and its dependents in order: PeerConnections → local tracks → local | ||
| * streams → video-effects processor → factory + ADM. Everything is ARC-refcounted, so the factory | ||
| * is freed only when its LAST reference drops — every dependent that strong-refs it (PCs, tracks, | ||
| * streams, and the videoEffectProcessor associated object) must be released first or the factory | ||
| * leaks. No-op unless this is the last reference; returns whether it disposed the factory. | ||
| * leaks. No-op unless this is the last reference; `onDisposed` receives whether the factory was | ||
| * actually disposed. | ||
| * | ||
| * Split across two worker-queue turns: phase 1 only closes the PeerConnections, phase 2 disposes | ||
| * them and everything else. -[RTCPeerConnection close] is synchronous and its delegate callbacks | ||
| * dispatch_async onto the worker queue before it returns, so phase 2 (queued after them) runs | ||
| * only once that backlog has drained against still-registered PeerConnections. | ||
| */ | ||
| - (BOOL)disposeCurrentFactoryOrdered { | ||
| - (void)disposeCurrentFactoryOrdered:(void (^)(BOOL disposed))onDisposed { | ||
| if (![self.factoryRegistry releaseReference]) { | ||
| return NO; | ||
| onDisposed(NO); | ||
| return; | ||
| } | ||
|
|
||
| self.factoryDisposalPending = YES; | ||
| for (NSNumber *pcId in [self.peerConnections.allKeys copy]) { | ||
| @try { | ||
| [self peerConnectionClose:pcId]; | ||
| } @catch (NSException *e) { | ||
| RCTLogWarn(@"disposeCurrentFactoryOrdered(): error closing pc %@: %@", pcId, e.reason); | ||
| } | ||
| } | ||
|
|
||
| dispatch_async(self.workerQueue, ^{ | ||
| @try { | ||
| [self disposeCurrentFactoryDependents]; | ||
| onDisposed([self.factoryRegistry disposeCurrent]); | ||
|
Comment on lines
+286
to
+289
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
# Inspect WebRTCModule callers and registry creation behavior.
rg -n -C 5 --glob 'WebRTCModule*.m' \
'peerConnectionFactory|audioDeviceModule|getOrCreateDefault|peerConnectionInit|peerConnectionCreate' \
ios/RCTWebRTC
rg -n -A 25 -B 5 \
'func (create|getOrCreateDefault|isBareForkDefaultLive)\b' \
ios/RCTWebRTC/Utils/PeerConnectionFactory/PeerConnectionFactoryRegistry.swiftRepository: GetStream/react-native-webrtc Length of output: 41743 🏁 Script executed: #!/bin/bash
printf '%s\n' '--- WebRTCModule lifecycle and accessors ---'
sed -n '125,340p' ios/RCTWebRTC/WebRTCModule.m
printf '%s\n' '--- peerConnectionInit and cleanup ---'
sed -n '100,180p' ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.m
rg -n -C 8 'disposeCurrentFactoryDependents|factoryDisposalPending|runFactoryOperation|closeAll|peerConnections' ios/RCTWebRTC/WebRTCModule.m ios/RCTWebRTC/WebRTCModule*.m
printf '%s\n' '--- declarations and lifecycle callers ---'
rg -n -C 5 'createCallFactory|disposeCallFactory|factoryDisposalPending|peerConnectionInit|peerConnectionWithConfiguration' ios/RCTWebRTCRepository: GetStream/react-native-webrtc Length of output: 42342 Block peer-connection creation while factory disposal is pending.
Reject creation while 🐛 Suggested fix dispatch_sync(self.workerQueue, ^{
+ if (self.factoryDisposalPending) {
+ ret = NO;
+ return;
+ }
+
RTCMediaConstraints *constraints = [[RTCMediaConstraints alloc] initWithMandatoryConstraints:nil🤖 Prompt for AI Agents |
||
| } @finally { | ||
| self.factoryDisposalPending = NO; | ||
| [self drainFactoryOperations]; | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| - (void)disposeCurrentFactoryDependents { | ||
| for (NSNumber *pcId in [self.peerConnections.allKeys copy]) { | ||
| @try { | ||
| [self peerConnectionDispose:pcId]; | ||
| } @catch (NSException *e) { | ||
| RCTLogWarn(@"disposeCurrentFactoryOrdered(): error disposing pc %@: %@", pcId, e.reason); | ||
|
|
@@ -267,8 +327,6 @@ - (BOOL)disposeCurrentFactoryOrdered { | |
| } | ||
|
|
||
| self.videoEffectProcessor = nil; | ||
|
|
||
| return [self.factoryRegistry disposeCurrent]; | ||
| } | ||
|
|
||
| - (NSArray<NSString *> *)supportedEvents { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: GetStream/react-native-webrtc
Length of output: 42014
🏁 Script executed:
Repository: GetStream/react-native-webrtc
Length of output: 41941
🏁 Script executed:
Repository: GetStream/react-native-webrtc
Length of output: 40551
🏁 Script executed:
Repository: GetStream/react-native-webrtc
Length of output: 41632
🏁 Script executed:
Repository: GetStream/react-native-webrtc
Length of output: 11446
Reject completions while factory disposal is pending.
Factory teardown closes each registered peer connection before phase two removes it from
peerConnections. An SDP or ICE completion queued during this interval can pass the identity check and settle its promise against the closed connection.Set a per-peer-connection disposing flag before teardown calls
close, and makeRunOnLivePeerConnectionreject or skip when that flag is set.🤖 Prompt for AI Agents
Source: Learnings