Answer a call only once the offer has actually been applied

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 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-01 11:19:06 -04:00
co-authored by Claude Opus 4.8
parent 32e4af06aa
commit cb82d59b77
@@ -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