Skip to content

Commit f9672cf

Browse files
authored
Fix some MatrixCall leaks and use a shared AudioContext (#2484)
* Fix some MatrixCall leaks and use a shared AudioContext These leaks, combined with the dozens of AudioContexts floating around in memory across different CallFeeds, could cause some really bad performance issues and audio crashes on Chrome. * Fully release the AudioContext in CallFeed's dispose method * Fix tests
1 parent e7493fd commit f9672cf

File tree

5 files changed

+108
-21
lines changed

5 files changed

+108
-21
lines changed

.eslintrc.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ module.exports = {
5555
// We're okay with assertion errors when we ask for them
5656
"@typescript-eslint/no-non-null-assertion": "off",
5757

58+
// The base rule produces false positives
59+
"func-call-spacing": "off",
60+
"@typescript-eslint/func-call-spacing": ["error"],
61+
5862
"quotes": "off",
5963
// We use a `logger` intermediary module
6064
"no-console": "error",

spec/unit/webrtc/call.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ const DUMMY_SDP = (
5959
"a=ssrc:3619738545 cname:2RWtmqhXLdoF4sOi\r\n"
6060
);
6161

62+
class MockMediaStreamAudioSourceNode {
63+
connect() {}
64+
}
65+
66+
class MockAudioContext {
67+
constructor() {}
68+
createAnalyser() { return {}; }
69+
createMediaStreamSource() { return new MockMediaStreamAudioSourceNode(); }
70+
close() {}
71+
}
72+
6273
class MockRTCPeerConnection {
6374
localDescription: RTCSessionDescription;
6475

@@ -162,6 +173,9 @@ describe('Call', function() {
162173
// @ts-ignore Mock
163174
global.document = {};
164175

176+
// @ts-ignore Mock
177+
global.AudioContext = MockAudioContext;
178+
165179
client = new TestClient("@alice:foo", "somedevice", "token", undefined, {});
166180
// We just stub out sendEvent: we're not interested in testing the client's
167181
// event sending code here

src/webrtc/audioContext.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
Copyright 2022 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
let audioContext: AudioContext | null = null;
18+
let refCount = 0;
19+
20+
/**
21+
* Acquires a reference to the shared AudioContext.
22+
* It's highly recommended to reuse this AudioContext rather than creating your
23+
* own, because multiple AudioContexts can be problematic in some browsers.
24+
* Make sure to call releaseContext when you're done using it.
25+
* @returns {AudioContext} The shared AudioContext
26+
*/
27+
export const acquireContext = (): AudioContext => {
28+
if (audioContext === null) audioContext = new AudioContext();
29+
refCount++;
30+
return audioContext;
31+
};
32+
33+
/**
34+
* Signals that one of the references to the shared AudioContext has been
35+
* released, allowing the context and associated hardware resources to be
36+
* cleaned up if nothing else is using it.
37+
*/
38+
export const releaseContext = () => {
39+
refCount--;
40+
if (refCount === 0) {
41+
audioContext.close();
42+
audioContext = null;
43+
}
44+
};

src/webrtc/call.ts

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
327327
private opponentCaps: CallCapabilities;
328328
private iceDisconnectedTimeout: ReturnType<typeof setTimeout>;
329329
private inviteTimeout: ReturnType<typeof setTimeout>;
330+
private readonly removeTrackListeners = new Map<MediaStream, () => void>();
330331

331332
// The logic of when & if a call is on hold is nontrivial and explained in is*OnHold
332333
// This flag represents whether we want the other party to be on hold
@@ -841,18 +842,25 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
841842
this.setState(CallState.Ringing);
842843

843844
if (event.getLocalAge()) {
844-
setTimeout(() => {
845-
if (this.state == CallState.Ringing) {
846-
logger.debug(`Call ${this.callId} invite has expired. Hanging up.`);
847-
this.hangupParty = CallParty.Remote; // effectively
848-
this.setState(CallState.Ended);
849-
this.stopAllMedia();
850-
if (this.peerConn.signalingState != 'closed') {
851-
this.peerConn.close();
852-
}
853-
this.emit(CallEvent.Hangup, this);
845+
// Time out the call if it's ringing for too long
846+
const ringingTimer = setTimeout(() => {
847+
logger.debug(`Call ${this.callId} invite has expired. Hanging up.`);
848+
this.hangupParty = CallParty.Remote; // effectively
849+
this.setState(CallState.Ended);
850+
this.stopAllMedia();
851+
if (this.peerConn.signalingState != 'closed') {
852+
this.peerConn.close();
854853
}
854+
this.emit(CallEvent.Hangup, this);
855855
}, invite.lifetime - event.getLocalAge());
856+
857+
const onState = (state: CallState) => {
858+
if (state !== CallState.Ringing) {
859+
clearTimeout(ringingTimer);
860+
this.off(CallEvent.State, onState);
861+
}
862+
};
863+
this.on(CallEvent.State, onState);
856864
}
857865
}
858866

@@ -1986,12 +1994,19 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
19861994

19871995
const stream = ev.streams[0];
19881996
this.pushRemoteFeed(stream);
1989-
stream.addEventListener("removetrack", () => {
1990-
if (stream.getTracks().length === 0) {
1991-
logger.info(`Call ${this.callId} removing track streamId: ${stream.id}`);
1992-
this.deleteFeedByStream(stream);
1993-
}
1994-
});
1997+
1998+
if (!this.removeTrackListeners.has(stream)) {
1999+
const onRemoveTrack = () => {
2000+
if (stream.getTracks().length === 0) {
2001+
logger.info(`Call ${this.callId} removing track streamId: ${stream.id}`);
2002+
this.deleteFeedByStream(stream);
2003+
stream.removeEventListener("removetrack", onRemoveTrack);
2004+
this.removeTrackListeners.delete(stream);
2005+
}
2006+
};
2007+
stream.addEventListener("removetrack", onRemoveTrack);
2008+
this.removeTrackListeners.set(stream, onRemoveTrack);
2009+
}
19952010
};
19962011

19972012
private onDataChannel = (ev: RTCDataChannelEvent): void => {
@@ -2290,6 +2305,11 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
22902305
this.callLengthInterval = null;
22912306
}
22922307

2308+
for (const [stream, listener] of this.removeTrackListeners) {
2309+
stream.removeEventListener("removetrack", listener);
2310+
}
2311+
this.removeTrackListeners.clear();
2312+
22932313
this.callStatsAtEnd = await this.collectCallStats();
22942314

22952315
// Order is important here: first we stopAllMedia() and only then we can deleteAllFeeds()

src/webrtc/callFeed.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ limitations under the License.
1515
*/
1616

1717
import { SDPStreamMetadataPurpose } from "./callEventTypes";
18+
import { acquireContext, releaseContext } from "./audioContext";
1819
import { MatrixClient } from "../client";
1920
import { RoomMember } from "../models/room-member";
2021
import { logger } from "../logger";
@@ -118,10 +119,8 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
118119
}
119120

120121
private initVolumeMeasuring(): void {
121-
const AudioContext = window.AudioContext || window.webkitAudioContext;
122-
if (!this.hasAudioTrack || !AudioContext) return;
123-
124-
this.audioContext = new AudioContext();
122+
if (!this.hasAudioTrack) return;
123+
if (!this.audioContext) this.audioContext = acquireContext();
125124

126125
this.analyser = this.audioContext.createAnalyser();
127126
this.analyser.fftSize = 512;
@@ -211,7 +210,7 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
211210
*/
212211
public measureVolumeActivity(enabled: boolean): void {
213212
if (enabled) {
214-
if (!this.audioContext || !this.analyser || !this.frequencyBinCount || !this.hasAudioTrack) return;
213+
if (!this.analyser || !this.frequencyBinCount || !this.hasAudioTrack) return;
215214

216215
this.measuringVolumeActivity = true;
217216
this.volumeLooper();
@@ -288,5 +287,11 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
288287

289288
public dispose(): void {
290289
clearTimeout(this.volumeLooperTimeout);
290+
this.stream?.removeEventListener("addtrack", this.onAddTrack);
291+
if (this.audioContext) {
292+
this.audioContext = null;
293+
this.analyser = null;
294+
releaseContext();
295+
}
291296
}
292297
}

0 commit comments

Comments
 (0)