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 { import {
CallEmbedContextProvider, CallEmbedContextProvider,
CallEmbedRefContextProvider, CallEmbedRefContextProvider,
useCallCloseEvent,
useCallHangupEvent, useCallHangupEvent,
useCallJoined, useCallJoined,
useCallThemeSync, useCallThemeSync,
@ -20,24 +21,23 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
useCallMemberSoundSync(embed); useCallMemberSoundSync(embed);
useCallThemeSync(embed); useCallThemeSync(embed);
useCallHangupEvent( const clearIfCurrent = useCallback(() => {
embed,
useCallback(() => {
if (store.get(callEmbedAtom) !== embed) return; if (store.get(callEmbedAtom) !== embed) return;
setCallEmbed(undefined); 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, // Widget failed to prepare (bad network, iframe load error). Clear the atom
// the embed lingers with joined=false forever, and the Android FGS (keyed // so callEmbedAtom's setter can dispose the embed and the Android FGS
// to callEmbedAtom presence) stays up as a ghost ongoing notification. // (keyed on the atom) stops.
useEffect(() => { useEffect(() => {
const unsubscribe = embed.onPreparingError(() => { const unsubscribe = embed.onPreparingError(clearIfCurrent);
if (store.get(callEmbedAtom) !== embed) return;
setCallEmbed(undefined);
});
return unsubscribe; return unsubscribe;
}, [embed, store, setCallEmbed]); }, [embed, clearIfCurrent]);
return null; return null;
} }

View file

@ -1,11 +1,19 @@
import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds'; import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useSetAtom } from 'jotai'; import { useSetAtom, useStore } from 'jotai';
import { StatusDivider } from './components'; import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call'; import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed'; 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 = { type MicrophoneButtonProps = {
enabled: boolean; enabled: boolean;
onToggle: () => Promise<unknown>; onToggle: () => Promise<unknown>;
@ -157,9 +165,30 @@ export function CallControl({
}) { }) {
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control); const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const setCallEmbed = useSetAtom(callEmbedAtom); const setCallEmbed = useSetAtom(callEmbedAtom);
const store = useStore();
const [hangupState, hangup] = useAsyncCallback( 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 = const exiting =
hangupState.status === AsyncStatus.Loading || hangupState.status === AsyncStatus.Success; 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); useClientWidgetApiEvent(embed.call, ElementWidgetActions.HangupCall, callback);
}; };
export const useCallCloseEvent = (embed: CallEmbed, callback: () => void) => {
useClientWidgetApiEvent(embed.call, ElementWidgetActions.Close, callback);
};
export const useCallMemberSoundSync = (embed: CallEmbed) => { export const useCallMemberSoundSync = (embed: CallEmbed) => {
const callSession = useCallSession(embed.room); const callSession = useCallSession(embed.room);
useCallMembersChange( useCallMembersChange(

View file

@ -2,7 +2,10 @@
// //
// Contract: // Contract:
// - no prev embed → start a new DM call // - 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, // - prev.roomId !== arg → switch: hangup prev, wait for clean leave,
// dispose prev, then start new // dispose prev, then start new
// //
@ -108,7 +111,17 @@ export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise<void>) =>
} }
const prev = store.get(callEmbedAtom); 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; const container = callEmbedRef.current;
if (!container) { if (!container) {
@ -116,9 +129,22 @@ export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise<void>) =>
} }
if (prev) { 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); await waitLeave(prev);
prev.dispose(); prev.dispose();
} }
}
const embed = createCallEmbed(mx, room, true, theme.kind, container, callPref, true); const embed = createCallEmbed(mx, room, true, theme.kind, container, callPref, true);
store.set(callEmbedAtom, embed); store.set(callEmbedAtom, embed);