Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/warm-owls-deny.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/rtc-node': patch
---

Dispose track publication FfiHandles on participant disconnect to prevent FD leaks
3 changes: 3 additions & 0 deletions packages/livekit-rtc/src/room.ts
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 participantDisconnected handler does not dispose remaining publication FfiHandles, causing FD leaks if trackUnpublished events are skipped

The PR's stated intent is to prevent FD leaks from track publication handles on participant disconnect, but the disposal is only added in the trackUnpublished handler (room.ts:451). The participantDisconnected handler (room.ts:408-416) removes the participant from remoteParticipants without disposing any remaining publication ffiHandles. If trackUnpublished events don't fire for every track before participantDisconnected (e.g., during abrupt disconnects or server-side removals), those handles will leak — the exact scenario the PR aims to fix. The test at line 562 asserts trackPublications.size === 0, validating only the happy path where trackUnpublished precedes participantDisconnected. A defensive cleanup loop in the participantDisconnected handler would close this gap.

(Refers to lines 410-413)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,9 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
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`);
}
Expand Down
92 changes: 92 additions & 0 deletions packages/livekit-rtc/src/tests/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,98 @@ 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;

// 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);

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,
);

it(
'concurrent getSid() calls share a single listener and resolve consistently',
async () => {
Expand Down
Loading