From 0f5bc33619444dc0062f15479f74bee5d9a4cce6 Mon Sep 17 00:00:00 2001 From: heaven Date: Sat, 4 Jul 2026 02:40:47 +0300 Subject: [PATCH] fix(calls): resolve call glare via co-presence, not answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both users starting a call in the same DM at once collapsed both attempts: two useSwitchOrStartDmCall instances (header outgoing + strip answer) race on the shared callEmbedAtom, the counter-ring surfaces during the outgoing embed's forming window, and answering it disposes+recreates the embed — putting two MembershipManagers on one (user,device) state-key, driving the SDK 'Missing own membership: force re-join' thrash. Fix per MatrixRTC canon (a call is co-presence, there is no 'answer'): a formingCallRoomIdAtom set synchronously before the outgoing awaits marks the room as forming; an incoming call for a room we already hold/are forming becomes a no-op (element-x model), the counter-ring is suppressed while forming, and the native-answer consumer won't tear down our forming call. Root + design from the internal research workflow. Not yet validated on device. --- docs/plans/telecom_migration.md | 22 +++ src/app/hooks/useIncomingRtcNotifications.ts | 12 +- src/app/hooks/usePendingCallActionConsumer.ts | 9 +- src/app/hooks/useSwitchOrStartDmCall.ts | 163 +++++++++++------- src/app/state/callEmbed.ts | 13 ++ 5 files changed, 150 insertions(+), 69 deletions(-) diff --git a/docs/plans/telecom_migration.md b/docs/plans/telecom_migration.md index d576e418..136f9de2 100644 --- a/docs/plans/telecom_migration.md +++ b/docs/plans/telecom_migration.md @@ -551,3 +551,25 @@ Telecom — чисто Android-нативный слой; web/Electron/iOS ег **F38** OverLockCallScreen inline-styles → css.ts (риск визуальной регрессии); **F39/F40** nit-рефакторы. Всё glare-критичное на железе (2 аккаунта Samsung): перекрёстный звонок → cancel → перезвон в окне 8s; decline→перезвон; hangup одного при живом втором → перезвон; answer-from-killed под lock с токеном. +- **2026-07-03 — Прогон на Samsung + research-воркфлоу (5 агентов по open-source Matrix/telecom) + фикс glare-коллапса (fix 1).** + На живом железе (@heaven Android ↔ @test6 web) подтверждено: применённые фиксы работают (двойной-answer guard, answered-keeps-session, + gen-смена сессий, FGS-тип 132, **ноль крашей/BAL**). Но одновременный старт звонка с обоих устройств (glare) **схлопывал обе попытки**. + - **Корень (research `glare` + код):** в MatrixRTC нет «ответа» — звонок это **co-presence** (оба просто члены одной RTC-сессии; MSC3401 glare + для групповых не разрешает — клиентское дело). Наш баг: два РАЗНЫХ инстанса `useSwitchOrStartDmCall` (исходящий в хедере + «ответ» в strip) с + раздельными `inFlightRef` не сериализуются, но пишут общий `callEmbedAtom`. В окне `waitOwnMembershipGone` (до 2.5с) атом пуст → `genuinelyParticipating` + не подавляет встречный ring → strip → Accept → второй инстанс → узкий same-room-guard (`prev.joined && peerPresent`) не ловит формирующийся эмбед → + zombie dispose+recreate → **два MembershipManager на одном (user,device) state-key** → `[MembershipManager] Missing own membership: force re-join` + по кругу, членство 0→1→2→1→0, `TypeError: a is not iterable` (краш внутри EC-бандла). element-x-android НИКОГДА не плодит второй call-объект. + - **Фикс 1 (co-presence, не answer):** новый `formingCallRoomIdAtom` (state/callEmbed.ts) — roomId формирующегося исходящего, ставится СИНХРОННО до + await'ов. (a) `doSwitchOrStart`: ранний no-op `if (incoming && (prev.roomId===roomId || forming===roomId)) return` (модель element-x); (b) маркер + ставится перед disposal/waitOwnMembershipGone, чистится в `finally` после `callEmbedAtom`; (c) `genuinelyParticipating` подавляет встречный ring по + маркеру (strip не рендерится, Accept недостижим); (d) guard в `usePendingCallActionConsumer` — не рвать формирующийся свой звонок. tsc/eslint/prettier — зелёное. + - **⚠️ НЕ провалидировано на железе** — нужен повторный glare-прогон: одновременный старт → ДОЛЖЕН выйти один 2-сторонний звонок без force-re-join и скачков членства. + - **Отложенное (ранжировано research, impact/effort):** (1) **FCM cancel-маркер** [high/M] — звонящий на отмене эмитит `m.rtc.notification` со + строковым `content_notification_type='cancel'` + `m.mentions.user_ids=[callee]` (триггерит дефолтное `.m.rule.is_user_mention`, всегда пушит сквозь + Sygnal-flatten); нативная ветка 'cancel' рядом с 'ring' → снимает CallStyle/ring/FGS. Закрывает и висящий ринг, и висящий teardown при замороженном + WebView. БЕЗ правки Synapse-конфига. (2) **host-side ring** [med/M] — слать ring самим (`mx.sendEvent` плоский IRTCNotificationContent) вместо + one-shot виджета; отключить ring виджета. (3) **EC-бандл 0.16.3→0.20.3** [med/M] — не чинит glare (наш хост), но закрывает **CVE-2026-48007** + (утечка URL в analytics, фикс с 0.19.4) + снижает крэш-шум (v0.17 rejoin-crash #3693, v0.18 circular-logger #3733 — кандидаты на `a is not iterable`); + держать multi-SFU off, пропустить 0.21-rc. (4) **не-фриз WebView / нативный teardown** [med/L] — research-агент вернул пусто, нужно отдельное копание + (фризит ли фоновый WebView EC-teardown; как element-x держит WebView живым). diff --git a/src/app/hooks/useIncomingRtcNotifications.ts b/src/app/hooks/useIncomingRtcNotifications.ts index b471336e..ef8607bb 100644 --- a/src/app/hooks/useIncomingRtcNotifications.ts +++ b/src/app/hooks/useIncomingRtcNotifications.ts @@ -15,7 +15,7 @@ // the same dedup key re-trigger. import { useEffect, useRef } from 'react'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { useAtomValue, useSetAtom, useStore } from 'jotai'; import { EventType, MatrixClient, @@ -33,7 +33,7 @@ import { CallMembership, SessionMembershipData } from 'matrix-js-sdk/lib/matrixr import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; import { useMatrixClient } from './useMatrixClient'; import { mDirectAtom } from '../state/mDirectList'; -import { callEmbedAtom } from '../state/callEmbed'; +import { callEmbedAtom, formingCallRoomIdAtom } from '../state/callEmbed'; import { incomingCallsAtom } from '../state/incomingCalls'; import { getIncomingCallKey, @@ -173,6 +173,7 @@ export const useIncomingRtcNotifications = (): void => { const callEmbed = useAtomValue(callEmbedAtom); const incoming = useAtomValue(incomingCallsAtom); const setIncoming = useSetAtom(incomingCallsAtom); + const store = useStore(); const mDirectRef = useRef(mDirect); mDirectRef.current = mDirect; @@ -405,6 +406,11 @@ export const useIncomingRtcNotifications = (): void => { // uses the same predicate. const session = mx.matrixRTC.getRoomSession(room); const genuinelyParticipating = (): boolean => { + // Glare: our own OUTGOING call to this room is forming (embed not yet in + // callEmbedAtom, waitOwnMembershipGone still awaiting). The counter-ring is + // just the peer joining the same session — suppress it so no strip renders + // and no competing "answer" embed is spawned (co-presence, not answer). + if (store.get(formingCallRoomIdAtom) === room.roomId) return true; if (callEmbedRef.current?.roomId === room.roomId) return true; const selfId = mx.getUserId(); const selfDeviceId = mx.getDeviceId(); @@ -611,5 +617,5 @@ export const useIncomingRtcNotifications = (): void => { peerLeaveTimers.forEach((t) => clearTimeout(t)); peerLeaveTimers.clear(); }; - }, [mx, setIncoming]); + }, [mx, setIncoming, store]); }; diff --git a/src/app/hooks/usePendingCallActionConsumer.ts b/src/app/hooks/usePendingCallActionConsumer.ts index 56c5a652..d83cf8e4 100644 --- a/src/app/hooks/usePendingCallActionConsumer.ts +++ b/src/app/hooks/usePendingCallActionConsumer.ts @@ -3,7 +3,7 @@ import { useAtomValue, useSetAtom, useStore } from 'jotai'; import { App } from '@capacitor/app'; import { pendingCallActionAtom } from '../state/pendingCallAction'; import { incomingCallsAtom } from '../state/incomingCalls'; -import { callEmbedAtom, overLockCallAtom } from '../state/callEmbed'; +import { callEmbedAtom, formingCallRoomIdAtom, overLockCallAtom } from '../state/callEmbed'; import { useMatrixClient } from './useMatrixClient'; import { isNativePlatform } from '../utils/capacitor'; import { useSwitchOrStartDmCall } from './useSwitchOrStartDmCall'; @@ -92,8 +92,11 @@ export const usePendingCallActionConsumer = (): void => { // screen would strand the user on a spinner, so bail out of over-lock. // No embed means the joined/embed-lifecycle teardowns won't run either, // so end the Telecom session AND stop the ring-time phoneCall FGS here - // (both leak otherwise — H2). - if (!store.get(callEmbedAtom)) { + // (both leak otherwise — H2). Skip the teardown when we are forming our + // OWN outgoing call to this room (glare): switchOrStartDmCall no-ops on + // co-presence, so the empty atom here is transient (our embed lands a + // beat later) — tearing down would kill the call we're establishing. + if (!store.get(callEmbedAtom) && store.get(formingCallRoomIdAtom) !== roomId) { telecomCall.endCall().catch(() => undefined); callForegroundService.stop().catch(() => undefined); leaveOverLock(); diff --git a/src/app/hooks/useSwitchOrStartDmCall.ts b/src/app/hooks/useSwitchOrStartDmCall.ts index ec1032af..ed5b5303 100644 --- a/src/app/hooks/useSwitchOrStartDmCall.ts +++ b/src/app/hooks/useSwitchOrStartDmCall.ts @@ -32,7 +32,7 @@ import { useMatrixClient } from './useMatrixClient'; import { useTheme } from './useTheme'; import { createCallEmbed, useCallEmbedRef } from './useCallEmbed'; import { useCallPreferencesAtom } from '../state/hooks/callPreferences'; -import { callEmbedAtom } from '../state/callEmbed'; +import { callEmbedAtom, formingCallRoomIdAtom } from '../state/callEmbed'; import { CallEmbed } from '../plugins/call'; const SWITCH_LEAVE_TIMEOUT_MS = 3000; @@ -166,6 +166,20 @@ export const useSwitchOrStartDmCall = (): (( } const prev = store.get(callEmbedAtom); + + // Glare co-presence. MatrixRTC has no "answer" — a DM call is co-presence in + // the RTC session, so when we already hold OR are forming an embed for this + // room, a counter-ring for it is just the peer joining the SAME session. + // Answering it would dispose+recreate our embed, putting a second + // MembershipManager on the same (user, device) state-key → the "Missing own + // membership: force re-join" thrash that collapses both calls. Mirror + // element-x-android (any active/forming call → ignore the incoming), matched + // by roomId. `formingCallRoomIdAtom` is set synchronously below before the + // outgoing path's awaits, so this beats the callEmbedAtom-empty window. + if (incoming && (prev?.roomId === roomId || store.get(formingCallRoomIdAtom) === roomId)) { + return; + } + if (prev?.roomId === roomId) { // Healthy same-room call → no-op. "Peer present" is USER-scoped: only the // OTHER user answering makes the call healthy. A device-scoped check @@ -185,73 +199,96 @@ export const useSwitchOrStartDmCall = (): (( throw new Error('Failed to start call, No embed container element found!'); } - // Tracks a same-room zombie whose waitLeave TIMED OUT (unresponsive widget). - // In that case the widget can never send its leave, so the own-membership - // wait below would just burn its full fail-open budget for nothing — a 2nd - // black-screen delay stacked on the zombie timeout (self-review regression). - // We skip it there; a widget that DID respond (or a fresh start / switch) - // still runs the wait, which is instant once our membership is gone. - let zombieLeaveTimedOut = false; - if (prev) { - if (prev.roomId === roomId) { - // Zombie same-room embed (widget never joined, peer gone, or stuck - // after LiveKit reconnect fail). Give a JOINED widget a bounded chance - // to send its server-side leave before disposing: waitLeave arms the - // membership/hangup barrier, then hangs up. If the widget responds our - // membership clears (so the re-dial rings — F1/F2); if it's - // unresponsive the short timeout fails open and we dispose anyway. A - // never-joined zombie has no membership to leave, so skip straight to - // dispose. - if (prev.joined) { + // Mark this room "call forming" synchronously, before any await, so a + // concurrent incoming-ring instance (a separate useSwitchOrStartDmCall with + // its own inFlightRef) and the ring-suppress hook see co-presence during the + // outgoing embed's forming window — otherwise the glare counter-ring surfaces + // while callEmbedAtom is still empty. Only the outgoing path forms; an answer + // reuses/joins. Cleared in `finally` once callEmbedAtom carries the embed (or + // on failure) so ring suppression can't stick. + if (!incoming) { + store.set(formingCallRoomIdAtom, roomId); + } + try { + // Tracks a same-room zombie whose waitLeave TIMED OUT (unresponsive widget). + // In that case the widget can never send its leave, so the own-membership + // wait below would just burn its full fail-open budget for nothing — a 2nd + // black-screen delay stacked on the zombie timeout (self-review regression). + // We skip it there; a widget that DID respond (or a fresh start / switch) + // still runs the wait, which is instant once our membership is gone. + let zombieLeaveTimedOut = false; + if (prev) { + if (prev.roomId === roomId) { + // Zombie same-room embed (widget never joined, peer gone, or stuck + // after LiveKit reconnect fail). Give a JOINED widget a bounded chance + // to send its server-side leave before disposing: waitLeave arms the + // membership/hangup barrier, then hangs up. If the widget responds our + // membership clears (so the re-dial rings — F1/F2); if it's + // unresponsive the short timeout fails open and we dispose anyway. A + // never-joined zombie has no membership to leave, so skip straight to + // dispose. + if (prev.joined) { + try { + await waitLeave(prev, ZOMBIE_LEAVE_TIMEOUT_MS); + } catch { + // fail-open: unresponsive widget — dispose + start regardless, and + // don't wait again below (its membership won't clear either way). + zombieLeaveTimedOut = true; + } + } + prev.dispose(); + } else { + // Wait for a clean leave, but NEVER let the 3s barrier timeout strand + // the previous embed. The SDK's delayed-leave (~8s) reconciles the + // server-side membership regardless, so on timeout we dispose and + // switch anyway instead of throwing — which previously left `prev` in + // callEmbedAtom, never started the new call, and made every retry + // re-arm waitLeave on the now-unresponsive widget (switch wedged until + // reload). Fail-open beats fail-stuck. try { - await waitLeave(prev, ZOMBIE_LEAVE_TIMEOUT_MS); - } catch { - // fail-open: unresponsive widget — dispose + start regardless, and - // don't wait again below (its membership won't clear either way). - zombieLeaveTimedOut = true; + await waitLeave(prev); + } catch (err) { + // eslint-disable-next-line no-console + console.warn( + '[dm-call] waitLeave timed out; disposing prev and switching anyway', + err + ); + } finally { + prev.dispose(); } } - prev.dispose(); - } else { - // Wait for a clean leave, but NEVER let the 3s barrier timeout strand - // the previous embed. The SDK's delayed-leave (~8s) reconciles the - // server-side membership regardless, so on timeout we dispose and - // switch anyway instead of throwing — which previously left `prev` in - // callEmbedAtom, never started the new call, and made every retry - // re-arm waitLeave on the now-unresponsive widget (switch wedged until - // reload). Fail-open beats fail-stuck. - try { - await waitLeave(prev); - } catch (err) { - // eslint-disable-next-line no-console - console.warn('[dm-call] waitLeave timed out; disposing prev and switching anyway', err); - } finally { - prev.dispose(); - } + } + + // Outgoing (not answering): make sure our own membership from a prior call + // in this room has actually left before creating the widget, or the SDK's + // first-member ring gate drops the outgoing ring (F1). Bounded + fail-open; + // instant when we hold no membership. Skipped when answering (join_existing + // intent never rings) and after a timed-out zombie leave (dead widget — the + // membership can't clear, so don't stack a second black-screen wait). + if (!incoming && !zombieLeaveTimedOut) { + await waitOwnMembershipGone(room, OWN_LEAVE_TIMEOUT_MS); + } + + const embed = createCallEmbed( + mx, + room, + true, + theme.kind, + container, + callPref, + true, + incoming + ); + store.set(callEmbedAtom, embed); + } finally { + // Once the embed is in the atom, co-presence rides callEmbedAtom; if we + // threw before that, clear the marker so a failed start doesn't wedge ring + // suppression for this room. Guarded so a newer start for another room + // (which reset the marker) isn't clobbered. + if (store.get(formingCallRoomIdAtom) === roomId) { + store.set(formingCallRoomIdAtom, undefined); } } - - // Outgoing (not answering): make sure our own membership from a prior call - // in this room has actually left before creating the widget, or the SDK's - // first-member ring gate drops the outgoing ring (F1). Bounded + fail-open; - // instant when we hold no membership. Skipped when answering (join_existing - // intent never rings) and after a timed-out zombie leave (dead widget — the - // membership can't clear, so don't stack a second black-screen wait). - if (!incoming && !zombieLeaveTimedOut) { - await waitOwnMembershipGone(room, OWN_LEAVE_TIMEOUT_MS); - } - - const embed = createCallEmbed( - mx, - room, - true, - theme.kind, - container, - callPref, - true, - incoming - ); - store.set(callEmbedAtom, embed); }, [mx, store, callEmbedRef, callPref, theme, waitLeave, waitOwnMembershipGone] ); diff --git a/src/app/state/callEmbed.ts b/src/app/state/callEmbed.ts index 2c15fb79..d209f962 100644 --- a/src/app/state/callEmbed.ts +++ b/src/app/state/callEmbed.ts @@ -17,6 +17,19 @@ export const callEmbedAtom = atom(undefined); + export const callChatAtom = atom(false); // In-call loudspeaker state (true = громкая связь / speaker, false = earpiece).