From cb82d59b7758b1a6e68a982c80b54a056aef22d5 Mon Sep 17 00:00:00 2001 From: Paul Hitt Date: Tue, 1 Sep 2026 11:19:06 -0400 Subject: [PATCH] Answer a call only once the offer has actually been applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepting a call failed intermittently, and when it did the caller heard nothing and neither end said why: NOT SENDING the offer/answer: no localDescription after gathering (state=null). The other side will never see this call. `accept` fired `setRemoteDescription` and `createAnswer` back to back. `setRemoteDescription` is asynchronous and WebRTC cannot answer an offer it has not finished reading, so when it lost that race `createAnswer` failed, `setLocalDescription` never ran, and the send path found no local description and gave up. The phone showed a call being accepted; the caller rang on until it timed out. ⚠️ A race, which is what made it look intermittent — on a quiet device the description usually lands first and the call connects, so the same build both works and fails depending on load. That is also why it survived every previous round of call testing. `createAnswer` now runs from `setRemoteDescription`'s success callback. The offer path needed no change: `createOffer` does not depend on a remote description, and the caller applying the answer is terminal. Found in a live log alongside the first end-to-end proof that calls connect at all — the same run recorded `ice connection -> CONNECTED` and then `COMPLETED` between this device and an iPhone. Co-Authored-By: Claude Opus 4.8 --- .../io/flamenet/messenger/app/CallManager.kt | 59 ++++++++++++++----- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt index b00417e..6978b07 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt @@ -440,22 +440,16 @@ class CallManager( var onStaleOffer: ((Long, Long) -> Unit)? = null /** Accept the call that is ringing. */ - fun accept(turn: JSONObject?) { - val (fromUser, offer) = pendingOffer ?: return - pendingOffer = null - val sdp = offer.sdp ?: return - state = State.CONNECTING - armConnectGuard() - val pc = open(turn, fromUser, offer.callId, offer.video) ?: run { - state = State.IDLE - return - } - current = Active(offer.callId, fromUser, incoming = true, video = offer.video) - pc.setRemoteDescription( - SimpleSdpObserver("answer:setRemote"), - SessionDescription(SessionDescription.Type.OFFER, sdp), - ) - pc.createAnswer(object : SimpleSdpObserver() { + /** + * Build and send the answer. Split out so it can only be reached from + * `setRemoteDescription`'s success callback — the ordering IS the fix. + */ + private fun answerNow( + pc: PeerConnection, + fromUser: Long, + offer: Content.CallSignal, + ) { + pc.createAnswer(object : SimpleSdpObserver("answer:create") { override fun onCreateSuccess(answer: SessionDescription) { pc.setLocalDescription(SimpleSdpObserver("answer:setLocal"), answer) // Same as the offer: the answer is complete only after @@ -471,6 +465,39 @@ class CallManager( }, MediaConstraints()) } + fun accept(turn: JSONObject?) { + val (fromUser, offer) = pendingOffer ?: return + pendingOffer = null + val sdp = offer.sdp ?: return + state = State.CONNECTING + armConnectGuard() + val pc = open(turn, fromUser, offer.callId, offer.video) ?: run { + state = State.IDLE + return + } + current = Active(offer.callId, fromUser, incoming = true, video = offer.video) + // 🔴 **`createAnswer` waits for the remote description to be APPLIED.** + // These two were fired back to back: `setRemoteDescription` is + // asynchronous, and WebRTC cannot answer an offer it has not finished + // reading. When it lost that race `createAnswer` failed, + // `setLocalDescription` never ran, and the send path found no local + // description and gave up — "NOT SENDING the offer/answer ... the other + // side will never see this call". Accepting a call did nothing, the + // caller rang on until it timed out, and neither end said why. + // + // ⚠️ It is a RACE, which is what made it look intermittent: on a quiet + // device the description often lands first and the call connects, so + // the same build both works and fails depending on load. + pc.setRemoteDescription( + object : SimpleSdpObserver("answer:setRemote") { + override fun onSetSuccess() { + main.post { answerNow(pc, fromUser, offer) } + } + }, + SessionDescription(SessionDescription.Type.OFFER, sdp), + ) + } + /** Refuse a ringing call, and say so rather than letting it ring out. */ fun decline() { val (fromUser, offer) = pendingOffer ?: return