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:
heaven 2026-07-04 02:40:47 +03:00
parent 28d266964e
commit 0f5bc33619
5 changed files with 150 additions and 69 deletions

View file

@ -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 живым).

View file

@ -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]);
};

View file

@ -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();

View file

@ -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]
);

View file

@ -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).