diff --git a/.changeset/tall-birds-rest.md b/.changeset/tall-birds-rest.md new file mode 100644 index 00000000..ba8c9bbd --- /dev/null +++ b/.changeset/tall-birds-rest.md @@ -0,0 +1,5 @@ +--- +'@livekit/rtc-node': patch +--- + +Deduplicate getSid() listeners to prevent event listener leak on concurrent calls diff --git a/packages/livekit-rtc/src/room.ts b/packages/livekit-rtc/src/room.ts index 6ef6bf63..739e93bd 100644 --- a/packages/livekit-rtc/src/room.ts +++ b/packages/livekit-rtc/src/room.ts @@ -131,6 +131,11 @@ export class Room extends (EventEmitter as new () => TypedEmitter return this._serverUrl; } + // Shared promise for concurrent getSid() callers. Without this, each call + // registers its own RoomSidChanged + Disconnected listeners, and if many + // calls race only one of each pair is cleaned up — leaking the rest. + private sidPromise?: Promise; + /** * Gets the room's server ID. This ID is assigned by the LiveKit server * and is unique for each room session. @@ -144,19 +149,26 @@ export class Room extends (EventEmitter as new () => TypedEmitter if (this.info?.sid && this.info.sid !== '') { return this.info.sid; } - return new Promise((resolve, reject) => { - const handleRoomUpdate = (sid: string) => { - if (sid !== '') { + if (!this.sidPromise) { + this.sidPromise = new Promise((resolve, reject) => { + const handleDisconnect = () => { this.off(RoomEvent.RoomSidChanged, handleRoomUpdate); - resolve(sid); - } - }; - this.on(RoomEvent.RoomSidChanged, handleRoomUpdate); - this.once(RoomEvent.Disconnected, () => { - this.off(RoomEvent.RoomSidChanged, handleRoomUpdate); - reject('Room disconnected before room server id was available'); + this.sidPromise = undefined; + reject('Room disconnected before room server id was available'); + }; + const handleRoomUpdate = (sid: string) => { + if (sid !== '') { + this.off(RoomEvent.RoomSidChanged, handleRoomUpdate); + this.off(RoomEvent.Disconnected as any, handleDisconnect); + this.sidPromise = undefined; + resolve(sid); + } + }; + this.on(RoomEvent.RoomSidChanged, handleRoomUpdate); + this.once(RoomEvent.Disconnected, handleDisconnect); }); - }); + } + return this.sidPromise; } get numParticipants(): number { @@ -283,6 +295,10 @@ export class Room extends (EventEmitter as new () => TypedEmitter return ev.message.case == 'disconnect' && ev.message.value.asyncId == res.asyncId; }); + // Clear sidPromise before removing listeners so that a reconnect + // doesn't return a stale, permanently-pending promise. + this.sidPromise = undefined; + FfiClient.instance.removeListener(FfiClientEvent.FfiEvent, this.onFfiEvent); this.removeAllListeners(); } diff --git a/packages/livekit-rtc/src/tests/e2e.test.ts b/packages/livekit-rtc/src/tests/e2e.test.ts index ad1d5af2..3bbced55 100644 --- a/packages/livekit-rtc/src/tests/e2e.test.ts +++ b/packages/livekit-rtc/src/tests/e2e.test.ts @@ -514,4 +514,24 @@ describeE2E('livekit-rtc e2e', () => { }, testTimeoutMs * 2, ); + + it( + 'concurrent getSid() calls share a single listener and resolve consistently', + async () => { + const { rooms } = await connectTestRooms(1); + const room = rooms[0]!; + + // Fire multiple concurrent getSid() calls — they should all resolve + // to the same SID without leaking event listeners. + const results = await Promise.all([room.getSid(), room.getSid(), room.getSid()]); + + // All calls should return the same non-empty SID + expect(results[0]).toBeTruthy(); + expect(results[1]).toBe(results[0]); + expect(results[2]).toBe(results[0]); + + await room.disconnect(); + }, + testTimeoutMs, + ); });