fix(calls): resolve call glare via co-presence, not answer
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.
This commit is contained in:
parent
28d266964e
commit
0f5bc33619
5 changed files with 150 additions and 69 deletions
|
|
@ -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 живым).
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,19 @@ export const callEmbedAtom = atom<CallEmbed | undefined, [CallEmbed | undefined]
|
|||
}
|
||||
);
|
||||
|
||||
// Room id of a DM call whose OUTGOING embed is currently forming — set
|
||||
// synchronously the moment the call starts, before the async
|
||||
// doSwitchOrStart awaits (waitOwnMembershipGone can take up to 2.5s, during
|
||||
// which callEmbedAtom is still empty). It is the deterministic "we are already
|
||||
// establishing a call in this room" signal used to resolve glare: MatrixRTC has
|
||||
// no "answer" — a call is co-presence, so a counter-ring for a room we're
|
||||
// already calling must NOT spawn a second embed (that would put two
|
||||
// MembershipManagers on one state-key → "Missing own membership: force re-join"
|
||||
// thrash). Consulted by useSwitchOrStartDmCall (co-presence no-op) and
|
||||
// useIncomingRtcNotifications (ring suppression). Cleared once callEmbedAtom
|
||||
// carries the embed. Single-call model → one room at a time.
|
||||
export const formingCallRoomIdAtom = atom<string | undefined>(undefined);
|
||||
|
||||
export const callChatAtom = atom<boolean>(false);
|
||||
|
||||
// In-call loudspeaker state (true = громкая связь / speaker, false = earpiece).
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue