From a132b5ab5516f795e1334c4e7a27de4678af4f8d Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Tue, 14 Apr 2026 11:53:02 -0300 Subject: [PATCH] Fix memory leak when Room.connect() fails When connect() throws (either from a ConnectError or a rejected waitFor), the onFfiEvent listener registered on the global FfiClient singleton was never removed. This prevents the Room from being GC'd and causes preConnectEvents to grow unboundedly because the handler keeps pushing events into a Room that never finished connecting. Wrap the connect logic in try/catch so the listener and stale preConnectEvents are cleaned up on any error path. --- .changeset/connect-error-listener-leak.md | 5 ++ packages/livekit-rtc/src/room.ts | 82 ++++++++++++----------- 2 files changed, 49 insertions(+), 38 deletions(-) create mode 100644 .changeset/connect-error-listener-leak.md diff --git a/.changeset/connect-error-listener-leak.md b/.changeset/connect-error-listener-leak.md new file mode 100644 index 00000000..67a37e82 --- /dev/null +++ b/.changeset/connect-error-listener-leak.md @@ -0,0 +1,5 @@ +--- +'@livekit/rtc-node': patch +--- + +Remove FfiClient listener on failed connect to prevent leak diff --git a/packages/livekit-rtc/src/room.ts b/packages/livekit-rtc/src/room.ts index e5358d57..61da7d6b 100644 --- a/packages/livekit-rtc/src/room.ts +++ b/packages/livekit-rtc/src/room.ts @@ -235,49 +235,55 @@ export class Room extends (EventEmitter as new () => TypedEmitter FfiClient.instance.on(FfiClientEvent.FfiEvent, this.onFfiEvent); - const res = FfiClient.instance.request({ - message: { - case: 'connect', - value: req, - }, - }); + try { + const res = FfiClient.instance.request({ + message: { + case: 'connect', + value: req, + }, + }); - const cb = await FfiClient.instance.waitFor((ev: FfiEvent) => { - return ev.message.case == 'connect' && ev.message.value.asyncId == res.asyncId; - }); + const cb = await FfiClient.instance.waitFor((ev: FfiEvent) => { + return ev.message.case == 'connect' && ev.message.value.asyncId == res.asyncId; + }); - log.debug('Connect callback received'); - - switch (cb.message.case) { - case 'result': - this.ffiHandle = new FfiHandle(cb.message.value.room!.handle!.id!); - this.e2eeManager = e2eeEnabled && new E2EEManager(this.ffiHandle.handle, e2eeOptions); - - this._token = token; - this._serverUrl = url; - this.info = cb.message.value.room!.info; - this.connectionState = ConnectionState.CONN_CONNECTED; - // Reset the abort controller for this connection session so that - // a previous disconnect doesn't immediately cancel new operations. - this.disconnectController = new AbortController(); - this.localParticipant = new LocalParticipant( - cb.message.value.localParticipant!, - this.ffiEventLock, - this.disconnectController.signal, - ); + log.debug('Connect callback received'); + + switch (cb.message.case) { + case 'result': + this.ffiHandle = new FfiHandle(cb.message.value.room!.handle!.id!); + this.e2eeManager = e2eeEnabled && new E2EEManager(this.ffiHandle.handle, e2eeOptions); + + this._token = token; + this._serverUrl = url; + this.info = cb.message.value.room!.info; + this.connectionState = ConnectionState.CONN_CONNECTED; + // Reset the abort controller for this connection session so that + // a previous disconnect doesn't immediately cancel new operations. + this.disconnectController = new AbortController(); + this.localParticipant = new LocalParticipant( + cb.message.value.localParticipant!, + this.ffiEventLock, + this.disconnectController.signal, + ); - for (const pt of cb.message.value.participants) { - const rp = this.createRemoteParticipant(pt.participant!); + for (const pt of cb.message.value.participants) { + const rp = this.createRemoteParticipant(pt.participant!); - for (const pub of pt.publications) { - const publication = new RemoteTrackPublication(pub); - rp.trackPublications.set(publication.sid!, publication); + for (const pub of pt.publications) { + const publication = new RemoteTrackPublication(pub); + rp.trackPublications.set(publication.sid!, publication); + } } - } - break; - case 'error': - default: - throw new ConnectError(cb.message.value || ''); + break; + case 'error': + default: + throw new ConnectError(cb.message.value || ''); + } + } catch (e) { + FfiClient.instance.off(FfiClientEvent.FfiEvent, this.onFfiEvent); + this.preConnectEvents = []; + throw e; } }