Harden DM call teardown with io.element.close listener, 8s hangup timeout, and same-room zombie escape so stale embeds stop blocking retries.

This commit is contained in:
v.lagerev 2026-04-24 21:20:05 +03:00
parent 49f7e7417f
commit 91afffc11e
4 changed files with 80 additions and 21 deletions

View file

@ -3,6 +3,7 @@ import { useAtomValue, useSetAtom, useStore } from 'jotai';
import {
CallEmbedContextProvider,
CallEmbedRefContextProvider,
useCallCloseEvent,
useCallHangupEvent,
useCallJoined,
useCallThemeSync,
@ -20,24 +21,23 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
useCallMemberSoundSync(embed);
useCallThemeSync(embed);
useCallHangupEvent(
embed,
useCallback(() => {
const clearIfCurrent = useCallback(() => {
if (store.get(callEmbedAtom) !== embed) return;
setCallEmbed(undefined);
}, [store, embed, setCallEmbed])
);
}, [store, embed, setCallEmbed]);
useCallHangupEvent(embed, clearIfCurrent);
// EC emits Close (not Hangup) when LiveKit gives up without explicit user
// action — e.g. reconnect fail after a network blip. Without this listener
// the embed lingers as a zombie and the Android FGS stays up.
useCallCloseEvent(embed, clearIfCurrent);
// Widget failed to prepare (bad network, iframe load error). Without this,
// the embed lingers with joined=false forever, and the Android FGS (keyed
// to callEmbedAtom presence) stays up as a ghost ongoing notification.
// Widget failed to prepare (bad network, iframe load error). Clear the atom
// so callEmbedAtom's setter can dispose the embed and the Android FGS
// (keyed on the atom) stops.
useEffect(() => {
const unsubscribe = embed.onPreparingError(() => {
if (store.get(callEmbedAtom) !== embed) return;
setCallEmbed(undefined);
});
const unsubscribe = embed.onPreparingError(clearIfCurrent);
return unsubscribe;
}, [embed, store, setCallEmbed]);
}, [embed, clearIfCurrent]);
return null;
}

View file

@ -1,11 +1,19 @@
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useCallback } from 'react';
import { useSetAtom } from 'jotai';
import { useSetAtom, useStore } from 'jotai';
import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed';
// Fail-open window for widget hangup ack. If the widget is healthy, its
// im.vector.hangup / io.element.close action lands within a second and
// clearIfCurrent (CallEmbedProvider) drops the atom. If LiveKit is dead and
// the widget never replies, we fall through and force-clear locally — same
// pattern as element-web's performDisconnection TIMEOUT_MS (16s there, 8s
// here to match the mobile-ish feel of our DM flow and PEER_LEAVE_GRACE_MS).
const HANGUP_TIMEOUT_MS = 8000;
type MicrophoneButtonProps = {
enabled: boolean;
onToggle: () => Promise<unknown>;
@ -157,9 +165,30 @@ export function CallControl({
}) {
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const setCallEmbed = useSetAtom(callEmbedAtom);
const store = useStore();
const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed])
useCallback(async () => {
// Transport.send itself can reject (widget dead / unsupported action).
// Swallow and still poll — even a dead widget may later re-emit via the
// Close path, and the force-clear fallback guarantees forward progress.
try {
await callEmbed.hangup();
} catch {
/* noop — fall through to poll + force-clear */
}
const start = Date.now();
while (Date.now() - start < HANGUP_TIMEOUT_MS) {
if (store.get(callEmbedAtom) !== callEmbed) return;
// eslint-disable-next-line no-await-in-loop
await new Promise((r) => {
setTimeout(r, 200);
});
}
if (store.get(callEmbedAtom) === callEmbed) {
setCallEmbed(undefined);
}
}, [callEmbed, store, setCallEmbed])
);
const exiting =
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success;

View file

@ -106,6 +106,10 @@ export const useCallHangupEvent = (embed: CallEmbed, callback: () => void) => {
useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
};
export const useCallCloseEvent = (embed: CallEmbed, callback: () => void) => {
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
};
export const useCallMemberSoundSync = (embed: CallEmbed) => {
const callSession = useCallSession(embed.room);
useCallMembersChange(

View file

@ -2,7 +2,10 @@
//
// Contract:
// - no prev embed → start a new DM call
// - prev.roomId === arg → no-op (healthy same-room click)
// - prev.roomId === arg, healthy (joined + peer present) → no-op
// - prev.roomId === arg, zombie (never joined, peer gone, or stuck) →
// dispose prev directly and start fresh; waitLeave is skipped because
// the widget is likely unresponsive
// - prev.roomId !== arg → switch: hangup prev, wait for clean leave,
// dispose prev, then start new
//
@ -108,7 +111,17 @@ export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise<void>) =>
}
const prev = store.get(callEmbedAtom);
if (prev?.roomId === roomId) return;
if (prev?.roomId === roomId) {
const selfUserId = mx.getSafeUserId();
const selfDeviceId = mx.getDeviceId();
const peerPresent = mx.matrixRTC
.getRoomSession(prev.room)
.memberships.some(
(m: CallMembership) =>
m.sender !== selfUserId || (!!selfDeviceId && m.deviceId !== selfDeviceId)
);
if (prev.joined && peerPresent) return;
}
const container = callEmbedRef.current;
if (!container) {
@ -116,9 +129,22 @@ export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise<void>) =>
}
if (prev) {
if (prev.roomId === roomId) {
// Zombie same-room embed (widget never joined, peer gone, or stuck
// after LiveKit reconnect fail). waitLeave would hang on the
// unresponsive widget. Best-effort widget hangup before direct
// dispose so the widget's MembershipManager gets a chance to send
// the server-side leave event; if it doesn't, the SDK's delayed
// leave fires a few seconds after dispose.
if (prev.joined) {
prev.hangup().catch(() => undefined);
}
prev.dispose();
} else {
await waitLeave(prev);
prev.dispose();
}
}
const embed = createCallEmbed(mx, room, true, theme.kind, container, callPref, true);
store.set(callEmbedAtom, embed);