fix: handle WebRTC offer glare during renegotiation

When two peers simultaneously trigger renegotiation (e.g., both enable
video), both create local offers and enter HAVE_LOCAL_OFFER state. When
a remote offer arrives in this state, setRemoteDescription fails with
"Called in wrong state: have-local-offer".

Fix by implementing standard WebRTC glare handling: use pubkey comparison
as a tiebreaker (higher pubkey wins). The losing peer rolls back their
local offer before accepting the remote one.

https://claude.ai/code/session_01XLbnNVx3GDhHrPZKPXfFdD
This commit is contained in:
Claude
2026-04-03 21:41:49 +00:00
parent 01618e71fc
commit 8169c40bb6
2 changed files with 56 additions and 4 deletions
@@ -539,11 +539,37 @@ class CallController(
Log.d(TAG) { "Renegotiation offer from ${peerPubKey.take(8)}, sdpLength=${sdpOffer.length}" }
scope.launch {
ps.session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
ps.session.createAnswer { sdp ->
scope.launch {
callManager.sendRenegotiationAnswer(sdp.description, peerPubKey)
val signalingState = ps.session.getSignalingState()
if (signalingState == PeerConnection.SignalingState.HAVE_LOCAL_OFFER) {
// Glare: both sides sent offers simultaneously.
// Use pubkey comparison as tiebreaker — higher pubkey wins.
val myPubKey = signerProvider().pubKey
if (myPubKey > peerPubKey) {
Log.d(TAG) { "Glare with ${peerPubKey.take(8)}: I win (my offer takes priority), ignoring remote offer" }
return@launch
}
Log.d(TAG) { "Glare with ${peerPubKey.take(8)}: I lose, rolling back my offer" }
ps.session.rollback {
scope.launch {
applyRenegotiationOffer(ps, peerPubKey, sdpOffer)
}
}
} else {
applyRenegotiationOffer(ps, peerPubKey, sdpOffer)
}
}
}
private fun applyRenegotiationOffer(
ps: PeerSessionState,
peerPubKey: HexKey,
sdpOffer: String,
) {
ps.session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
ps.session.createAnswer { sdp ->
scope.launch {
callManager.sendRenegotiationAnswer(sdp.description, peerPubKey)
}
}
}
@@ -257,6 +257,32 @@ class WebRtcCallSession(
fun getSignalingState(): PeerConnection.SignalingState? = peerConnection?.signalingState()
/**
* Rolls back a local offer so an incoming remote offer can be accepted
* (WebRTC offe glare handling).
*/
fun rollback(onDone: () -> Unit) {
val rollbackSdp = SessionDescription(SessionDescription.Type.ROLLBACK, "")
peerConnection?.setLocalDescription(
object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription?) {}
override fun onCreateFailure(error: String?) {}
override fun onSetSuccess() {
Log.d(TAG) { "Rollback SUCCESS" }
onDone()
}
override fun onSetFailure(error: String?) {
Log.e(TAG, "Rollback FAILED: $error")
error?.let { onError("Rollback failed: $it") }
}
},
rollbackSdp,
)
}
fun dispose() {
peerConnection?.close()
peerConnection?.dispose()