From 303e68331aa04f80956f84bdd606b02102307143 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 19:33:30 +0000 Subject: [PATCH] fix: clean up rejected peer's PeerSession to prevent stale sessions blocking hangup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a peer rejects a group call, CallManager updated its state but never notified CallController to dispose the peer's PeerSession. This caused two issues: 1. The rejected peer's PeerConnection lingered in HAVE_LOCAL_OFFER state until the entire call ended (resource leak visible as "disposing 2 peer sessions" when only 1 should exist). 2. In onPeerDisconnected, the allDisconnected check required ALL sessions to be CLOSED. The stale session blocked this check, preventing automatic hangup when ICE failed — meaning the remote peer never received a hangup event and kept ringing. Fix: invoke onPeerLeft when a peer rejects in Offering/Connecting/Connected states, and make onPeerDisconnected treat sessions without a remote description as inactive. https://claude.ai/code/session_01UXaFKmHVPDyTzfHL8fkSw1 --- .../amethyst/service/call/CallController.kt | 11 ++++++++--- .../amethyst/commons/call/CallManager.kt | 3 +++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index be655c05f..5d90caf49 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -729,12 +729,17 @@ class CallController( private fun onPeerDisconnected(peerPubKey: HexKey) { Log.d(TAG) { "Peer ${peerPubKey.take(8)} disconnected (ICE FAILED)" } - // If all peers disconnected, hang up - val sessionStates = peerSessions.map { (key, ps) -> "${key.take(8)}=${ps.session.getSignalingState()}" } + // If all peers disconnected, hang up. + // A session counts as "not active" if it is the one that just failed, + // if it was already closed, or if it never received a remote description + // (e.g. the peer rejected before answering). + val sessionStates = peerSessions.map { (key, ps) -> "${key.take(8)}=${ps.session.getSignalingState()},remoteSet=${ps.remoteDescriptionSet.get()}" } Log.d(TAG) { "onPeerDisconnected: checking remaining sessions: $sessionStates" } val allDisconnected = peerSessions.keys.all { key -> - key == peerPubKey || peerSessions[key]?.session?.getSignalingState() == PeerConnection.SignalingState.CLOSED + key == peerPubKey || + peerSessions[key]?.session?.getSignalingState() == PeerConnection.SignalingState.CLOSED || + peerSessions[key]?.remoteDescriptionSet?.get() != true } if (allDisconnected) { Log.d(TAG) { "onPeerDisconnected: all peers disconnected, hanging up" } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index d7bde9a2b..055f664e9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -349,6 +349,7 @@ class CallManager( transitionToEnded(current.callId, current.peerPubKeys, EndReason.PEER_REJECTED) } else { _state.value = current.copy(peerPubKeys = remaining) + onPeerLeft?.invoke(rejectingPeer) } } @@ -356,12 +357,14 @@ class CallManager( if (callId != current.callId) return _state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - rejectingPeer) + onPeerLeft?.invoke(rejectingPeer) } is CallState.Connected -> { if (callId != current.callId) return _state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - rejectingPeer) + onPeerLeft?.invoke(rejectingPeer) } is CallState.IncomingCall -> {