Skip to content

Commit 4936cdf

Browse files
authored
Merge pull request #3537 from robintown/connection-leaks
Fix resource leaks when we stop using a connection
2 parents 2c66e11 + 1b3a564 commit 4936cdf

23 files changed

+532
-454
lines changed

src/main.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { App } from "./App";
2424
import { init as initRageshake } from "./settings/rageshake";
2525
import { Initializer } from "./initializer";
2626
import { AppViewModel } from "./state/AppViewModel";
27+
import { globalScope } from "./state/ObservableScope";
2728

2829
window.setLKLogLevel = setLKLogLevel;
2930

@@ -61,7 +62,7 @@ Initializer.initBeforeReact()
6162
.then(() => {
6263
root.render(
6364
<StrictMode>
64-
<App vm={new AppViewModel()} />,
65+
<App vm={new AppViewModel(globalScope)} />,
6566
</StrictMode>,
6667
);
6768
})

src/reactions/ReactionsReader.test.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
localRtcMember,
2424
} from "../utils/test-fixtures";
2525
import { getBasicRTCSession } from "../utils/test-viewmodel";
26-
import { withTestScheduler } from "../utils/test";
26+
import { testScope, withTestScheduler } from "../utils/test";
2727
import { ElementCallReactionEventType, ReactionSet } from ".";
2828

