Skip to content

fix(call): answer a far end's new session on a fresh connection - #200

Merged
TheCryptoDonkey merged 1 commit into
mainfrom
fix/peer-new-remote-session
Sep 27, 2026
Merged

TheCryptoDonkey merged 1 commit into
mainfrom
fix/peer-new-remote-session

Conversation

@TheCryptoDonkey

Copy link
Copy Markdown
Member

Web half of kithmoot-android #107 (in Android 0.6.23). On the owner's 5-person call, people went silent to one device while still hearing it, after a far end rebuilt its connection.

Root cause: the mesh rebuilds only when a roster sid changes and both old and new are known; Android publishes none and a page that rebuilt its own connection keeps its sid, so the far end's new offer went to the old RTCPeerConnection. Chromium refused every copy (setRemoteDescription m-line order); the rebuilding side stayed unheard until the old connection timed out and ICE-restarted. #senderRefused was not involved.

  • src/sdp-shape.ts: read o= session id and ufrag; detect a new far-end session; recognise the m-line refusal.
  • src/peer.ts: both changed = new session, checked before glare handling; handed to the mesh once with any early candidates (onRemoteRestart); fallback on the refusal when the session id differs; ufrag alone = ICE restart, stays.
  • src/mesh.ts #replaceForRemoteSession: close old peer, build new one like #downgradePeer (tracks first, then offer and carried candidates); remembers retired session ids so a late copy of an old offer causes no second rebuild. Forwarder excluded; profile-2 unchanged.

Tests: unit (new-session answered with tracks, impolite glare, refusal fallback, ICE restart not rebuilt, retransmissions = one rebuild, early candidates carried, web↔web); new e2e in call-stability.spec fails without the fix; npm test 2,639 pass; typecheck; Playwright chromium 32/32 (call-stability incl. Firefox receiver, media, speaking, volume). Not yet tested live against Android 0.6.23.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CG4pPCsd8pdySNvBpt8fTk

A far end that rebuilds its RTCPeerConnection and offers from the new one
reached this side's old connection: the roster says nothing (Android
publishes no page session, and a web client that rebuilt keeps its own),
so the old peer was kept. Chromium refused every copy of that offer on
the old connection, and where the m-lines happened to line up the answer
came from senders that believed their tracks were negotiated already.
Either way that person went unheard while they still heard us.

A profile-1 peer now remembers the o= session id and ICE ufrag of the
last remote description it applied. An offer where both have moved is a
new far-end session, judged before glare handling; the connection's own
m-line refusal is the fallback when the session id alone changed. The
peer hands the offer to the mesh, which closes it and builds a fresh one
the way a downgrade does: tracks first, then the offer, so the answer
carries this side's media. Once per peer; retransmissions are answered
by the replacement from store, and a late copy from the retired session
is dropped. Candidates naming credentials not yet applied are held
rather than refused, and ones that overtook the new offer go with it.
An ICE restart (ufrag alone) stays on the existing connection.

The fake connection now mints a session id and ufrag per connection and
writes m-lines in negotiated order.

Claude-Session: https://claude.ai/code/session_01CG4pPCsd8pdySNvBpt8fTk
@TheCryptoDonkey
TheCryptoDonkey merged commit 02e449b into main Sep 27, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant