From 83aef828fe67f8c25a318fb7dd86d275dd5b255d Mon Sep 17 00:00:00 2001 From: Artem Grintsevich Date: Thu, 13 Aug 2026 16:00:40 +0200 Subject: [PATCH 1/5] chore: aligned disposal pc factory disposal implementation --- .../PeerConnectionFactoryRegistry.swift | 35 ++++++++++-- ios/RCTWebRTC/WebRTCModule+RTCMediaStream.h | 2 + .../WebRTCModule+RTCPeerConnection.h | 3 + ios/RCTWebRTC/WebRTCModule.m | 56 ++++++++++++++++++- 4 files changed, 89 insertions(+), 7 deletions(-) diff --git a/ios/RCTWebRTC/Utils/PeerConnectionFactory/PeerConnectionFactoryRegistry.swift b/ios/RCTWebRTC/Utils/PeerConnectionFactory/PeerConnectionFactoryRegistry.swift index 08504b311..7c2d1d00c 100644 --- a/ios/RCTWebRTC/Utils/PeerConnectionFactory/PeerConnectionFactoryRegistry.swift +++ b/ios/RCTWebRTC/Utils/PeerConnectionFactory/PeerConnectionFactoryRegistry.swift @@ -65,6 +65,16 @@ public typealias PeerConnectionFactoryBuilder = (_ factoryId: String, _ bypassVo return currentFactory } + /// True when the live factory is the lazily-built bare-fork default (no per-call factory has + /// taken its place). Lets the module tear a stale default down in order before building the + /// call factory, matching the Android registry. + @objc public func isBareForkDefaultLive() -> Bool { + lock.lock() + defer { lock.unlock() } + guard let currentFactory = currentFactory, !currentFactory.isDisposed() else { return false } + return currentIsBareForkDefault + } + @objc public func create(_ bypassVoiceProcessing: Bool) -> PeerConnectionFactoryProvider? { lock.lock() defer { lock.unlock() } @@ -108,22 +118,35 @@ public typealias PeerConnectionFactoryBuilder = (_ factoryId: String, _ bypassVo return factory } - /// Releases one consumer's reference to the live call factory. Actually disposes only when - /// the LAST reference is released; when other concurrent-call consumers still - /// hold it, it decrements and keeps the factory alive. Also false when nothing is live. - @objc public func disposeCurrent() -> Bool { + /// Releases one consumer's reference to the live call factory. Returns true only when the LAST + /// reference is released; when other concurrent-call consumers still hold it, it decrements and + /// keeps the factory alive. Also false when nothing is live. + @objc public func releaseReference() -> Bool { lock.lock() defer { lock.unlock() } guard let factory = currentFactory else { - NSLog("[PCFactoryRegistry] disposeCurrent(): no live factory (already disposed?)") return false } if currentRefCount > 1 { currentRefCount -= 1 - NSLog("[PCFactoryRegistry] disposeCurrent(): factory %@ still shared; kept (refCount=%d)", + NSLog("[PCFactoryRegistry] releaseReference(): factory %@ still shared; kept (refCount=%d)", factory.factoryId, currentRefCount) return false } + currentRefCount = 0 + return true + } + + /// Disposes the live factory + its ADM unconditionally and returns whether one was disposed. + /// Reference counting is handled by `releaseReference()`, which must be called first on the + /// leave / dispose path; this only performs the final teardown once the last reference is gone. + @objc public func disposeCurrent() -> Bool { + lock.lock() + defer { lock.unlock() } + guard let factory = currentFactory else { + NSLog("[PCFactoryRegistry] disposeCurrent(): no live factory (already disposed?)") + return false + } let wasDefault = currentIsBareForkDefault let factoryId = factory.factoryId factory.dispose() diff --git a/ios/RCTWebRTC/WebRTCModule+RTCMediaStream.h b/ios/RCTWebRTC/WebRTCModule+RTCMediaStream.h index 082ea1709..97483b9e4 100644 --- a/ios/RCTWebRTC/WebRTCModule+RTCMediaStream.h +++ b/ios/RCTWebRTC/WebRTCModule+RTCMediaStream.h @@ -14,4 +14,6 @@ - (void)removeLocalVideoTrackDimensionDetection:(RTCVideoTrack *)videoTrack; - (RTCMediaStreamTrack *)trackForId:(nonnull NSString *)trackId pcId:(nonnull NSNumber *)pcId; + +- (void)mediaStreamTrackRelease:(nonnull NSString *)trackID; @end \ No newline at end of file diff --git a/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.h b/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.h index 6c4b38b96..60099a12d 100644 --- a/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.h +++ b/ios/RCTWebRTC/WebRTCModule+RTCPeerConnection.h @@ -16,4 +16,7 @@ + (RTCCertificate *)getCertificate:(NSString *)certId; +- (void)peerConnectionClose:(nonnull NSNumber *)objectID; +- (void)peerConnectionDispose:(nonnull NSNumber *)objectID; + @end diff --git a/ios/RCTWebRTC/WebRTCModule.m b/ios/RCTWebRTC/WebRTCModule.m index f932d85d7..2d994cbb2 100644 --- a/ios/RCTWebRTC/WebRTCModule.m +++ b/ios/RCTWebRTC/WebRTCModule.m @@ -9,6 +9,7 @@ #import "AudioDeviceModuleObserver.h" #import "RTCCameraPreviewViewManager.h" +#import "WebRTCModule+RTCMediaStream.h" #import "WebRTCModule+RTCPeerConnection.h" #import "WebRTCModule.h" #import "WebRTCModuleOptions.h" @@ -199,6 +200,14 @@ - (dispatch_queue_t)methodQueue { : (RCTPromiseResolveBlock)resolve rejecter : (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); @@ -210,7 +219,52 @@ - (dispatch_queue_t)methodQueue { RCT_EXPORT_METHOD(disposeCallFactory : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { - resolve(@([self.factoryRegistry disposeCurrent])); + resolve(@([self disposeCurrentFactoryOrdered])); +} + +/** + * Disposes the live factory in order — PeerConnections, then local tracks, then the factory + its + * ADM — and returns whether a factory was disposed. An RTCPeerConnectionFactory must not be released + * while PeerConnections or tracks created from it are still alive (use-after-free in libwebrtc), so + * its dependents are torn down first. Shared by disposeCallFactory (leave) and createCallFactory + * (replacing a stale bare-fork default at join), mirroring Android's disposeCurrentFactoryOrdered. + * + * Reference-counted: when the factory is shared across concurrent calls, only the LAST consumer's + * release actually tears it down (releaseReference returns NO for earlier releases). On iOS the + * module's own peerConnections / localTracks maps are the factory's ownership registry — only one + * factory is ever live — so every remaining entry belongs to the factory being disposed. A leaving + * call's own PCs/tracks were already released by its leave() before this runs. + * + * Runs on the module's serial worker queue (methodQueue), so the reused peerConnectionClose/ + * peerConnectionDispose/mediaStreamTrackRelease calls execute synchronously on the same thread. + */ +- (BOOL)disposeCurrentFactoryOrdered { + if (![self.factoryRegistry releaseReference]) { + return NO; + } + + // 1. Close + dispose the factory's PeerConnections first. + for (NSNumber *pcId in [self.peerConnections.allKeys copy]) { + @try { + [self peerConnectionClose:pcId]; + [self peerConnectionDispose:pcId]; + } @catch (NSException *e) { + RCTLogWarn(@"disposeCurrentFactoryOrdered(): error disposing pc %@: %@", pcId, e.reason); + } + } + + // 2. Stop capture + release owned local tracks (e.g. a camera capturer adopted from the lobby + // preview) so the AVCaptureSession is torn down before the factory's video sources are freed. + for (NSString *trackId in [self.localTracks.allKeys copy]) { + @try { + [self mediaStreamTrackRelease:trackId]; + } @catch (NSException *e) { + RCTLogWarn(@"disposeCurrentFactoryOrdered(): error disposing track %@: %@", trackId, e.reason); + } + } + + // 3. Now it is safe to dispose the factory + its ADM. + return [self.factoryRegistry disposeCurrent]; } - (NSArray *)supportedEvents { From ad341cd16591161cb9c55987b5f670f4fe804db8 Mon Sep 17 00:00:00 2001 From: Artem Grintsevich Date: Thu, 13 Aug 2026 19:36:29 +0200 Subject: [PATCH 2/5] chore: fixed disposal memory leaks --- ios/RCTWebRTC/WebRTCModule+RTCMediaStream.h | 1 + ios/RCTWebRTC/WebRTCModule.m | 24 +++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/ios/RCTWebRTC/WebRTCModule+RTCMediaStream.h b/ios/RCTWebRTC/WebRTCModule+RTCMediaStream.h index 97483b9e4..f4f318c22 100644 --- a/ios/RCTWebRTC/WebRTCModule+RTCMediaStream.h +++ b/ios/RCTWebRTC/WebRTCModule+RTCMediaStream.h @@ -16,4 +16,5 @@ - (RTCMediaStreamTrack *)trackForId:(nonnull NSString *)trackId pcId:(nonnull NSNumber *)pcId; - (void)mediaStreamTrackRelease:(nonnull NSString *)trackID; +- (void)mediaStreamRelease:(nonnull NSString *)streamID; @end \ No newline at end of file diff --git a/ios/RCTWebRTC/WebRTCModule.m b/ios/RCTWebRTC/WebRTCModule.m index 2d994cbb2..11dd18bb4 100644 --- a/ios/RCTWebRTC/WebRTCModule.m +++ b/ios/RCTWebRTC/WebRTCModule.m @@ -263,6 +263,30 @@ - (BOOL)disposeCurrentFactoryOrdered { } } + // 2b. Release local streams. An RTCMediaStream strong-refs its tracks, and every track (and the + // video/audio source behind it) strong-refs the RTCPeerConnectionFactory — so a leftover stream + // transitively pins the factory even after the tracks are gone from localTracks. Drop the + // stream's track refs, then the stream itself, so nothing keeps the factory alive. + for (NSString *streamId in [self.localStreams.allKeys copy]) { + @try { + RTCMediaStream *stream = self.localStreams[streamId]; + for (RTCAudioTrack *t in [stream.audioTracks copy]) { + [stream removeAudioTrack:t]; + } + for (RTCVideoTrack *t in [stream.videoTracks copy]) { + [stream removeVideoTrack:t]; + } + [self mediaStreamRelease:streamId]; + } @catch (NSException *e) { + RCTLogWarn(@"disposeCurrentFactoryOrdered(): error disposing stream %@: %@", streamId, e.reason); + } + } + + // 2c. Drop the video-effects processor. It is retained by the module via an OBJC_ASSOCIATION_RETAIN + // associated object and strong-refs the RTCVideoSource (background-blur pipeline), which strong-refs + // the factory. Nothing else clears it on leave, so it independently pins the factory across calls. + self.videoEffectProcessor = nil; + // 3. Now it is safe to dispose the factory + its ADM. return [self.factoryRegistry disposeCurrent]; } From d9515a3793ef744c1b152f37c3b4265dcc973584 Mon Sep 17 00:00:00 2001 From: Artem Grintsevich Date: Fri, 14 Aug 2026 10:52:16 +0200 Subject: [PATCH 3/5] chore: dispose media stream as video track strong reference holders --- .../com/oney/WebRTCModule/WebRTCModule.java | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java b/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java index 36051cbd3..377ec386f 100644 --- a/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java +++ b/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java @@ -186,7 +186,23 @@ private boolean disposeCurrentFactoryOrdered() { } } - // 2. Stop + dispose owned tracks (e.g. a camera capturer adopted from the lobby + // 2. Release local streams BEFORE their tracks (mirrors invalidate()): detach each track + // while it is still valid, then dispose the stream. Doing this after track disposal would + // touch already-freed native tracks. localStreams is otherwise only cleared in invalidate(), + // so leftover MediaStream / VideoTrack objects accumulate across join/leave cycles. + for (Map.Entry entry : localStreams.entrySet()) { + try { + MediaStream stream = entry.getValue(); + for (AudioTrack t : new ArrayList<>(stream.audioTracks)) stream.removeTrack(t); + for (VideoTrack t : new ArrayList<>(stream.videoTracks)) stream.removeTrack(t); + stream.dispose(); + } catch (Exception e) { + Log.w(TAG, "disposeCurrentFactoryOrdered(): error disposing stream " + entry.getKey(), e); + } + } + localStreams.clear(); + + // 3. Stop + dispose owned tracks (e.g. a camera capturer adopted from the lobby // preview) so the camera2 session is fully closed before the VideoSources are freed. for (String trackId : factoryRegistry.currentOwnedTrackIds()) { try { @@ -196,7 +212,7 @@ private boolean disposeCurrentFactoryOrdered() { } } - // 3. Now it is safe to dispose the factory + its ADM. + // 4. Now it is safe to dispose the factory + its ADM. return factoryRegistry.disposeCurrent(); } From a9998cbbe20552ee6fd6932c56fca276e281b949 Mon Sep 17 00:00:00 2001 From: Artem Grintsevich Date: Fri, 14 Aug 2026 12:33:31 +0200 Subject: [PATCH 4/5] chore: adjusted disposal order --- .../com/oney/WebRTCModule/WebRTCModule.java | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java b/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java index 377ec386f..d01a47a70 100644 --- a/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java +++ b/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java @@ -172,7 +172,23 @@ private boolean disposeCurrentFactoryOrdered() { return false; } - // 1. Dispose the factory's PeerConnections first. + // 1. Detach + dispose local streams first, before PCs and owned tracks. A stream may hold a + // remote track (owned by a PC) or a local track, so removeTrack() must run while those tracks + // are still alive — before their owner is disposed. localStreams is otherwise only cleared in + // invalidate(), so leftover MediaStream/VideoTrack objects leak across join/leave cycles. + for (Map.Entry entry : localStreams.entrySet()) { + try { + MediaStream stream = entry.getValue(); + for (AudioTrack t : new ArrayList<>(stream.audioTracks)) stream.removeTrack(t); + for (VideoTrack t : new ArrayList<>(stream.videoTracks)) stream.removeTrack(t); + stream.dispose(); + } catch (Exception e) { + Log.w(TAG, "disposeCurrentFactoryOrdered(): error disposing stream " + entry.getKey(), e); + } + } + localStreams.clear(); + + // 2. Dispose the factory's PeerConnections (they own the remote tracks detached above). for (int pcId : factoryRegistry.currentOwnedPcIds()) { try { PeerConnectionObserver pco = mPeerConnectionObservers.get(pcId); @@ -186,22 +202,6 @@ private boolean disposeCurrentFactoryOrdered() { } } - // 2. Release local streams BEFORE their tracks (mirrors invalidate()): detach each track - // while it is still valid, then dispose the stream. Doing this after track disposal would - // touch already-freed native tracks. localStreams is otherwise only cleared in invalidate(), - // so leftover MediaStream / VideoTrack objects accumulate across join/leave cycles. - for (Map.Entry entry : localStreams.entrySet()) { - try { - MediaStream stream = entry.getValue(); - for (AudioTrack t : new ArrayList<>(stream.audioTracks)) stream.removeTrack(t); - for (VideoTrack t : new ArrayList<>(stream.videoTracks)) stream.removeTrack(t); - stream.dispose(); - } catch (Exception e) { - Log.w(TAG, "disposeCurrentFactoryOrdered(): error disposing stream " + entry.getKey(), e); - } - } - localStreams.clear(); - // 3. Stop + dispose owned tracks (e.g. a camera capturer adopted from the lobby // preview) so the camera2 session is fully closed before the VideoSources are freed. for (String trackId : factoryRegistry.currentOwnedTrackIds()) { From 23f9f375eb9fc6156e41457b72ff4eb79bb8189f Mon Sep 17 00:00:00 2001 From: Artem Grintsevich Date: Fri, 14 Aug 2026 14:26:33 +0200 Subject: [PATCH 5/5] chore: cleanup comments --- .../com/oney/WebRTCModule/WebRTCModule.java | 24 ++++----------- ios/RCTWebRTC/WebRTCModule.m | 30 ++++--------------- 2 files changed, 11 insertions(+), 43 deletions(-) diff --git a/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java b/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java index d01a47a70..4c903d8bc 100644 --- a/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java +++ b/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java @@ -156,26 +156,18 @@ public void disposeCallFactory(Promise promise) { } /** - * Disposes the live factory in order — PeerConnections, then tracks, then the factory + its ADM — - * and returns whether a factory was disposed. A libwebrtc {@code PeerConnectionFactory} must not - * be disposed while PCs or tracks from it are still alive (use-after-free), so its dependents go - * first. Shared by {@link #disposeCallFactory} (leave) and {@link #createCallFactory} (replacing a - * stale bare-fork default at join). - * - *

