Attempt to fix room GC cycle resulting in memory leak#2009
Conversation
Room was not clearing its 'devicechange' event because a different handler was being registered than was being cleaned up.
🦋 Changeset detectedLatest commit: bdc9112 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
size-limit report 📦
|
|
@1egoman Thanks for working on this! We tested this branch locally with our application. The memory leak situation has definitely improved compared to the current release, so this fix is making a noticeable difference. However, we're still seeing retained While investigating, we noticed a few other areas that might be worth checking. We haven't fully verified all of them yet, so please treat these as potential leads rather than confirmed bugs:
Would it be possible to take a look at these areas as well? They seem like plausible candidates based on the heap snapshots we're seeing, although we haven't confirmed them individually yet. Thanks again for investigating and for the work on this fix. |
| window.removeEventListener('beforeunload', this.onPageLeave); | ||
| window.removeEventListener('pagehide', this.onPageLeave); | ||
| window.removeEventListener('freeze', this.onPageLeave); | ||
| navigator.mediaDevices?.removeEventListener?.('devicechange', this.handleDeviceChange); | ||
| } |
There was a problem hiding this comment.
🟡 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()`.
Was this helpful? React with 👍 or 👎 to provide feedback.
Fixes #2008
In the linked issue, it was reported that
Roomwas not being garbage collected properly, leading toRoominstances piling up in memory when a user joins and leaves a room in rapid succession.The one issue reported was definitely valid -
Roomwas not clearing its 'devicechange' event because a different handler was being registered than was being cleaned up. So, I adjusted the 'devicechange' unregistration mechanism to instead use thecleanupControlleradded in #1944.In addition, I found another reference cycle:
Room.frameMetadataManageris aFrameMetadataManager, andFrameMetadataManager.roomis aRoom. To fix this one, I've made some updates toFrameMetadataManagerto wraproomin aWeakRef.With both of these updates, after running 10x connect -> disconnect cycles in a row, there don't look to be any clearly suspicious reference cycles in the heap snapshot: