After a reload/restart, a call that already ended (or that you rejected) rings again and the DM shows "1 Live" + the call screen for minutes #312

Closed
opened 2026-10-05 01:04:06 -04:00 by jared · 1 comment
Owner

Repro (Playwright, two users, local Synapse + LiveKit):

  1. Bob starts a voice call in the DM. Your client rings.
  2. Reject it, or ignore it. Bob hangs up.
  3. Within the ring lifetime (90 s), reload the tab or restart the app.

Result. The server says nobody is in the call: every org.matrix.msc3401.call.member state for the DM is empty. The reloaded client nevertheless:

  • rings again ("Incoming Voice Call · Answer · Reject", with the ringtone) until the 90 s lifetime runs out, and does so whether you had rejected or ignored the call;
  • shows "1 Live" on the DM in the room list and opens the DM into the call screen (Join button, no timeline, no composer) instead of the chat;
  • keeps "1 Live" and the call screen for at least 5.5 minutes, until the next call event in that room. The cached membership has expires: 14400000 (4 h).

Answering the phantom ring joins an empty call.

It only happens when the client starts from its IndexedDB cache:

live tab, 4 s after Bob hung up ............ no "Live"
fresh login (no cache) during the window ... no "Live"
same profile after reload .................. 1 Live + RING (to ~90 s), then 1 Live (still at +330 s)

Root cause (instrumented by exposing the client in a dev build): after the reload the room state is correct, but the MatrixRTC session is stale:

after reload: room state = [@aud_bob…: out @04:53:22, …all out]   rtc session = ["aud_bob/GUJNQYGMHZ"]   pill = 1 Live
manual mx.matrixRTC.refreshRoom(room) → session members = 0, pill gone

The catch-up /sync from the cached token contains Bob's join and leave in one batch.

  • MatrixRTCSessionManager.onRoomState calls refreshRoom() once per event. That runs the async session._onRTCSessionMemberUpdate(), and in matrix-js-sdk 41.7.0 the last result to finish wins, which here is the join.
  • Separately, CallEmbedProvider.handleTimelineEvent (src/app/components/CallEmbedProvider.tsx:536-605) sees the replayed rtc.notification as liveEvent and starts ringing because Date.now() < senderTs + lifetime.
  • Your own rtc.decline, which is in the same batch, was processed before the ring's dismiss listener existed, so it is never seen.
  • The 5 s "caller gone" check reads the stale session, so it doesn't dismiss either.

Fix:

  • Ring: before ringing, check the room's current state. Skip if the caller has no non-empty call.member for the referenced call, or if the timeline already contains our own RTCDecline (or our own membership) for refEventId. Treating notifications that arrive in the first sync after start (SyncState.Catchup or the initial Prepared) as "maybe stale" and re-checking them after the first Syncing would also work.
  • Stale session: after the first sync completes, call mx.matrixRTC.refreshRoom(room) for every room with a non-empty session, or debounce refreshes per room. It's worth reporting the refresh race upstream (matrix-js-sdk). Note: I could not reproduce it for a voice room where Bob joined and left while the app was fully closed (reopened: no phantom). It needs the tab to have been open during the call and reloaded shortly after, which is the common "restart the app right after a call" case.
  • Test: a Playwright case that rejects a ring, lets the caller hang up, reloads within the lifetime, and asserts no ring and no "Live".

Also verified in this pass, and working:

  • both of your sessions ring;
  • answering on one stops the other within 4 s;
  • Reject on one sends org.matrix.msc4310.rtc.decline and stops both;
  • the "Missed call from Bob Builder" line appears live about 3 s after the caller hangs up.

Screenshot

phantom-ring-after-reload.png


Found in the October 2026 client audit (Playwright against a local Synapse + LiveKit stack, plus code reading). Code refs are at cinny 30e2dbcd, cinny-desktop 8f67ec7, element-call 3b0050e8.

**Repro** (Playwright, two users, local Synapse + LiveKit): 1. Bob starts a voice call in the DM. Your client rings. 2. Reject it, or ignore it. Bob hangs up. 3. Within the ring lifetime (90 s), reload the tab or restart the app. **Result.** The server says nobody is in the call: every `org.matrix.msc3401.call.member` state for the DM is empty. The reloaded client nevertheless: - **rings again** ("Incoming Voice Call · Answer · Reject", with the ringtone) until the 90 s lifetime runs out, and does so whether you had rejected or ignored the call; - shows **"1 Live"** on the DM in the room list and opens the DM into the **call screen** (Join button, no timeline, no composer) instead of the chat; - keeps "1 Live" and the call screen for **at least 5.5 minutes**, until the next call event in that room. The cached membership has `expires: 14400000` (4 h). Answering the phantom ring joins an empty call. **It only happens when the client starts from its IndexedDB cache:** ``` live tab, 4 s after Bob hung up ............ no "Live" fresh login (no cache) during the window ... no "Live" same profile after reload .................. 1 Live + RING (to ~90 s), then 1 Live (still at +330 s) ``` **Root cause (instrumented by exposing the client in a dev build):** after the reload the **room state is correct**, but the **MatrixRTC session is stale**: ``` after reload: room state = [@aud_bob…: out @04:53:22, …all out] rtc session = ["aud_bob/GUJNQYGMHZ"] pill = 1 Live manual mx.matrixRTC.refreshRoom(room) → session members = 0, pill gone ``` The catch-up `/sync` from the cached token contains Bob's join *and* leave in one batch. - `MatrixRTCSessionManager.onRoomState` calls `refreshRoom()` once per event. That runs the async `session._onRTCSessionMemberUpdate()`, and in matrix-js-sdk 41.7.0 the last result to finish wins, which here is the join. - Separately, `CallEmbedProvider.handleTimelineEvent` (src/app/components/CallEmbedProvider.tsx:536-605) sees the replayed `rtc.notification` as `liveEvent` and starts ringing because `Date.now() < senderTs + lifetime`. - Your own `rtc.decline`, which is in the same batch, was processed **before** the ring's dismiss listener existed, so it is never seen. - The 5 s "caller gone" check reads the stale session, so it doesn't dismiss either. **Fix:** - **Ring:** before ringing, check the room's *current state*. Skip if the caller has no non-empty `call.member` for the referenced call, or if the timeline already contains our own `RTCDecline` (or our own membership) for `refEventId`. Treating notifications that arrive in the first sync after start (`SyncState.Catchup` or the initial `Prepared`) as "maybe stale" and re-checking them after the first `Syncing` would also work. - **Stale session:** after the first sync completes, call `mx.matrixRTC.refreshRoom(room)` for every room with a non-empty session, or debounce refreshes per room. It's worth reporting the refresh race upstream (matrix-js-sdk). Note: I could **not** reproduce it for a voice room where Bob joined and left while the app was fully closed (reopened: no phantom). It needs the tab to have been open during the call and reloaded shortly after, which is the common "restart the app right after a call" case. - **Test:** a Playwright case that rejects a ring, lets the caller hang up, reloads within the lifetime, and asserts no ring and no "Live". Also verified in this pass, and working: - both of your sessions ring; - answering on one stops the other within 4 s; - Reject on one sends `org.matrix.msc4310.rtc.decline` and stops both; - the "Missed call from Bob Builder" line appears live about 3 s after the caller hangs up. **Screenshot** ![phantom-ring-after-reload.png](https://code.lotusguild.org/attachments/b348984c-5198-4f3a-aa25-09d1ad7133a6) --- _Found in the October 2026 client audit (Playwright against a local Synapse + LiveKit stack, plus code reading). Code refs are at cinny `30e2dbcd`, cinny-desktop `8f67ec7`, element-call `3b0050e8`._
jared added the bugpriority: higharea: calls labels 2026-10-05 01:04:06 -04:00
Author
Owner

Related: #302 ("Rejoin voice?" offered for DM calls that already ended) reads the same stale session after a reload.

Related: #302 ("Rejoin voice?" offered for DM calls that already ended) reads the same stale session after a reload.
jared closed this issue 2026-10-16 22:24:06 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/cinny#312