From 91afffc11e38c047f3222c8676503f1a6aa1ecc9 Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Fri, 24 Apr 2026 21:20:05 +0300 Subject: [PATCH] Harden DM call teardown with io.element.close listener, 8s hangup timeout, and same-room zombie escape so stale embeds stop blocking retries. --- src/app/components/CallEmbedProvider.tsx | 30 ++++++++--------- src/app/features/call-status/CallControl.tsx | 33 +++++++++++++++++-- src/app/hooks/useCallEmbed.ts | 4 +++ src/app/hooks/useSwitchOrStartDmCall.ts | 34 +++++++++++++++++--- 4 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 6f5ab183..844bdc33 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -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(() => { - if (store.get(callEmbedAtom) !== embed) return; - setCallEmbed(undefined); - }, [store, embed, setCallEmbed]) - ); + const clearIfCurrent = useCallback(() => { + if (store.get(callEmbedAtom) !== embed) return; + setCallEmbed(undefined); + }, [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; } diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 6ffd936e..dc8cb6c4 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -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; @@ -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; diff --git a/src/app/hooks/useCallEmbed.ts b/src/app/hooks/useCallEmbed.ts index 5c9bbe00..74c35d31 100644 --- a/src/app/hooks/useCallEmbed.ts +++ b/src/app/hooks/useCallEmbed.ts @@ -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( diff --git a/src/app/hooks/useSwitchOrStartDmCall.ts b/src/app/hooks/useSwitchOrStartDmCall.ts index 2c8382ea..d4e425e4 100644 --- a/src/app/hooks/useSwitchOrStartDmCall.ts +++ b/src/app/hooks/useSwitchOrStartDmCall.ts @@ -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) => } 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,8 +129,21 @@ export const useSwitchOrStartDmCall = (): ((roomId: string) => Promise) => } if (prev) { - await waitLeave(prev); - prev.dispose(); + 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);