Reference-counted: when the factory is shared by concurrent calls, only the LAST consumer's - * release actually tears it down. Earlier releases keep the factory (and its PCs/tracks) intact - * for the remaining call(s) — the leaving call's own PCs were already disposed by its {@code - * leave}, so its ids are gone from the owned sets before this runs. + * Disposes the live factory and its dependents in order: streams → PeerConnections → owned tracks + * → factory + ADM. Dependents go before the factory (disposing a libwebrtc factory with live + * PCs/tracks is a use-after-free); streams go first so {@code removeTrack()} runs while their + * tracks are still alive. Also clears {@code localStreams}, otherwise only released in + * {@link #invalidate()} (else it leaks across join/leave). No-op unless this is the last + * reference; returns whether it disposed the factory. */ private boolean disposeCurrentFactoryOrdered() { if (!factoryRegistry.releaseReference()) { return false; } - // 1. Detach + dispose local streams first, before PCs and owned tracks. A stream may hold a - // remote track (owned by a PC) or a local track, so removeTrack() must run while those tracks - // are still alive — before their owner is disposed. localStreams is otherwise only cleared in - // invalidate(), so leftover MediaStream/VideoTrack objects leak across join/leave cycles. for (Map.Entry entry : localStreams.entrySet()) { try { MediaStream stream = entry.getValue(); @@ -188,7 +180,6 @@ private boolean disposeCurrentFactoryOrdered() { } localStreams.clear(); - // 2. Dispose the factory's PeerConnections (they own the remote tracks detached above). for (int pcId : factoryRegistry.currentOwnedPcIds()) { try { PeerConnectionObserver pco = mPeerConnectionObservers.get(pcId); @@ -202,8 +193,6 @@ private boolean disposeCurrentFactoryOrdered() { } } - // 3. Stop + dispose owned tracks (e.g. a camera capturer adopted from the lobby - // preview) so the camera2 session is fully closed before the VideoSources are freed. for (String trackId : factoryRegistry.currentOwnedTrackIds()) { try { getUserMediaImpl.disposeTrack(trackId); @@ -212,7 +201,6 @@ private boolean disposeCurrentFactoryOrdered() { } } - // 4. Now it is safe to dispose the factory + its ADM. return factoryRegistry.disposeCurrent(); } diff --git a/ios/RCTWebRTC/WebRTCModule.m b/ios/RCTWebRTC/WebRTCModule.m index 11dd18bb4..ffde484d5 100644 --- a/ios/RCTWebRTC/WebRTCModule.m +++ b/ios/RCTWebRTC/WebRTCModule.m @@ -223,27 +223,17 @@ - (dispatch_queue_t)methodQueue { } /** - * Disposes the live factory in order — PeerConnections, then local tracks, then the factory + its - * ADM — and returns whether a factory was disposed. An RTCPeerConnectionFactory must not be released - * while PeerConnections or tracks created from it are still alive (use-after-free in libwebrtc), so - * its dependents are torn down first. Shared by disposeCallFactory (leave) and createCallFactory - * (replacing a stale bare-fork default at join), mirroring Android's disposeCurrentFactoryOrdered. - * - * Reference-counted: when the factory is shared across concurrent calls, only the LAST consumer's - * release actually tears it down (releaseReference returns NO for earlier releases). On iOS the - * module's own peerConnections / localTracks maps are the factory's ownership registry — only one - * factory is ever live — so every remaining entry belongs to the factory being disposed. A leaving - * call's own PCs/tracks were already released by its leave() before this runs. - * - * Runs on the module's serial worker queue (methodQueue), so the reused peerConnectionClose/ - * peerConnectionDispose/mediaStreamTrackRelease calls execute synchronously on the same thread. + * 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. */ - (BOOL)disposeCurrentFactoryOrdered { if (![self.factoryRegistry releaseReference]) { return NO; } - // 1. Close + dispose the factory's PeerConnections first. for (NSNumber *pcId in [self.peerConnections.allKeys copy]) { @try { [self peerConnectionClose:pcId]; @@ -253,8 +243,6 @@ - (BOOL)disposeCurrentFactoryOrdered { } } - // 2. Stop capture + release owned local tracks (e.g. a camera capturer adopted from the lobby - // preview) so the AVCaptureSession is torn down before the factory's video sources are freed. for (NSString *trackId in [self.localTracks.allKeys copy]) { @try { [self mediaStreamTrackRelease:trackId]; @@ -263,10 +251,6 @@ - (BOOL)disposeCurrentFactoryOrdered { } } - // 2b. Release local streams. An RTCMediaStream strong-refs its tracks, and every track (and the - // video/audio source behind it) strong-refs the RTCPeerConnectionFactory — so a leftover stream - // transitively pins the factory even after the tracks are gone from localTracks. Drop the - // stream's track refs, then the stream itself, so nothing keeps the factory alive. for (NSString *streamId in [self.localStreams.allKeys copy]) { @try { RTCMediaStream *stream = self.localStreams[streamId]; @@ -282,12 +266,8 @@ - (BOOL)disposeCurrentFactoryOrdered { } } - // 2c. Drop the video-effects processor. It is retained by the module via an OBJC_ASSOCIATION_RETAIN - // associated object and strong-refs the RTCVideoSource (background-blur pipeline), which strong-refs - // the factory. Nothing else clears it on leave, so it independently pins the factory across calls. self.videoEffectProcessor = nil; - // 3. Now it is safe to dispose the factory + its ADM. return [self.factoryRegistry disposeCurrent]; }