Skip to content
5 changes: 5 additions & 0 deletions .changeset/full-tires-call.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'livekit-client': patch
---

Fix room GC cycle due to not unregistered devicechange event
9 changes: 7 additions & 2 deletions src/e2ee/E2eeManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
isVideoTrack,
} from '../room/utils';
import type { NonSharedUint8Array } from '../type-polyfills/non-shared-typed-arrays';
import { WeakRefPolyfill } from '../utils/weak-ref-polyfill';
import type { BaseKeyProvider } from './KeyProvider';
import { E2EE_FLAG } from './constants';
import { type E2EEManagerCallbacks, EncryptionEvent, KeyProviderEvent } from './events';
Expand Down Expand Up @@ -72,7 +73,11 @@ export class E2EEManager
{
protected worker: Worker;

protected room?: Room;
private roomRef?: WeakRefPolyfill<Room>;

protected get room(): Room | undefined {
return this.roomRef?.deref();
}

private encryptionEnabled: boolean;

Expand Down Expand Up @@ -113,7 +118,7 @@ export class E2EEManager
}
log.info('setting up e2ee');
if (room !== this.room) {
this.room = room;
this.roomRef = new WeakRefPolyfill(room);
this.setupEventListeners(room, this.keyProvider);
// this.worker = new Worker('');
const msg: InitMessage = {
Expand Down
15 changes: 13 additions & 2 deletions src/frameMetadata/FrameMetadataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { RoomEvent } from '../room/events';
import { FrameMetadataExtractor } from '../room/track/FrameMetadataExtractor';
import type RemoteTrack from '../room/track/RemoteTrack';
import RemoteVideoTrack from '../room/track/RemoteVideoTrack';
import { WeakRefPolyfill } from '../utils/weak-ref-polyfill';
import type { PTDecodeMessage, PTUpdateTrackIdMessage, PTWorkerMessage } from './types';
import { isFrameMetadataSupported, shouldUseFrameMetadataScriptTransform } from './utils';

Expand Down Expand Up @@ -37,7 +38,17 @@ export interface FrameMetadataOptions {
export class FrameMetadataManager {
private worker?: Worker;

private room?: Room;
/**
* Held as a weak reference to break the reference cycle between Room and this
* manager (`Room.frameMetadataManager` -> FrameMetadataManager -> Room).
* Without this, a Room could not be garbage collected once it constructed
* a FrameMetadataManager. Access via the `room` getter.
*/
private roomRef?: WeakRefPolyfill<Room>;

private get room(): Room | undefined {
return this.roomRef?.deref();
}

private extractors = new Map<string, FrameMetadataExtractor>();

Expand All @@ -58,7 +69,7 @@ export class FrameMetadataManager {
if (room === this.room) {
return;
}
this.room = room;
this.roomRef = new WeakRefPolyfill(room);

if (this.worker) {
this.worker.onmessage = this.onWorkerMessage;
Expand Down
7 changes: 4 additions & 3 deletions src/room/Room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1347,7 +1347,7 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
track.enabled = true;
const stream = new MediaStream([track]);
dummyAudioEl.srcObject = stream;
document.addEventListener('visibilitychange', () => {
const onVisibilityChange = () => {
if (!dummyAudioEl) {
return;
}
Expand All @@ -1359,9 +1359,11 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
);
this.startAudio();
}
});
};
document.addEventListener('visibilitychange', onVisibilityChange);
document.body.append(dummyAudioEl);
this.once(RoomEvent.Disconnected, () => {
document.removeEventListener('visibilitychange', onVisibilityChange);
dummyAudioEl?.remove();
dummyAudioEl = null;
});
Expand Down Expand Up @@ -1864,7 +1866,6 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
window.removeEventListener('beforeunload', this.onPageLeave);
window.removeEventListener('pagehide', this.onPageLeave);
window.removeEventListener('freeze', this.onPageLeave);
navigator.mediaDevices?.removeEventListener?.('devicechange', this.handleDeviceChange);
}
Comment on lines 1866 to 1869

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Device-change listener is never removed on disconnect for legacy browsers, leaking the Room

The device-change listener is no longer unregistered on disconnect (handleDisconnect at src/room/Room.ts:1865-1869) after the explicit removeEventListener call was deleted, so on legacy browsers (without WeakRef/FinalizationRegistry) the handler permanently retains the Room object.

Impact: On legacy browsers, a disconnected Room can never be garbage-collected and continues to react to device-change events.

Legacy-path reference chain keeps Room alive after disconnect

In the constructor (src/room/Room.ts:395-399), when Room.cleanupRegistry is falsy (legacy browsers), the fallback sets onDeviceChange = this.handleDeviceChange — a direct strong reference to the Room instance. The local cleanupController is never registered with a FinalizationRegistry and is never aborted, so the signal passed to addEventListener at src/room/Room.ts:402-404 never fires.

Previously, handleDisconnect contained:

navigator.mediaDevices?.removeEventListener?.('devicechange', this.handleDeviceChange);

This line was the only cleanup path for the legacy case. With it removed, the listener persists indefinitely after disconnect, preventing GC of the Room and continuing to invoke handleDeviceChange on a disconnected Room.

(Refers to lines 1865-1869)

Prompt for agents
The removal of `navigator.mediaDevices?.removeEventListener?.('devicechange', this.handleDeviceChange)` from `handleDisconnect` is correct for the modern path (WeakRef + FinalizationRegistry), but creates a regression for the legacy fallback path (src/room/Room.ts:395-399) where `onDeviceChange = this.handleDeviceChange` is used directly without any cleanup mechanism.

To fix this, you need to ensure the legacy path also cleans up on disconnect. Options:
1. Store the `cleanupController` as an instance property and call `cleanupController.abort()` in `handleDisconnect`. This works for both modern and legacy paths (on browsers that support `signal` in addEventListener).
2. Alternatively, only remove the listener in `handleDisconnect` when `Room.cleanupRegistry` is falsy (legacy path), since the modern path handles cleanup via FinalizationRegistry.

Option 1 is cleaner since it works uniformly. The `cleanupController` (currently a local variable in the constructor at line 377) should be promoted to an instance field, and `handleDisconnect` should call `this.cleanupController.abort()`.
Open in Devin Review

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

} finally {
this.setAndEmitConnectionState(ConnectionState.Disconnected);
Expand Down
13 changes: 11 additions & 2 deletions src/room/track/RemoteTrack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export default abstract class RemoteTrack<
/** @internal */
receiver: RTCRtpReceiver | undefined;

private mediaStreamAbort?: AbortController;

constructor(
mediaTrack: MediaStreamTrack,
sid: string,
Expand Down Expand Up @@ -38,11 +40,16 @@ export default abstract class RemoteTrack<

/** @internal */
setMediaStream(stream: MediaStream) {
// Detach the listener bound to any previously set stream so the old
// MediaStream (and this track, captured by the handler closure) can be
// garbage collected when a new stream replaces it.
this.mediaStreamAbort?.abort();
this.mediaStreamAbort = new AbortController();
// this is needed to determine when the track is finished
this.mediaStream = stream;
const onRemoveTrack = (event: MediaStreamTrackEvent) => {
if (event.track === this._mediaStreamTrack) {
stream.removeEventListener('removetrack', onRemoveTrack);
this.mediaStreamAbort?.abort();
if (this.receiver && 'playoutDelayHint' in this.receiver) {
this.receiver.playoutDelayHint = undefined;
}
Expand All @@ -51,7 +58,9 @@ export default abstract class RemoteTrack<
this.emit(TrackEvent.Ended, this);
}
};
stream.addEventListener('removetrack', onRemoveTrack);
stream.addEventListener('removetrack', onRemoveTrack, {
signal: this.mediaStreamAbort.signal,
});
}

start() {
Expand Down
4 changes: 4 additions & 0 deletions src/room/track/Track.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,10 @@ export abstract class Track<
// we only need to re-use a single element
let shouldCache = true;
element.pause();
// Sever any lingering MediaStream reference before the element sits in
// the module-global pool, so a pooled element can never retain a stream
// (and its tracks) regardless of how detachTrack left srcObject.
element.srcObject = null;
recycledElements.forEach((e) => {
if (!e.parentElement) {
shouldCache = false;
Expand Down
32 changes: 32 additions & 0 deletions src/utils/weak-ref-polyfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* A `WeakRef`-like reference that falls back to a strong reference on runtime
* environments that do not implement `WeakRef`.
*
* This is primarily useful for breaking reference cycles (so an object can be
* garbage collected once no longer referenced elsewhere) while remaining safe
* on legacy browsers. On those legacy browsers the fallback reintroduces the
* strong reference — and therefore the cycle — which is an acceptable trade-off
* since they typically lack the modern APIs these cycles arise from anyway.
*
* Mirrors the `WeakRef` API: call {@link deref} to retrieve the referenced
* value, which returns `undefined` once it has been collected.
*
* @link https://developer.mozilla.org/en-US/docs/Web/API/WeakRef
*/
export class WeakRefPolyfill<T extends object> {
private weak?: WeakRef<T>;

private strong?: T;

constructor(value: T) {
if (typeof WeakRef !== 'undefined') {
this.weak = new WeakRef(value);
} else {
this.strong = value;
}
}

deref(): T | undefined {
return this.weak ? this.weak.deref() : this.strong;
}
}
Loading