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-рефакторы.
|
**F38** OverLockCallScreen inline-styles → css.ts (риск визуальной регрессии); **F39/F40** nit-рефакторы.
|
||||||
Всё glare-критичное на железе (2 аккаунта Samsung): перекрёстный звонок → cancel → перезвон в окне 8s; decline→перезвон;
|
Всё glare-критичное на железе (2 аккаунта Samsung): перекрёстный звонок → cancel → перезвон в окне 8s; decline→перезвон;
|
||||||
hangup одного при живом втором → перезвон; answer-from-killed под lock с токеном.
|
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.
|
// the same dedup key re-trigger.
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom, useStore } from 'jotai';
|
||||||
import {
|
import {
|
||||||
EventType,
|
EventType,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
|
|
@ -33,7 +33,7 @@ import { CallMembership, SessionMembershipData } from 'matrix-js-sdk/lib/matrixr
|
||||||
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
|
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
import { callEmbedAtom } from '../state/callEmbed';
|
import { callEmbedAtom, formingCallRoomIdAtom } from '../state/callEmbed';
|
||||||
import { incomingCallsAtom } from '../state/incomingCalls';
|
import { incomingCallsAtom } from '../state/incomingCalls';
|
||||||
import {
|
import {
|
||||||
getIncomingCallKey,
|
getIncomingCallKey,
|
||||||
|
|
@ -173,6 +173,7 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
const callEmbed = useAtomValue(callEmbedAtom);
|
const callEmbed = useAtomValue(callEmbedAtom);
|
||||||
const incoming = useAtomValue(incomingCallsAtom);
|
const incoming = useAtomValue(incomingCallsAtom);
|
||||||
const setIncoming = useSetAtom(incomingCallsAtom);
|
const setIncoming = useSetAtom(incomingCallsAtom);
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
const mDirectRef = useRef(mDirect);
|
const mDirectRef = useRef(mDirect);
|
||||||
mDirectRef.current = mDirect;
|
mDirectRef.current = mDirect;
|
||||||
|
|
@ -405,6 +406,11 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
// uses the same predicate.
|
// uses the same predicate.
|
||||||
const session = mx.matrixRTC.getRoomSession(room);
|
const session = mx.matrixRTC.getRoomSession(room);
|
||||||
const genuinelyParticipating = (): boolean => {
|
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;
|
if (callEmbedRef.current?.roomId === room.roomId) return true;
|
||||||
const selfId = mx.getUserId();
|
const selfId = mx.getUserId();
|
||||||
const selfDeviceId = mx.getDeviceId();
|
const selfDeviceId = mx.getDeviceId();
|
||||||
|
|
@ -611,5 +617,5 @@ export const useIncomingRtcNotifications = (): void => {
|
||||||
peerLeaveTimers.forEach((t) => clearTimeout(t));
|
peerLeaveTimers.forEach((t) => clearTimeout(t));
|
||||||
peerLeaveTimers.clear();
|
peerLeaveTimers.clear();
|
||||||
};
|
};
|
||||||
}, [mx, setIncoming]);
|
}, [mx, setIncoming, store]);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useAtomValue, useSetAtom, useStore } from 'jotai';
|
||||||
import { App } from '@capacitor/app';
|
import { App } from '@capacitor/app';
|
||||||
import { pendingCallActionAtom } from '../state/pendingCallAction';
|
import { pendingCallActionAtom } from '../state/pendingCallAction';
|
||||||
import { incomingCallsAtom } from '../state/incomingCalls';
|
import { incomingCallsAtom } from '../state/incomingCalls';
|
||||||
import { callEmbedAtom, overLockCallAtom } from '../state/callEmbed';
|
import { callEmbedAtom, formingCallRoomIdAtom, overLockCallAtom } from '../state/callEmbed';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { isNativePlatform } from '../utils/capacitor';
|
import { isNativePlatform } from '../utils/capacitor';
|
||||||
import { useSwitchOrStartDmCall } from './useSwitchOrStartDmCall';
|
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.
|
// 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,
|
// 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
|
// so end the Telecom session AND stop the ring-time phoneCall FGS here
|
||||||
// (both leak otherwise — H2).
|
// (both leak otherwise — H2). Skip the teardown when we are forming our
|
||||||
if (!store.get(callEmbedAtom)) {
|
// 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);
|
telecomCall.endCall().catch(() => undefined);
|
||||||
callForegroundService.stop().catch(() => undefined);
|
callForegroundService.stop().catch(() => undefined);
|
||||||
leaveOverLock();
|
leaveOverLock();
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ import { useMatrixClient } from './useMatrixClient';
|
||||||
import { useTheme } from './useTheme';
|
import { useTheme } from './useTheme';
|
||||||
import { createCallEmbed, useCallEmbedRef } from './useCallEmbed';
|
import { createCallEmbed, useCallEmbedRef } from './useCallEmbed';
|
||||||
import { useCallPreferencesAtom } from '../state/hooks/callPreferences';
|
import { useCallPreferencesAtom } from '../state/hooks/callPreferences';
|
||||||
import { callEmbedAtom } from '../state/callEmbed';
|
import { callEmbedAtom, formingCallRoomIdAtom } from '../state/callEmbed';
|
||||||
import { CallEmbed } from '../plugins/call';
|
import { CallEmbed } from '../plugins/call';
|
||||||
|
|
||||||
const SWITCH_LEAVE_TIMEOUT_MS = 3000;
|
const SWITCH_LEAVE_TIMEOUT_MS = 3000;
|
||||||
|
|
@ -166,6 +166,20 @@ export const useSwitchOrStartDmCall = (): ((
|
||||||
}
|
}
|
||||||
|
|
||||||
const prev = store.get(callEmbedAtom);
|
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) {
|
if (prev?.roomId === roomId) {
|
||||||
// Healthy same-room call → no-op. "Peer present" is USER-scoped: only the
|
// Healthy same-room call → no-op. "Peer present" is USER-scoped: only the
|
||||||
// OTHER user answering makes the call healthy. A device-scoped check
|
// OTHER user answering makes the call healthy. A device-scoped check
|
||||||
|
|
@ -185,6 +199,17 @@ export const useSwitchOrStartDmCall = (): ((
|
||||||
throw new Error('Failed to start call, No embed container element found!');
|
throw new Error('Failed to start call, No embed container element found!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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).
|
// 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
|
// 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
|
// wait below would just burn its full fail-open budget for nothing — a 2nd
|
||||||
|
|
@ -224,7 +249,10 @@ export const useSwitchOrStartDmCall = (): ((
|
||||||
await waitLeave(prev);
|
await waitLeave(prev);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn('[dm-call] waitLeave timed out; disposing prev and switching anyway', err);
|
console.warn(
|
||||||
|
'[dm-call] waitLeave timed out; disposing prev and switching anyway',
|
||||||
|
err
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
prev.dispose();
|
prev.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -252,6 +280,15 @@ export const useSwitchOrStartDmCall = (): ((
|
||||||
incoming
|
incoming
|
||||||
);
|
);
|
||||||
store.set(callEmbedAtom, embed);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[mx, store, callEmbedRef, callPref, theme, waitLeave, waitOwnMembershipGone]
|
[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);
|
export const callChatAtom = atom<boolean>(false);
|
||||||
|
|
||||||
// In-call loudspeaker state (true = громкая связь / speaker, false = earpiece).
|
// In-call loudspeaker state (true = громкая связь / speaker, false = earpiece).
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue