From e66bc45b6da6449706f2c159e42674ac6207c013 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Wed, 1 Apr 2026 09:37:49 -0300 Subject: [PATCH 1/5] 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 d04005765efc877e5e1f06390d661c5f31503df0 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Wed, 1 Apr 2026 11:35:42 -0300 Subject: [PATCH 2/5] 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 93391bb4aac4833fc105540c5c99ec45ad79613b Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Wed, 1 Apr 2026 11:36:48 -0300 Subject: [PATCH 3/5] 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 e8820d358fa8fb153ece795b1d448e29484c021c Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Wed, 1 Apr 2026 11:38:32 -0300 Subject: [PATCH 4/5] 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 b1cd48c978b3258cd2f540f4b5ec36793fe9cb31 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Wed, 1 Apr 2026 11:42:12 -0300 Subject: [PATCH 5/5] 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`); }