2929
afterEach(() => {
@@ -37,6 +37,7 @@ test("handles a hand raised reaction", () => {
3737
withTestScheduler(({ schedule, expectObservable }) => {
3838
renderHook(() => {
3939
const { raisedHands$ } = new ReactionsReader(
40+
testScope(),
4041
rtcSession.asMockedSession(),
4142
);
4243
schedule("ab", {
@@ -85,6 +86,7 @@ test("handles a redaction", () => {
8586
withTestScheduler(({ schedule, expectObservable }) => {
8687
renderHook(() => {
8788
const { raisedHands$ } = new ReactionsReader(
89+
testScope(),
8890
rtcSession.asMockedSession(),
8991
);
9092
schedule("abc", {
@@ -148,6 +150,7 @@ test("handles waiting for event decryption", () => {
148150
withTestScheduler(({ schedule, expectObservable }) => {
149151
renderHook(() => {
150152
const { raisedHands$ } = new ReactionsReader(
153+
testScope(),
151154
rtcSession.asMockedSession(),
152155
);
153156
schedule("abc", {
@@ -217,6 +220,7 @@ test("hands rejecting events without a proper membership", () => {
217220
withTestScheduler(({ schedule, expectObservable }) => {
218221
renderHook(() => {
219222
const { raisedHands$ } = new ReactionsReader(
223+
testScope(),
220224
rtcSession.asMockedSession(),
221225
);
222226
schedule("ab", {
@@ -261,7 +265,10 @@ test("handles a reaction", () => {
261265

262266
withTestScheduler(({ schedule, time, expectObservable }) => {
263267
renderHook(() => {
264-
const { reactions$ } = new ReactionsReader(rtcSession.asMockedSession());
268+
const { reactions$ } = new ReactionsReader(
269+
testScope(),
270+
rtcSession.asMockedSession(),
271+
);
265272
schedule(`abc`, {
266273
a: () => {},
267274
b: () => {
@@ -317,7 +324,10 @@ test("ignores bad reaction events", () => {
317324

318325
withTestScheduler(({ schedule, expectObservable }) => {
319326
renderHook(() => {
320-
const { reactions$ } = new ReactionsReader(rtcSession.asMockedSession());
327+
const { reactions$ } = new ReactionsReader(
328+
testScope(),
329+
rtcSession.asMockedSession(),
330+
);
321331
schedule("ab", {
322332
a: () => {},
323333
b: () => {
@@ -439,7 +449,10 @@ test("that reactions cannot be spammed", () => {
439449

440450
withTestScheduler(({ schedule, expectObservable }) => {
441451
renderHook(() => {
442-
const { reactions$ } = new ReactionsReader(rtcSession.asMockedSession());
452+
const { reactions$ } = new ReactionsReader(
453+
testScope(),
454+
rtcSession.asMockedSession(),
455+
);
443456
schedule("abcd", {
444457
a: () => {},
445458
b: () => {

src/reactions/ReactionsReader.ts

Lines changed: 44 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
EventType,
1919
RoomEvent as MatrixRoomEvent,
2020
} from "matrix-js-sdk";
21-
import { BehaviorSubject, delay, type Subscription } from "rxjs";
21+
import { BehaviorSubject, delay } from "rxjs";
2222

2323
import {
2424
ElementCallReactionEventType,
@@ -28,6 +28,7 @@ import {
2828
type RaisedHandInfo,
2929
type ReactionInfo,
3030
} from ".";
31+
import { type ObservableScope } from "../state/ObservableScope";
3132

3233
export const REACTION_ACTIVE_TIME_MS = 3000;
3334

@@ -54,12 +55,13 @@ export class ReactionsReader {
5455
*/
5556
public readonly reactions$ = this.reactionsSubject$.asObservable();
5657

57-
private readonly reactionsSub: Subscription;
58-
59-
public constructor(private readonly rtcSession: MatrixRTCSession) {
58+
public constructor(
59+
private readonly scope: ObservableScope,
60+
private readonly rtcSession: MatrixRTCSession,
61+
) {
6062
// Hide reactions after a given time.
61-
this.reactionsSub = this.reactionsSubject$
62-
.pipe(delay(REACTION_ACTIVE_TIME_MS))
63+
this.reactionsSubject$
64+
.pipe(delay(REACTION_ACTIVE_TIME_MS), this.scope.bind())
6365
.subscribe((reactions) => {
6466
const date = new Date();
6567
const nextEntries = Object.fromEntries(
@@ -71,27 +73,62 @@ export class ReactionsReader {
7173
this.reactionsSubject$.next(nextEntries);
7274
});
7375

76+
// TODO: Convert this class to the functional reactive style and get rid of
77+
// all this manual setup and teardown for event listeners
78+
7479
this.rtcSession.room.on(MatrixRoomEvent.Timeline, this.handleReactionEvent);
80+
this.scope.onEnd(() =>
81+
this.rtcSession.room.off(
82+
MatrixRoomEvent.Timeline,
83+
this.handleReactionEvent,
84+
),
85+
);
86+
7587
this.rtcSession.room.on(
7688
MatrixRoomEvent.Redaction,
7789
this.handleReactionEvent,
7890
);
91+
this.scope.onEnd(() =>
92+
this.rtcSession.room.off(
93+
MatrixRoomEvent.Redaction,
94+
this.handleReactionEvent,
95+
),
96+
);
97+
7998
this.rtcSession.room.client.on(
8099
MatrixEventEvent.Decrypted,
81100
this.handleReactionEvent,
82101
);
102+
this.scope.onEnd(() =>
103+
this.rtcSession.room.client.off(
104+
MatrixEventEvent.Decrypted,
105+
this.handleReactionEvent,
106+
),
107+
);
83108

84109
// We listen for a local echo to get the real event ID, as timeline events
85110
// may still be sending.
86111
this.rtcSession.room.on(
87112
MatrixRoomEvent.LocalEchoUpdated,
88113
this.handleReactionEvent,
89114
);
115+
this.scope.onEnd(() =>
116+
this.rtcSession.room.off(
117+
MatrixRoomEvent.LocalEchoUpdated,
118+
this.handleReactionEvent,
119+
),
120+
);
90121

91-
rtcSession.on(
122+
this.rtcSession.on(
92123
MatrixRTCSessionEvent.MembershipsChanged,
93124
this.onMembershipsChanged,
94125
);
126+
this.scope.onEnd(() =>
127+
this.rtcSession.off(
128+
MatrixRTCSessionEvent.MembershipsChanged,
129+
this.onMembershipsChanged,
130+
),
131+
);
95132

96133
// Run this once to ensure we have fetched the state from the call.
97134
this.onMembershipsChanged([]);
@@ -309,31 +346,4 @@ export class ReactionsReader {
309346
this.removeRaisedHand(targetUser);
310347
}
311348
};
312-
313-
/**
314-
* Stop listening for events.
315-
*/
316-
public destroy(): void {
317-
this.rtcSession.off(
318-
MatrixRTCSessionEvent.MembershipsChanged,
319-
this.onMembershipsChanged,
320-
);
321-
this.rtcSession.room.off(
322-
MatrixRoomEvent.Timeline,
323-
this.handleReactionEvent,
324-
);
325-
this.rtcSession.room.off(
326-
MatrixRoomEvent.Redaction,
327-
this.handleReactionEvent,
328-
);
329-
this.rtcSession.room.client.off(
330-
MatrixEventEvent.Decrypted,
331-
this.handleReactionEvent,
332-
);
333-
this.rtcSession.room.off(
334-
MatrixRoomEvent.LocalEchoUpdated,
335-
this.handleReactionEvent,
336-
);
337-
this.reactionsSub.unsubscribe();
338-
}
339349
}

src/room/InCallView.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ import ringtoneMp3 from "../sound/ringtone.mp3?url";
110110
import ringtoneOgg from "../sound/ringtone.ogg?url";
111111
import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.tsx";
112112
import { type Layout } from "../state/layout-types.ts";
113+
import { ObservableScope } from "../state/ObservableScope.ts";
113114

114115
const maxTapDurationMs = 400;
115116

@@ -129,8 +130,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
129130

130131
const trackProcessorState$ = useTrackProcessorObservable$();
131132
useEffect(() => {
132-
const reactionsReader = new ReactionsReader(props.rtcSession);
133+
const scope = new ObservableScope();
134+
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
133135
const vm = new CallViewModel(
136+
scope,
134137
props.rtcSession,
135138
props.matrixRoom,
136139
mediaDevices,
@@ -146,11 +149,9 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
146149
);
147150
setVm(vm);
148151

149-
const sub = vm.leave$.subscribe(props.onLeft);
152+
vm.leave$.pipe(scope.bind()).subscribe(props.onLeft);
150153
return (): void => {
151-
vm.destroy();
152-
sub.unsubscribe();
153-
reactionsReader.destroy();
154+
scope.end();
154155
};
155156
}, [
156157
props.rtcSession,

src/room/MuteStates.test.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,7 @@ function mockMediaDevices(
108108
throw new Error("Unimplemented");
109109
}
110110
});
111-
const scope = new ObservableScope();
112-
onTestFinished(() => scope.end());
113-
return new MediaDevices(scope);
111+
return new MediaDevices(testScope());
114112
}
115113
116114
describe("useMuteStates VITE_PACKAGE='full' (SPA) mode", () => {

src/state/AppViewModel.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ Please see LICENSE in the repository root for full details.
66
*/
77

88
import { MediaDevices } from "./MediaDevices";
9-
import { ViewModel } from "./ViewModel";
9+
import { type ObservableScope } from "./ObservableScope";
1010

1111
/**
1212
* The top-level state holder for the application.
1313
*/
14-
export class AppViewModel extends ViewModel {
14+
export class AppViewModel {
1515
public readonly mediaDevices = new MediaDevices(this.scope);
1616

1717
// TODO: Move more application logic here. The CallViewModel, at the very
1818
// least, ought to be accessible from this object.
19+
20+
public constructor(private readonly scope: ObservableScope) {}
1921
}

src/state/CallViewModel.test.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
mockMediaDevices,
6161
mockMuteStates,
6262
mockConfig,
63+
testScope,
6364
} from "../utils/test";
6465
import {
6566
ECAddonConnectionState,
@@ -89,7 +90,6 @@ import {
8990
localRtcMember,
9091
localRtcMemberDevice2,
9192
} from "../utils/test-fixtures";
92-
import { ObservableScope } from "./ObservableScope";
9393
import { MediaDevices } from "./MediaDevices";
9494
import { getValue } from "../utils/observable";
9595
import { type Behavior, constant } from "./Behavior";
@@ -347,6 +347,7 @@ function withCallViewModel(
347347
const reactions$ = new BehaviorSubject<Record<string, ReactionInfo>>({});
348348

349349
const vm = new CallViewModel(
350+
testScope(),
350351
rtcSession.asMockedSession(),
351352
room,
352353
mediaDevices,
@@ -361,7 +362,6 @@ function withCallViewModel(
361362
);
362363

363364
onTestFinished(() => {
364-
vm!.destroy();
365365
participantsSpy!.mockRestore();
366366
mediaSpy!.mockRestore();
367367
eventsSpy!.mockRestore();
@@ -402,6 +402,7 @@ test("test missing RTC config error", async () => {
402402
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({});
403403

404404
const callVM = new CallViewModel(
405+
testScope(),
405406
fakeRtcSession.asMockedSession(),
406407
matrixRoom,
407408
mockMediaDevices({}),
@@ -1630,9 +1631,7 @@ test("audio output changes when toggling earpiece mode", () => {
16301631
getUrlParams.mockReturnValue({ controlledAudioDevices: true });
16311632
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(of([]));
16321633

1633-
const scope = new ObservableScope();
1634-
onTestFinished(() => scope.end());
1635-
const devices = new MediaDevices(scope);
1634+
const devices = new MediaDevices(testScope());
16361635

16371636
window.controls.setAvailableAudioDevices([
16381637
{ id: "speaker", name: "Speaker", isSpeaker: true },

0 commit comments

Comments
 (0)