Every call leaks ~11 MB: an 11th CallControl subscriber trips MaxListenersExceededWarning, and the logged warning keeps the whole Element Call document alive #316

Closed
opened 2026-10-05 15:32:38 -04:00 by jared · 0 comments
Owner

Measured (Chromium, the same tab joining and leaving the voice room; forced GC before each sample; instance counts via CDP Runtime.queryObjects):

before any call: heap 30.3 MB  CallControl 0  iframes 0  documents 6  frames 4
after call 1:    heap 52.5 MB  CallControl 1  iframes 1  documents 8  frames 5
after call 2:    heap 65.4 MB  CallControl 2  iframes 2  documents 9  frames 6
after call 3:    heap 76.3 MB  CallControl 3  iframes 3  documents 10 frames 7
after call 5:    heap 97.4 MB  CallControl 5  iframes 5  documents 12 frames 9

Each call leaves its CallControl, the removed <iframe> and the entire Element Call document in memory, about 11 MB per call. A desktop app left open for a week of daily calls grows by hundreds of MB.

Why:

  • The retainer path (heap snapshot): (Global handles) / DevTools console → Error → .emitter → CallControl → iframe/ClientWidgetApi → EC document. The Error is Node events' MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 state_update listeners added. It is printed with console.warn(warning), and Chromium keeps logged objects (so DevTools can show them later) together with warning.emitter.
  • Why there are 11 listeners: CallControl extends EventEmitter (src/app/plugins/call/CallControl.ts:57) keeps the default limit of 10, but during a call more than 10 components subscribe to state_update at once: useCallControlState in the call bar, the controls, useCallHotkeys, useAfkAutoMute, useMutedTalkWarning, useCallJoinLeaveSounds, useCallAnnouncements, useCallRejoin, useCallPolicyRevokedToast, MemberGlance, the Tauri thumbbar/SMTC hooks, and more. They do unsubscribe; the count is simply above the default limit.
  • Confirmed: with only that warning swallowed (init script), the same 5 calls leave 0 CallControls, 0 iframes and 7 documents, and the heap levels off at 41.6 → 46.2 MB.

Fix:

  • this.setMaxListeners(0) (or e.g. 50) in the CallControl constructor, and check the other per-call emitters (CallEmbed, widget transport) for the same.
  • Better: one subscription feeding a store (useSyncExternalStore), so the number of subscribers doesn't grow with each new call feature.
  • Add a regression test: N join/leave cycles, then a queryObjects(CallControl.prototype) count of 0 after GC, plus "no MaxListenersExceededWarning in the console".

Navigating between rooms (100 room switches) showed no comparable growth: client listeners 93 → 95, heap stable around 41 MB.


Found in the October 2026 client audit (Playwright against a local Synapse + LiveKit stack, plus code reading). Code refs are at cinny 30e2dbcd, cinny-desktop 8f67ec7, element-call 3b0050e8.

**Measured** (Chromium, the same tab joining and leaving the voice room; forced GC before each sample; instance counts via CDP `Runtime.queryObjects`): ``` before any call: heap 30.3 MB CallControl 0 iframes 0 documents 6 frames 4 after call 1: heap 52.5 MB CallControl 1 iframes 1 documents 8 frames 5 after call 2: heap 65.4 MB CallControl 2 iframes 2 documents 9 frames 6 after call 3: heap 76.3 MB CallControl 3 iframes 3 documents 10 frames 7 after call 5: heap 97.4 MB CallControl 5 iframes 5 documents 12 frames 9 ``` Each call leaves its `CallControl`, the removed `<iframe>` and the **entire Element Call document** in memory, about 11 MB per call. A desktop app left open for a week of daily calls grows by hundreds of MB. **Why:** - **The retainer path** (heap snapshot): `(Global handles) / DevTools console → Error → .emitter → CallControl → iframe/ClientWidgetApi → EC document`. The Error is Node `events`' **`MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 state_update listeners added`**. It is printed with `console.warn(warning)`, and Chromium keeps logged objects (so DevTools can show them later) together with `warning.emitter`. - **Why there are 11 listeners:** `CallControl extends EventEmitter` (src/app/plugins/call/CallControl.ts:57) keeps the default limit of 10, but during a call more than 10 components subscribe to `state_update` at once: `useCallControlState` in the call bar, the controls, `useCallHotkeys`, `useAfkAutoMute`, `useMutedTalkWarning`, `useCallJoinLeaveSounds`, `useCallAnnouncements`, `useCallRejoin`, `useCallPolicyRevokedToast`, `MemberGlance`, the Tauri thumbbar/SMTC hooks, and more. They do unsubscribe; the count is simply above the default limit. - **Confirmed:** with only that warning swallowed (init script), the same 5 calls leave **0** `CallControl`s, 0 iframes and 7 documents, and the heap levels off at 41.6 → 46.2 MB. **Fix:** - `this.setMaxListeners(0)` (or e.g. 50) in the `CallControl` constructor, and check the other per-call emitters (`CallEmbed`, widget transport) for the same. - Better: one subscription feeding a store (`useSyncExternalStore`), so the number of subscribers doesn't grow with each new call feature. - Add a regression test: N join/leave cycles, then a `queryObjects(CallControl.prototype)` count of 0 after GC, plus "no MaxListenersExceededWarning in the console". Navigating between rooms (100 room switches) showed no comparable growth: client listeners 93 → 95, heap stable around 41 MB. --- _Found in the October 2026 client audit (Playwright against a local Synapse + LiveKit stack, plus code reading). Code refs are at cinny `30e2dbcd`, cinny-desktop `8f67ec7`, element-call `3b0050e8`._
jared added the bugpriority: mediumperformancearea: calls labels 2026-10-05 15:32:38 -04:00
jared closed this issue 2026-10-17 21:19:13 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/cinny#316