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.
This commit is contained in:
Goffi 2025-11-06 22:18:58 +01:00
commit 9a2830c7e2

View file

@ -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],