From 9a2830c7e253e8fc853ddcb365851d7dd6c59cdf Mon Sep 17 00:00:00 2001 From: Goffi Date: Thu, 6 Nov 2025 22:18:58 +0100 Subject: [PATCH] frontends (tools/webrtc): improve ICE candidate buffering: Buffer and wait for remote description to be set before sending ICE candidate. This avoids the sending of ICE candidates before the negotiation is done. --- libervia/frontends/tools/webrtc.py | 74 ++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/libervia/frontends/tools/webrtc.py b/libervia/frontends/tools/webrtc.py index ba05207f..ca5b05c7 100644 --- a/libervia/frontends/tools/webrtc.py +++ b/libervia/frontends/tools/webrtc.py @@ -233,6 +233,27 @@ class WebRTC: raise Exception("self._media_types_inv should not be None!") return self._media_types_inv + + @property + def remote_desc_set(self) -> bool: + return self._remote_desc_set + + @remote_desc_set.setter + def remote_desc_set(self, is_set: bool) -> None: + """Setter for the remote_desc_set flag.""" + if self._remote_desc_set == is_set: + return + + self._remote_desc_set = is_set + if is_set: + log.debug("Remote description has been set.") + # Now that both descriptions are set, we can process buffered remote + # candidates and send our buffered local candidates. + self.on_ice_candidates_new(self.remote_candidates_buffer) + + # Also send any local candidates we buffered before this point. + self._send_buffered_local_candidates() + def bind(self, **kwargs: Callable) -> None: self.bindings.clear() for key, cb in kwargs.items(): @@ -448,6 +469,7 @@ class WebRTC: self._media_types = None self._media_types_inv = None self._sdp_set: bool = False + self._remote_desc_set: bool = False self.remote_candidates_buffer: dict[str, dict[str, list]] = { "audio": {"candidates": []}, "video": {"candidates": []}, @@ -1155,9 +1177,14 @@ class WebRTC: self.local_candidates_buffer.clear() def _remote_sdp_set(self, promise) -> None: + """Called after set-remote-description with an ANSWER is complete (initiator).""" assert promise.wait() == Gst.PromiseResult.REPLIED self.sdp_set = True + # The remote description is now set. We can process remote candidates and send our + # buffered ones. + self.remote_desc_set = True + def on_accepted_call(self, sdp: str, profile: str) -> None: """Outgoing call has been accepted. @@ -1204,6 +1231,46 @@ class WebRTC: promise = Gst.Promise.new_with_change_func(self.on_offer_set) self.webrtcbin.emit("set-remote-description", offer, promise) + # For the responder, the remote description is set synchronously. We can set the + # flag here. + self.remote_desc_set = True + + def _send_buffered_local_candidates(self) -> None: + """Sends any local ICE candidates that were buffered before the remote description was set.""" + if not self.local_candidates_buffer: + return + + log.debug( + f"sending buffered local ICE candidates: {self.local_candidates_buffer}" + ) + sdp = self.webrtcbin.props.local_description.sdp.as_text() + self.extract_ufrag_pwd(sdp) + + ice_data = {} + for media_type, candidates in self.local_candidates_buffer.items(): + if media_type not in self.ufrag or media_type not in self.pwd: + log.error(f"Missing ICE credentials for {media_type}") + continue + + ice_data[media_type] = { + "ufrag": self.ufrag[media_type], + "pwd": self.pwd[media_type], + "candidates": candidates, + } + + if ice_data: + log.debug(f"Sending ICE data for media types: {list(ice_data.keys())}") + self._a_call( + self.bridge.ice_candidates_add, + self.sid, + data_format.serialise(ice_data), + self.profile, + ) + else: + log.error("No ICE data to send - missing credentials!") + + self.local_candidates_buffer.clear() + def on_ice_candidate(self, webrtc, mline_index, candidate_sdp): """Handles the on-ice-candidate signal of webrtcbin. @@ -1231,15 +1298,16 @@ class WebRTC: log.debug(f"{self.media_types=}") return - if self.sid is None: + # Buffer local candidates until the remote description is set to avoid a race + # condition. + if not self._remote_desc_set: log.debug(f"buffering local ICE candidate for {media_type}") self.local_candidates_buffer.setdefault(media_type, []).append( parsed_candidate ) - else: + # The remote description is set, so we can send the candidate immediately. sdp = self.webrtcbin.props.local_description.sdp.as_text() - assert sdp is not None self.extract_ufrag_pwd(sdp) ice_data = { "ufrag": self.ufrag[media_type],