From 86021ba6d547e3e278ed4bc0bf1ff7663fd9cdfd Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Fri, 8 May 2026 14:14:42 +0200 Subject: [PATCH 1/2] Release Room FFI handles on disconnect Room never disposed its RoomHandle on Disconnect(), and Participant / TrackPublication / Track never disposed theirs at all. Each handle is an independent entry in the Rust FFI handle table, so dropping one does not cascade. The handles only got freed when GC eventually finalized each SafeHandle, which in practice meant the entire Rust-side room (peer connection, signaling client, libwebrtc state) plus every wrapped participant, publication, and track lingered for an unpredictable amount of time after the user-visible session had ended. Make Room implement IDisposable. Disconnect() now sends the FFI request and then runs Cleanup(), which unsubscribes from FfiClient events, walks LocalParticipant + RemoteParticipants disposing each participant + its publications + the publications' tracks, and finally disposes RoomHandle itself. The same Cleanup() runs from the Disconnected room event so server-initiated drops behave the same as client-initiated ones. OnEventReceived guards against late events arriving after Cleanup, so the FfiClient unsubscribe race is harmless. DisposeHandles is added as an internal cascade: Track disposes its own handle, TrackPublication forwards to its Track, RemoteTrackPublication also disposes its publication handle, and Participant walks _tracks before disposing its own handle. The Meet sample's OnDestroy also calls Disconnect now so a scene change or quit while still connected releases the handles instead of leaking them until process exit. Verified with the FFI handle table diagnostic: a connect / hold / disconnect cycle now drops rooms, participants, tracks, and remote publications back to zero. Local publications and audio/video source handles still leak on this path; those are addressed separately. Co-Authored-By: Claude Opus 4.7 (1M context) --- Runtime/Scripts/Participant.cs | 8 ++++ Runtime/Scripts/Room.cs | 44 +++++++++++++++++++-- Runtime/Scripts/Track.cs | 5 +++ Runtime/Scripts/TrackPublication.cs | 11 ++++++ Samples~/Meet/Assets/Runtime/MeetManager.cs | 1 + 5 files changed, 65 insertions(+), 4 deletions(-) diff --git a/Runtime/Scripts/Participant.cs b/Runtime/Scripts/Participant.cs index b4c65e31..f5210a3a 100644 --- a/Runtime/Scripts/Participant.cs +++ b/Runtime/Scripts/Participant.cs @@ -55,6 +55,14 @@ internal void OnTrackUnpublished(RemoteTrackPublication publication) { TrackUnpublished?.Invoke(publication); } + + internal void DisposeHandles() + { + foreach (var pub in _tracks.Values) + pub.DisposeHandles(); + _tracks.Clear(); + Handle?.Dispose(); + } } public sealed class LocalParticipant : Participant diff --git a/Runtime/Scripts/Room.cs b/Runtime/Scripts/Room.cs index 88d58b2b..6252b67d 100644 --- a/Runtime/Scripts/Room.cs +++ b/Runtime/Scripts/Room.cs @@ -106,9 +106,10 @@ public Proto.RoomOptions ToProto() } } - public class Room + public class Room : IDisposable { internal FfiHandle RoomHandle = null; + private bool _disposed = false; private readonly Dictionary _participants = new(); private StreamHandlerRegistry _streamHandlers = new(); @@ -183,7 +184,7 @@ public ConnectInstruction Connect(string url, string token, RoomOptions options) public void Disconnect() { - if (this.RoomHandle == null) + if (_disposed || RoomHandle == null) return; var (response, _) = FFIBridge.Instance.SendDisconnectRequest(this); using (response) @@ -191,6 +192,38 @@ public void Disconnect() Utils.Debug($"Disconnect.... {RoomHandle}"); Utils.Debug($"Disconnect response.... {response}"); } + // Release the Rust-side room synchronously. Without this the FfiRoom + // (peer connection, signaling client, libwebrtc state) lingers in the + // FFI handle table until the SafeHandle finalizer runs. + Cleanup(); + } + + public void Dispose() + { + Disconnect(); + GC.SuppressFinalize(this); + } + + private void Cleanup() + { + if (_disposed) + return; + _disposed = true; + + FfiClient.Instance.RoomEventReceived -= OnEventReceived; + FfiClient.Instance.RpcMethodInvocationReceived -= OnRpcMethodInvocationReceived; + FfiClient.Instance.DisconnectReceived -= OnDisconnectReceived; + + // Participant + track + publication FFI handles are independent entries in the + // Rust handle table — dropping the room handle alone does not cascade to them, so + // they would otherwise linger until C# GC finalizes each SafeHandle. + LocalParticipant?.DisposeHandles(); + foreach (var p in _participants.Values) + p.DisposeHandles(); + _participants.Clear(); + + RoomHandle?.Dispose(); + RoomHandle = null; } /// @@ -266,6 +299,10 @@ internal void OnRpcMethodInvocationReceived(RpcMethodInvocationEvent e) internal void OnEventReceived(RoomEvent e) { + // After Cleanup() the handle is null but late events may still flow + // through the FfiClient before the unsubscribe fully takes effect. + if (RoomHandle == null) + return; if (e.RoomHandle != (ulong)RoomHandle.DangerousGetHandle()) return; @@ -564,8 +601,7 @@ private void OnDisconnectReceived(DisconnectCallback e) private void OnDisconnect() { - FfiClient.Instance.RoomEventReceived -= OnEventReceived; - FfiClient.Instance.RpcMethodInvocationReceived -= OnRpcMethodInvocationReceived; + Cleanup(); } internal RemoteParticipant CreateRemoteParticipantWithTracks(ConnectCallback.Types.ParticipantWithTracks item) diff --git a/Runtime/Scripts/Track.cs b/Runtime/Scripts/Track.cs index 91cc0608..419c25df 100644 --- a/Runtime/Scripts/Track.cs +++ b/Runtime/Scripts/Track.cs @@ -108,6 +108,11 @@ internal void UpdateMuted(bool muted) { _info.Muted = muted; } + + internal void DisposeHandles() + { + Handle?.Dispose(); + } } public sealed class LocalAudioTrack : Track, ILocalTrack, IAudioTrack diff --git a/Runtime/Scripts/TrackPublication.cs b/Runtime/Scripts/TrackPublication.cs index d078c6b2..edfcb525 100644 --- a/Runtime/Scripts/TrackPublication.cs +++ b/Runtime/Scripts/TrackPublication.cs @@ -40,6 +40,11 @@ internal void UpdateMuted(bool muted) _info.Muted = muted; Track?.UpdateMuted(muted); } + + internal virtual void DisposeHandles() + { + Track?.DisposeHandles(); + } } public sealed class RemoteTrackPublication : TrackPublication @@ -54,6 +59,12 @@ internal RemoteTrackPublication(TrackPublicationInfo info, FfiHandle handle) : b Handle = handle; } + internal override void DisposeHandles() + { + base.DisposeHandles(); + Handle?.Dispose(); + } + public void SetSubscribed(bool subscribed) { Subscribed = subscribed; diff --git a/Samples~/Meet/Assets/Runtime/MeetManager.cs b/Samples~/Meet/Assets/Runtime/MeetManager.cs index f8ed1fd5..225c7a0c 100644 --- a/Samples~/Meet/Assets/Runtime/MeetManager.cs +++ b/Samples~/Meet/Assets/Runtime/MeetManager.cs @@ -90,6 +90,7 @@ private void OnDestroy() } CleanUpAllTracks(); _webCamTexture?.Stop(); + _room?.Disconnect(); } #endregion From 725429588f425d6aefab0178d403a2a473fbc762 Mon Sep 17 00:00:00 2001 From: Max Heimbrock <43608204+MaxHeimbrock@users.noreply.github.com> Date: Fri, 8 May 2026 14:15:24 +0200 Subject: [PATCH 2/2] Release local publication and Rtc source FFI handles on cleanup After the previous commit, Room cleanup walked participants and disposed their handles, including the publication handles for remote tracks. Three local-side handles still leaked on every disconnect: - LocalTrackPublication never wrapped its FFI handle. When PublishTrack succeeded, OnPublish constructed the C# publication from the proto info alone and dropped e.Publication.Handle on the floor. The Rust side kept the entry alive in the FFI handle table for the rest of the process. - RtcVideoSource and RtcAudioSource owned an FFI source handle but their Dispose(bool) implementations released the preview texture, capture buffer, and pending audio frames without ever disposing the handle. The source therefore stayed registered with Rust until the SafeHandle finalizer eventually ran. Wrap the publication handle in the PublishTrackInstruction callback and dispose it through the existing DisposeHandles cascade. Add Handle disposal to the two Rtc source Dispose(bool) overrides so the Meet sample's CleanUpAllTracks now actually frees them. With this change, disconnecting after publishing local mic + camera returns the FFI handle table to its pre-connect baseline on macOS. Co-Authored-By: Claude Opus 4.7 (1M context) --- Runtime/Scripts/Participant.cs | 2 +- Runtime/Scripts/RtcAudioSource.cs | 1 + Runtime/Scripts/RtcVideoSource.cs | 1 + Runtime/Scripts/TrackPublication.cs | 11 ++++++++++- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Runtime/Scripts/Participant.cs b/Runtime/Scripts/Participant.cs index f5210a3a..bc49710b 100644 --- a/Runtime/Scripts/Participant.cs +++ b/Runtime/Scripts/Participant.cs @@ -624,7 +624,7 @@ internal void OnPublish(PublishTrackCallback e) IsError = !string.IsNullOrEmpty(e.Error); IsDone = true; - var publication = new LocalTrackPublication(e.Publication.Info); + var publication = new LocalTrackPublication(e.Publication.Info, FfiHandle.FromOwnedHandle(e.Publication.Handle)); publication.UpdateTrack(_localTrack as Track); _localTrack.UpdateSid(publication.Sid); _internalTracks.Add(e.Publication.Info.Sid, publication); diff --git a/Runtime/Scripts/RtcAudioSource.cs b/Runtime/Scripts/RtcAudioSource.cs index fa6c090f..dcbfb58f 100644 --- a/Runtime/Scripts/RtcAudioSource.cs +++ b/Runtime/Scripts/RtcAudioSource.cs @@ -294,6 +294,7 @@ protected virtual void Dispose(bool disposing) } _pendingFrameData.Clear(); } + Handle?.Dispose(); _disposed = true; Utils.Debug($"{DebugTag} disposed"); } diff --git a/Runtime/Scripts/RtcVideoSource.cs b/Runtime/Scripts/RtcVideoSource.cs index fc9b7677..5f62c671 100644 --- a/Runtime/Scripts/RtcVideoSource.cs +++ b/Runtime/Scripts/RtcVideoSource.cs @@ -211,6 +211,7 @@ protected virtual void Dispose(bool disposing) Debug.Log("Disposing capture buffer"); _captureBuffer.Dispose(); } + Handle?.Dispose(); _disposed = true; } diff --git a/Runtime/Scripts/TrackPublication.cs b/Runtime/Scripts/TrackPublication.cs index edfcb525..4fbd1284 100644 --- a/Runtime/Scripts/TrackPublication.cs +++ b/Runtime/Scripts/TrackPublication.cs @@ -96,8 +96,17 @@ public sealed class LocalTrackPublication : TrackPublication { public new ILocalTrack Track => base.Track as ILocalTrack; - internal LocalTrackPublication(TrackPublicationInfo info) : base(info) + private FfiHandle Handle; + + internal LocalTrackPublication(TrackPublicationInfo info, FfiHandle handle) : base(info) { + Handle = handle; + } + + internal override void DisposeHandles() + { + base.DisposeHandles(); + Handle?.Dispose(); } } } \ No newline at end of file