Skip to content

Commit 6e9b837

Browse files
toger5BillCarsonFr
andauthored
Fix connection leaks: Ensure that any pending connection open are cancelled/undo when ActiveCall is unmounted (#3255) (#3269)
* Better logs for connection/component lifecycle * fix: `AudioCaptureOptions` was causing un-necessary effect render AudioCaptureOptions was a different object but with same internal values, use directly deviceId so that Object.is works properly * fix: Livekit openned connection leaks * review: rename to AbortHandles * review: rename variable --------- Co-authored-by: Valere Fedronic <[email protected]>
1 parent a4a6a16 commit 6e9b837

File tree

7 files changed

+233
-18
lines changed

7 files changed

+233
-18
lines changed

src/livekit/useECConnectionState.test.tsx

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

88
import { type FC, useCallback, useState } from "react";
9-
import { test, vi } from "vitest";
9+
import { describe, expect, test, vi, vitest } from "vitest";
1010
import {
1111
ConnectionError,
1212
ConnectionErrorReason,
@@ -15,6 +15,7 @@ import {
1515
import userEvent from "@testing-library/user-event";
1616
import { render, screen } from "@testing-library/react";
1717
import { MemoryRouter } from "react-router-dom";
18+
import { defer, sleep } from "matrix-js-sdk/lib/utils";
1819

1920
import { useECConnectionState } from "./useECConnectionState";
2021
import { type SFUConfig } from "./openIDSFU";
@@ -57,7 +58,7 @@ test.each<[string, ConnectionError]>([
5758
() => setSfuConfig({ url: "URL", jwt: "JWT token" }),
5859
[],
5960
);
60-
useECConnectionState({}, false, mockRoom, sfuConfig);
61+
useECConnectionState("default", false, mockRoom, sfuConfig);
6162
return <button onClick={connect}>Connect</button>;
6263
};
6364

@@ -73,3 +74,111 @@ test.each<[string, ConnectionError]>([
7374
screen.getByText("Insufficient capacity");
7475
},
7576
);
77+
78+
describe("Leaking connection prevention", () => {
79+
function createTestComponent(mockRoom: Room): FC {
80+
const TestComponent: FC = () => {
81+
const [sfuConfig, setSfuConfig] = useState<SFUConfig | undefined>(
82+
undefined,
83+
);
84+
const connect = useCallback(
85+
() => setSfuConfig({ url: "URL", jwt: "JWT token" }),
86+
[],
87+
);
88+
useECConnectionState("default", false, mockRoom, sfuConfig);
89+
return <button onClick={connect}>Connect</button>;
90+
};
91+
return TestComponent;
92+
}
93+
94+
test("Should cancel pending connections when the component is unmounted", async () => {
95+
const connectCall = vi.fn();
96+
const pendingConnection = defer<void>();
97+
// let pendingDisconnection = defer<void>()
98+
const disconnectMock = vi.fn();
99+
100+
const mockRoom = {
101+
on: () => {},
102+
off: () => {},
103+
once: () => {},
104+
connect: async () => {
105+
connectCall.call(undefined);
106+
return await pendingConnection.promise;
107+
},
108+
disconnect: disconnectMock,
109+
localParticipant: {
110+
getTrackPublication: () => {},
111+
createTracks: () => [],
112+
},
113+
} as unknown as Room;
114+
115+
const TestComponent = createTestComponent(mockRoom);
116+
117+
const { unmount } = render(<TestComponent />);
118+
const user = userEvent.setup();
119+
await user.click(screen.getByRole("button", { name: "Connect" }));
120+
121+
expect(connectCall).toHaveBeenCalled();
122+
// unmount while the connection is pending
123+
unmount();
124+
125+
// resolve the pending connection
126+
pendingConnection.resolve();
127+
128+
await vitest.waitUntil(
129+
() => {
130+
return disconnectMock.mock.calls.length > 0;
131+
},
132+
{
133+
timeout: 1000,
134+
interval: 100,
135+
},
136+
);
137+
138+
// There should be some cleaning up to avoid leaking an open connection
139+
expect(disconnectMock).toHaveBeenCalledTimes(1);
140+
});
141+
142+
test("Should cancel about to open but not yet opened connection", async () => {
143+
const createTracksCall = vi.fn();
144+
const pendingCreateTrack = defer<void>();
145+
// let pendingDisconnection = defer<void>()
146+
const disconnectMock = vi.fn();
147+
const connectMock = vi.fn();
148+
149+
const mockRoom = {
150+
on: () => {},
151+
off: () => {},
152+
once: () => {},
153+
connect: connectMock,
154+
disconnect: disconnectMock,
155+
localParticipant: {
156+
getTrackPublication: () => {},
157+
createTracks: async () => {
158+
createTracksCall.call(undefined);
159+
await pendingCreateTrack.promise;
160+
return [];
161+
},
162+
},
163+
} as unknown as Room;
164+
165+
const TestComponent = createTestComponent(mockRoom);
166+
167+
const { unmount } = render(<TestComponent />);
168+
const user = userEvent.setup();
169+
await user.click(screen.getByRole("button", { name: "Connect" }));
170+
171+
expect(createTracksCall).toHaveBeenCalled();
172+
// unmount while createTracks is pending
173+
unmount();
174+
175+
// resolve createTracks
176+
pendingCreateTrack.resolve();
177+
178+
// Yield to the event loop to let the connection attempt finish
179+
await sleep(100);
180+
181+
// The operation should have been aborted before even calling connect.
182+
expect(connectMock).not.toHaveBeenCalled();
183+
});
184+
});

src/livekit/useECConnectionState.ts

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

88
import {
9-
type AudioCaptureOptions,
109
ConnectionError,
1110
ConnectionState,
1211
type LocalTrack,
@@ -25,6 +24,7 @@ import {
2524
InsufficientCapacityError,
2625
UnknownCallError,
2726
} from "../utils/errors.ts";
27+
import { AbortHandle } from "../utils/abortHandle.ts";
2828

2929
declare global {
3030
interface Window {
@@ -59,7 +59,8 @@ async function doConnect(
5959
livekitRoom: Room,
6060
sfuConfig: SFUConfig,
6161
audioEnabled: boolean,
62-
audioOptions: AudioCaptureOptions,
62+
initialDeviceId: string | undefined,
63+
abortHandle: AbortHandle,
6364
): Promise<void> {
6465
// Always create an audio track manually.
6566
// livekit (by default) keeps the mic track open when you mute, but if you start muted,
@@ -82,19 +83,40 @@ async function doConnect(
8283
let preCreatedAudioTrack: LocalTrack | undefined;
8384
try {
8485
const audioTracks = await livekitRoom!.localParticipant.createTracks({
85-
audio: audioOptions,
86+
audio: { deviceId: initialDeviceId },
8687
});
88+
8789
if (audioTracks.length < 1) {
8890
logger.info("Tried to pre-create local audio track but got no tracks");
8991
} else {
9092
preCreatedAudioTrack = audioTracks[0];
9193
}
94+
// There was a yield point previously (awaiting for the track to be created) so we need to check
95+
// if the operation was cancelled and stop connecting if needed.
96+
if (abortHandle.isAborted()) {
97+
logger.info(
98+
"[Lifecycle] Signal Aborted: Pre-created audio track but connection aborted",
99+
);
100+
preCreatedAudioTrack?.stop();
101+
return;
102+
}
103+
92104
logger.info("Pre-created microphone track");
93105
} catch (e) {
94106
logger.error("Failed to pre-create microphone track", e);
95107
}
96108

97-
if (!audioEnabled) await preCreatedAudioTrack?.mute();
109+
if (!audioEnabled) {
110+
await preCreatedAudioTrack?.mute();
111+
// There was a yield point. Check if the operation was cancelled and stop connecting.
112+
if (abortHandle.isAborted()) {
113+
logger.info(
114+
"[Lifecycle] Signal Aborted: Pre-created audio track but connection aborted",
115+
);
116+
preCreatedAudioTrack?.stop();
117+
return;
118+
}
119+
}
98120

99121
// check again having awaited for the track to create
100122
if (
@@ -107,9 +129,18 @@ async function doConnect(
107129
return;
108130
}
109131

110-
logger.info("Connecting & publishing");
132+
logger.info("[Lifecycle] Connecting & publishing");
111133
try {
112134
await connectAndPublish(livekitRoom, sfuConfig, preCreatedAudioTrack, []);
135+
if (abortHandle.isAborted()) {
136+
logger.info(
137+
"[Lifecycle] Signal Aborted: Connected but operation was cancelled. Force disconnect",
138+
);
139+
livekitRoom?.disconnect().catch((err) => {
140+
logger.error("Failed to disconnect from SFU", err);
141+
});
142+
return;
143+
}
113144
} catch (e) {
114145
preCreatedAudioTrack?.stop();
115146
logger.debug("Stopped precreated audio tracks.");
@@ -137,13 +168,16 @@ async function connectAndPublish(
137168
livekitRoom.once(RoomEvent.SignalConnected, tracker.cacheWsConnect);
138169

139170
try {
171+
logger.info(`[Lifecycle] Connecting to livekit room ${sfuConfig!.url} ...`);
140172
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt, {
141173
// Due to stability issues on Firefox we are testing the effect of different
142174
// timeouts, and allow these values to be set through the console
143175
peerConnectionTimeout: window.peerConnectionTimeout ?? 45000,
144176
websocketTimeout: window.websocketTimeout ?? 45000,
145177
});
178+
logger.info(`[Lifecycle] ... connected to livekit room`);
146179
} catch (e) {
180+
logger.error("[Lifecycle] Failed to connect", e);
147181
// LiveKit uses 503 to indicate that the server has hit its track limits.
148182
// https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
149183
// It also errors with a status code of 200 (yes, really) for room
@@ -184,7 +218,7 @@ async function connectAndPublish(
184218
}
185219

186220
export function useECConnectionState(
187-
initialAudioOptions: AudioCaptureOptions,
221+
initialDeviceId: string | undefined,
188222
initialAudioEnabled: boolean,
189223
livekitRoom?: Room,
190224
sfuConfig?: SFUConfig,
@@ -247,6 +281,22 @@ export function useECConnectionState(
247281

248282
const currentSFUConfig = useRef(Object.assign({}, sfuConfig));
249283

284+
// Protection against potential leaks, where the component to be unmounted and there is
285+
// still a pending doConnect promise. This would lead the user to still be in the call even
286+
// if the component is unmounted.
287+
const abortHandlesBag = useRef(new Set<AbortHandle>());
288+
289+
// This is a cleanup function that will be called when the component is about to be unmounted.
290+
// It will cancel all abortHandles in the bag
291+
useEffect(() => {
292+
const bag = abortHandlesBag.current;
293+
return (): void => {
294+
bag.forEach((handle) => {
295+
handle.abort();
296+
});
297+
};
298+
}, []);
299+
250300
// Id we are transitioning from a valid config to another valid one, we need
251301
// to explicitly switch focus
252302
useEffect(() => {
@@ -273,11 +323,14 @@ export function useECConnectionState(
273323
// always capturing audio: it helps keep bluetooth headsets in the right mode and
274324
// mobile browsers to know we're doing a call.
275325
setIsInDoConnect(true);
326+
const abortHandle = new AbortHandle();
327+
abortHandlesBag.current.add(abortHandle);
276328
doConnect(
277329
livekitRoom!,
278330
sfuConfig!,
279331
initialAudioEnabled,
280-
initialAudioOptions,
332+
initialDeviceId,
333+
abortHandle,
281334
)
282335
.catch((e) => {
283336
if (e instanceof ElementCallError) {
@@ -286,14 +339,17 @@ export function useECConnectionState(
286339
setError(new UnknownCallError(e));
287340
} else logger.error("Failed to connect to SFU", e);
288341
})
289-
.finally(() => setIsInDoConnect(false));
342+
.finally(() => {
343+
abortHandlesBag.current.delete(abortHandle);
344+
setIsInDoConnect(false);
345+
});
290346
}
291347

292348
currentSFUConfig.current = Object.assign({}, sfuConfig);
293349
}, [
294350
sfuConfig,
295351
livekitRoom,
296-
initialAudioOptions,
352+
initialDeviceId,
297353
initialAudioEnabled,
298354
doFocusSwitch,
299355
]);

src/livekit/useLiveKit.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,7 @@ export function useLiveKit(
155155
);
156156

157157
const connectionState = useECConnectionState(
158-
{
159-
deviceId: initialDevices.current.audioInput.selectedId,
160-
},
158+
initialDevices.current.audioInput.selectedId,
161159
initialMuteStates.current.audio.enabled,
162160
room,
163161
sfuConfig,

src/room/GroupCallView.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ export const GroupCallView: FC<Props> = ({
118118
// eslint-disable-next-line react-hooks/exhaustive-deps
119119
}, []);
120120

121+
useEffect(() => {
122+
logger.info("[Lifecycle] GroupCallView Component mounted");
123+
return (): void => {
124+
logger.info("[Lifecycle] GroupCallView Component unmounted");
125+
};
126+
}, []);
127+
121128
useEffect(() => {
122129
window.rtcSession = rtcSession;
123130
return (): void => {

src/room/InCallView.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,23 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
127127
const [vm, setVm] = useState<CallViewModel | null>(null);
128128

129129
useEffect(() => {
130+
logger.info(
131+
`[Lifecycle] InCallView Component mounted, livekitroom state ${livekitRoom?.state}`,
132+
);
130133
return (): void => {
131-
livekitRoom?.disconnect().catch((e) => {
132-
logger.error("Failed to disconnect from livekit room", e);
133-
});
134+
logger.info(
135+
`[Lifecycle] InCallView Component unmounted, livekitroom state ${livekitRoom?.state}`,
136+
);
137+
livekitRoom
138+
?.disconnect()
139+
.then(() => {
140+
logger.info(
141+
`[Lifecycle] Disconnected from livekite room, state:${livekitRoom?.state}`,
142+
);
143+
})
144+
.catch((e) => {
145+
logger.error("[Lifecycle] Failed to disconnect from livekit room", e);
146+
});
134147
};
135148
// eslint-disable-next-line react-hooks/exhaustive-deps
136149
}, []);

src/room/LobbyView.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
55
Please see LICENSE in the repository root for full details.
66
*/
77

8-
import { type FC, useCallback, useMemo, useState, type JSX } from "react";
8+
import {
9+
type FC,
10+
useCallback,
11+
useMemo,
12+
useState,
13+
type JSX,
14+
useEffect,
15+
} from "react";
916
import { useTranslation } from "react-i18next";
1017
import { type MatrixClient } from "matrix-js-sdk";
1118
import { Button } from "@vector-im/compound-web";
@@ -72,6 +79,13 @@ export const LobbyView: FC<Props> = ({
7279
onShareClick,
7380
waitingForInvite,
7481
}) => {
82+
useEffect(() => {
83+
logger.info("[Lifecycle] GroupCallView Component mounted");
84+
return (): void => {
85+
logger.info("[Lifecycle] GroupCallView Component unmounted");
86+
};
87+
}, []);
88+
7589
const { t } = useTranslation();
7690
usePageTitle(matrixInfo.roomName);
7791

0 commit comments

Comments
 (0)