Fix connection leaks: Ensure that any pending connection open are cancelled/undo when ActiveCall is unmounted (#3255)

* 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: Timo <toger5@hotmail.de>
This commit is contained in:
Valere Fedronic
2025-05-14 18:41:22 +02:00
committed by GitHub
parent 4569d01353
commit 86d80630c1
7 changed files with 233 additions and 18 deletions

View File

@@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details.
*/
import {
type AudioCaptureOptions,
ConnectionError,
ConnectionState,
type LocalTrack,
@@ -25,6 +24,7 @@ import {
InsufficientCapacityError,
UnknownCallError,
} from "../utils/errors.ts";
import { AbortHandle } from "../utils/abortHandle.ts";
declare global {
interface Window {
@@ -59,7 +59,8 @@ async function doConnect(
livekitRoom: Room,
sfuConfig: SFUConfig,
audioEnabled: boolean,
audioOptions: AudioCaptureOptions,
initialDeviceId: string | undefined,
abortHandle: AbortHandle,
): Promise<void> {
// Always create an audio track manually.
// livekit (by default) keeps the mic track open when you mute, but if you start muted,
@@ -82,19 +83,40 @@ async function doConnect(
let preCreatedAudioTrack: LocalTrack | undefined;
try {
const audioTracks = await livekitRoom!.localParticipant.createTracks({
audio: audioOptions,
audio: { deviceId: initialDeviceId },
});
if (audioTracks.length < 1) {
logger.info("Tried to pre-create local audio track but got no tracks");
} else {
preCreatedAudioTrack = audioTracks[0];
}
// There was a yield point previously (awaiting for the track to be created) so we need to check
// if the operation was cancelled and stop connecting if needed.
if (abortHandle.isAborted()) {
logger.info(
"[Lifecycle] Signal Aborted: Pre-created audio track but connection aborted",
);
preCreatedAudioTrack?.stop();
return;
}
logger.info("Pre-created microphone track");
} catch (e) {
logger.error("Failed to pre-create microphone track", e);
}
if (!audioEnabled) await preCreatedAudioTrack?.mute();
if (!audioEnabled) {
await preCreatedAudioTrack?.mute();
// There was a yield point. Check if the operation was cancelled and stop connecting.
if (abortHandle.isAborted()) {
logger.info(
"[Lifecycle] Signal Aborted: Pre-created audio track but connection aborted",
);
preCreatedAudioTrack?.stop();
return;
}
}
// check again having awaited for the track to create
if (
@@ -107,9 +129,18 @@ async function doConnect(
return;
}
logger.info("Connecting & publishing");
logger.info("[Lifecycle] Connecting & publishing");
try {
await connectAndPublish(livekitRoom, sfuConfig, preCreatedAudioTrack, []);
if (abortHandle.isAborted()) {
logger.info(
"[Lifecycle] Signal Aborted: Connected but operation was cancelled. Force disconnect",
);
livekitRoom?.disconnect().catch((err) => {
logger.error("Failed to disconnect from SFU", err);
});
return;
}
} catch (e) {
preCreatedAudioTrack?.stop();
logger.debug("Stopped precreated audio tracks.");
@@ -137,13 +168,16 @@ async function connectAndPublish(
livekitRoom.once(RoomEvent.SignalConnected, tracker.cacheWsConnect);
try {
logger.info(`[Lifecycle] Connecting to livekit room ${sfuConfig!.url} ...`);
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt, {
// Due to stability issues on Firefox we are testing the effect of different
// timeouts, and allow these values to be set through the console
peerConnectionTimeout: window.peerConnectionTimeout ?? 45000,
websocketTimeout: window.websocketTimeout ?? 45000,
});
logger.info(`[Lifecycle] ... connected to livekit room`);
} catch (e) {
logger.error("[Lifecycle] Failed to connect", e);
// LiveKit uses 503 to indicate that the server has hit its track limits.
// https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
// It also errors with a status code of 200 (yes, really) for room
@@ -184,7 +218,7 @@ async function connectAndPublish(
}
export function useECConnectionState(
initialAudioOptions: AudioCaptureOptions,
initialDeviceId: string | undefined,
initialAudioEnabled: boolean,
livekitRoom?: Room,
sfuConfig?: SFUConfig,
@@ -247,6 +281,22 @@ export function useECConnectionState(
const currentSFUConfig = useRef(Object.assign({}, sfuConfig));
// Protection against potential leaks, where the component to be unmounted and there is
// still a pending doConnect promise. This would lead the user to still be in the call even
// if the component is unmounted.
const abortHandlesBag = useRef(new Set<AbortHandle>());
// This is a cleanup function that will be called when the component is about to be unmounted.
// It will cancel all abortHandles in the bag
useEffect(() => {
const bag = abortHandlesBag.current;
return (): void => {
bag.forEach((handle) => {
handle.abort();
});
};
}, []);
// Id we are transitioning from a valid config to another valid one, we need
// to explicitly switch focus
useEffect(() => {
@@ -273,11 +323,14 @@ export function useECConnectionState(
// always capturing audio: it helps keep bluetooth headsets in the right mode and
// mobile browsers to know we're doing a call.
setIsInDoConnect(true);
const abortHandle = new AbortHandle();
abortHandlesBag.current.add(abortHandle);
doConnect(
livekitRoom!,
sfuConfig!,
initialAudioEnabled,
initialAudioOptions,
initialDeviceId,
abortHandle,
)
.catch((e) => {
if (e instanceof ElementCallError) {
@@ -286,14 +339,17 @@ export function useECConnectionState(
setError(new UnknownCallError(e));
} else logger.error("Failed to connect to SFU", e);
})
.finally(() => setIsInDoConnect(false));
.finally(() => {
abortHandlesBag.current.delete(abortHandle);
setIsInDoConnect(false);
});
}
currentSFUConfig.current = Object.assign({}, sfuConfig);
}, [
sfuConfig,
livekitRoom,
initialAudioOptions,
initialDeviceId,
initialAudioEnabled,
doFocusSwitch,
]);