diff --git a/.changeset/full-tires-call.md b/.changeset/full-tires-call.md new file mode 100644 index 0000000000..e7ef53ed69 --- /dev/null +++ b/.changeset/full-tires-call.md @@ -0,0 +1,5 @@ +--- +'livekit-client': patch +--- + +Fix room GC cycle due to not unregistered devicechange event diff --git a/src/e2ee/E2eeManager.ts b/src/e2ee/E2eeManager.ts index b7ae206a8d..ca4a879a12 100644 --- a/src/e2ee/E2eeManager.ts +++ b/src/e2ee/E2eeManager.ts @@ -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'; @@ -72,7 +73,11 @@ export class E2EEManager { protected worker: Worker; - protected room?: Room; + private roomRef?: WeakRefPolyfill; + + protected get room(): Room | undefined { + return this.roomRef?.deref(); + } private encryptionEnabled: boolean; @@ -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 = { diff --git a/src/frameMetadata/FrameMetadataManager.ts b/src/frameMetadata/FrameMetadataManager.ts index c052c32692..56bd9a0948 100644 --- a/src/frameMetadata/FrameMetadataManager.ts +++ b/src/frameMetadata/FrameMetadataManager.ts @@ -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'; @@ -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; + + private get room(): Room | undefined { + return this.roomRef?.deref(); + } private extractors = new Map(); @@ -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; diff --git a/src/room/Room.ts b/src/room/Room.ts index b09f79bb8f..c99e950862 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -1347,7 +1347,7 @@ class Room extends (EventEmitter as new () => TypedEmitter) track.enabled = true; const stream = new MediaStream([track]); dummyAudioEl.srcObject = stream; - document.addEventListener('visibilitychange', () => { + const onVisibilityChange = () => { if (!dummyAudioEl) { return; } @@ -1359,9 +1359,11 @@ class Room extends (EventEmitter as new () => TypedEmitter) ); this.startAudio(); } - }); + }; + document.addEventListener('visibilitychange', onVisibilityChange); document.body.append(dummyAudioEl); this.once(RoomEvent.Disconnected, () => { + document.removeEventListener('visibilitychange', onVisibilityChange); dummyAudioEl?.remove(); dummyAudioEl = null; }); @@ -1864,7 +1866,6 @@ class Room extends (EventEmitter as new () => TypedEmitter) window.removeEventListener('beforeunload', this.onPageLeave); window.removeEventListener('pagehide', this.onPageLeave); window.removeEventListener('freeze', this.onPageLeave); - navigator.mediaDevices?.removeEventListener?.('devicechange', this.handleDeviceChange); } } finally { this.setAndEmitConnectionState(ConnectionState.Disconnected); diff --git a/src/room/track/RemoteTrack.ts b/src/room/track/RemoteTrack.ts index 3c378aa1cb..04020bbfdf 100644 --- a/src/room/track/RemoteTrack.ts +++ b/src/room/track/RemoteTrack.ts @@ -10,6 +10,8 @@ export default abstract class RemoteTrack< /** @internal */ receiver: RTCRtpReceiver | undefined; + private mediaStreamAbort?: AbortController; + constructor( mediaTrack: MediaStreamTrack, sid: string, @@ -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; } @@ -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() { diff --git a/src/room/track/Track.ts b/src/room/track/Track.ts index 5a502b4ccc..9bb7fff7dd 100644 --- a/src/room/track/Track.ts +++ b/src/room/track/Track.ts @@ -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; diff --git a/src/utils/weak-ref-polyfill.ts b/src/utils/weak-ref-polyfill.ts new file mode 100644 index 0000000000..057987a0b9 --- /dev/null +++ b/src/utils/weak-ref-polyfill.ts @@ -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 { + private weak?: WeakRef; + + 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; + } +}