From 9c95289e3fe4841158bf89149b9dbb94c04a9aee Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Wed, 1 Apr 2026 09:37:49 -0300 Subject: [PATCH 1/7] fix: dispose track publication handles on participant disconnect --- packages/livekit-rtc/src/room.ts | 7 ++ packages/livekit-rtc/src/tests/e2e.test.ts | 92 ++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/packages/livekit-rtc/src/room.ts b/packages/livekit-rtc/src/room.ts index 6ef6bf63..6fdc686b 100644 --- a/packages/livekit-rtc/src/room.ts +++ b/packages/livekit-rtc/src/room.ts @@ -382,6 +382,13 @@ export class Room extends (EventEmitter as new () => TypedEmitter if (participant) { this.remoteParticipants.delete(participant.identity); participant.info.disconnectReason = ev.value.disconnectReason; + // Dispose each track publication's FfiHandle to prevent FD leaks. + // Without this, rapid participant disconnections accumulate undisposed + // native handles since nothing else triggers their cleanup. + for (const [, publication] of participant.trackPublications) { + publication.ffiHandle.dispose(); + } + participant.trackPublications.clear(); this.emit(RoomEvent.ParticipantDisconnected, participant); } else { log.warn(`RoomEvent.ParticipantDisconnected: Could not find participant`); diff --git a/packages/livekit-rtc/src/tests/e2e.test.ts b/packages/livekit-rtc/src/tests/e2e.test.ts index ad1d5af2..f3146c64 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -514,4 +514,96 @@ describeE2E('livekit-rtc e2e', () => { }, testTimeoutMs * 2, ); + + it( + 'cleans up track publications when a remote participant disconnects', + async () => { + const { rooms } = await connectTestRooms(2); + const [stayingRoom, leavingRoom] = rooms; + + // Publish a track from the leaving participant so its track publication + // will need to be cleaned up on disconnect. + const source = new AudioSource(48_000, 1); + const track = LocalAudioTrack.createAudioTrack('cleanup-test', source); + const options = new TrackPublishOptions(); + options.source = TrackSource.SOURCE_MICROPHONE; + await leavingRoom!.localParticipant!.publishTrack(track, options); + + // Wait for the staying room to see the track subscription + await waitFor( + () => { + const remote = stayingRoom!.remoteParticipants.get( + leavingRoom!.localParticipant!.identity, + ); + return remote !== undefined && remote.trackPublications.size > 0; + }, + { timeoutMs: 5000, debugName: 'track publication visible' }, + ); + + // Capture a reference to the remote participant before disconnect + const remoteParticipant = stayingRoom!.remoteParticipants.get( + leavingRoom!.localParticipant!.identity, + )!; + expect(remoteParticipant.trackPublications.size).toBeGreaterThan(0); + + // Listen for the disconnect event + const disconnected = waitForRoomEvent( + stayingRoom!, + RoomEvent.ParticipantDisconnected, + testTimeoutMs, + (p: { identity: string }) => p.identity, + ); + + await leavingRoom!.disconnect(); + await disconnected; + + // After disconnect, the remote participant's track publications map + // should be cleared (handles disposed). + expect(remoteParticipant.trackPublications.size).toBe(0); + expect(stayingRoom!.remoteParticipants.has(remoteParticipant.identity)).toBe(false); + + await source.close(); + await stayingRoom!.disconnect(); + }, + testTimeoutMs, + ); + + it( + 'cleans up resources when multiple participants disconnect simultaneously', + async () => { + // Connect 4 participants to stress-test concurrent disconnection cleanup + const { rooms } = await connectTestRooms(4); + + // Publish a track from each participant to create track publications + const sources: AudioSource[] = []; + for (const room of rooms) { + const source = new AudioSource(48_000, 1); + sources.push(source); + const track = LocalAudioTrack.createAudioTrack('multi-cleanup', source); + const options = new TrackPublishOptions(); + options.source = TrackSource.SOURCE_MICROPHONE; + await room.localParticipant!.publishTrack(track, options); + } + + // Wait for all participants to see each other's tracks + await waitFor( + () => + rooms.every( + (r) => + r.remoteParticipants.size === 3 && + [...r.remoteParticipants.values()].every((p) => p.trackPublications.size > 0), + ), + { timeoutMs: 5000, debugName: 'all tracks visible' }, + ); + + // Disconnect all participants simultaneously + await Promise.all([...rooms.map((r) => r.disconnect()), ...sources.map((s) => s.close())]); + + // Verify all rooms are disconnected and remote participant maps are empty + for (const room of rooms) { + expect(room.isConnected).toBe(false); + } + }, + testTimeoutMs * 2, + ); }); From cfbd69e3c4fbf16c06dbbcdc192b9f959e9002e4 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Wed, 1 Apr 2026 11:35:42 -0300 Subject: [PATCH 2/7] chore: add changeset --- .changeset/dispose-track-publications.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dispose-track-publications.md diff --git a/.changeset/dispose-track-publications.md b/.changeset/dispose-track-publications.md new file mode 100644 index 00000000..8f5e3964 --- /dev/null +++ b/.changeset/dispose-track-publications.md @@ -0,0 +1,5 @@ +--- +'@livekit/rtc-node': patch +--- + +Dispose track publication FfiHandles on participant disconnect to prevent FD leaks From db3177d20ca86fb90981c1dfeea440c21c95a669 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Wed, 1 Apr 2026 11:36:48 -0300 Subject: [PATCH 3/7] chore: add changeset --- .changeset/warm-owls-deny.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/warm-owls-deny.md diff --git a/.changeset/warm-owls-deny.md b/.changeset/warm-owls-deny.md new file mode 100644 index 00000000..8f5e3964 --- /dev/null +++ b/.changeset/warm-owls-deny.md @@ -0,0 +1,5 @@ +--- +'@livekit/rtc-node': patch +--- + +Dispose track publication FfiHandles on participant disconnect to prevent FD leaks From 90007c64b05f8efe6b83c3a011645f3b5ec1f704 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Wed, 1 Apr 2026 11:38:32 -0300 Subject: [PATCH 4/7] chore: remove duplicate changeset --- .changeset/dispose-track-publications.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/dispose-track-publications.md diff --git a/.changeset/dispose-track-publications.md b/.changeset/dispose-track-publications.md deleted file mode 100644 index 8f5e3964..00000000 --- a/.changeset/dispose-track-publications.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@livekit/rtc-node': patch ---- - -Dispose track publication FfiHandles on participant disconnect to prevent FD leaks From 240eb0669b238951025a0e00344e69b9ee71c671 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Wed, 1 Apr 2026 11:42:12 -0300 Subject: [PATCH 5/7] fix: emit ParticipantDisconnected before disposing handles --- packages/livekit-rtc/src/room.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/livekit-rtc/src/room.ts b/packages/livekit-rtc/src/room.ts index 6fdc686b..719eda6f 100644 --- a/packages/livekit-rtc/src/room.ts +++ b/packages/livekit-rtc/src/room.ts @@ -382,6 +382,8 @@ export class Room extends (EventEmitter as new () => TypedEmitter if (participant) { this.remoteParticipants.delete(participant.identity); participant.info.disconnectReason = ev.value.disconnectReason; + // Emit before disposing so listeners can still access trackPublications. + this.emit(RoomEvent.ParticipantDisconnected, participant); // Dispose each track publication's FfiHandle to prevent FD leaks. // Without this, rapid participant disconnections accumulate undisposed // native handles since nothing else triggers their cleanup. @@ -389,7 +391,6 @@ export class Room extends (EventEmitter as new () => TypedEmitter publication.ffiHandle.dispose(); } participant.trackPublications.clear(); - this.emit(RoomEvent.ParticipantDisconnected, participant); } else { log.warn(`RoomEvent.ParticipantDisconnected: Could not find participant`); } From 15117b4f389e41a7d971d5fdf41e4e8f72701bdd Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Fri, 10 Apr 2026 15:54:13 -0300 Subject: [PATCH 6/7] fix: dispose FfiHandles eagerly on trackUnpublished Move the primary disposal to the trackUnpublished handler so handles are freed as soon as tracks are unpublished, preventing accumulation during long-lived sessions with track churn. Keep the sweep in participantDisconnected as a safety net for abrupt disconnects where individual trackUnpublished events may not fire. --- packages/livekit-rtc/src/room.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/livekit-rtc/src/room.ts b/packages/livekit-rtc/src/room.ts index 719eda6f..02a72a41 100644 --- a/packages/livekit-rtc/src/room.ts +++ b/packages/livekit-rtc/src/room.ts @@ -384,9 +384,10 @@ export class Room extends (EventEmitter as new () => TypedEmitter participant.info.disconnectReason = ev.value.disconnectReason; // Emit before disposing so listeners can still access trackPublications. this.emit(RoomEvent.ParticipantDisconnected, participant); - // Dispose each track publication's FfiHandle to prevent FD leaks. - // Without this, rapid participant disconnections accumulate undisposed - // native handles since nothing else triggers their cleanup. + // Safety net: dispose any remaining publication handles that were not + // already cleaned up by individual trackUnpublished events (e.g. the + // server sent participantDisconnected without prior trackUnpublished + // events for every track, which can happen on abrupt disconnects). for (const [, publication] of participant.trackPublications) { publication.ffiHandle.dispose(); } @@ -426,6 +427,9 @@ export class Room extends (EventEmitter as new () => TypedEmitter participant.trackPublications.delete(ev.value.publicationSid!); if (publication) { this.emit(RoomEvent.TrackUnpublished, publication, participant); + // Dispose eagerly so handles don't accumulate when a participant + // publishes and unpublishes many tracks during a long-lived session. + publication.ffiHandle.dispose(); } else { log.warn(`RoomEvent.TrackUnpublished: Could not find publication`); } From 251b6d54bd728a163ff3807e9ec93cf5fb23dcbc Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Fri, 10 Apr 2026 16:32:33 -0300 Subject: [PATCH 7/7] refactor: remove redundant disposal sweep from participantDisconnected trackUnpublished events always fire before participantDisconnected (the SDK already depends on this ordering via requireRemoteParticipant), so the safety-net loop was dead code. The eager disposal in the trackUnpublished handler is sufficient. --- packages/livekit-rtc/src/room.ts | 9 --------- packages/livekit-rtc/src/tests/e2e.test.ts | 4 ++-- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/livekit-rtc/src/room.ts b/packages/livekit-rtc/src/room.ts index 02a72a41..91de2dff 100644 --- a/packages/livekit-rtc/src/room.ts +++ b/packages/livekit-rtc/src/room.ts @@ -382,16 +382,7 @@ export class Room extends (EventEmitter as new () => TypedEmitter if (participant) { this.remoteParticipants.delete(participant.identity); participant.info.disconnectReason = ev.value.disconnectReason; - // Emit before disposing so listeners can still access trackPublications. this.emit(RoomEvent.ParticipantDisconnected, participant); - // Safety net: dispose any remaining publication handles that were not - // already cleaned up by individual trackUnpublished events (e.g. the - // server sent participantDisconnected without prior trackUnpublished - // events for every track, which can happen on abrupt disconnects). - for (const [, publication] of participant.trackPublications) { - publication.ffiHandle.dispose(); - } - participant.trackPublications.clear(); } else { log.warn(`RoomEvent.ParticipantDisconnected: Could not find participant`); } diff --git a/packages/livekit-rtc/src/tests/e2e.test.ts b/packages/livekit-rtc/src/tests/e2e.test.ts index f3146c64..4ffc27dc 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -557,8 +557,8 @@ describeE2E('livekit-rtc e2e', () => { await leavingRoom!.disconnect(); await disconnected; - // After disconnect, the remote participant's track publications map - // should be cleared (handles disposed). + // trackUnpublished events fire before participantDisconnected, so + // by this point all publications should already be removed and disposed. expect(remoteParticipant.trackPublications.size).toBe(0); expect(stayingRoom!.remoteParticipants.has(remoteParticipant.identity)).toBe(false);