mirror of
https://github.com/rustdesk/hbb_common.git
synced 2026-08-27 20:49:00 +00:00
webrtc: make the cache guard actually apply; drop unusable ICE servers
Two findings, both cases of a check that reads correct but never runs.
- The SESSIONS admissibility test was applied at the lookup only. The
insert-time duplicate check reads the SAME key and returned whatever
it found, so an entry the lookup had just rejected came straight back
- the freshly built peer connection was closed and the rejected one
returned in its place. A Relay-only request could therefore be served
by a cached All-policy pc (free to pick a direct pair, with
is_relayed() answering for a policy nobody asked for), and a caller
could be handed a pc already latched Closed. Both sites now share
`is_reusable_for`, which is the only way a test on a shared key holds.
- A TURN server with no credentials is not just useless: webrtc-rs
validates every configured server when the peer connection is built,
so one such entry fails EVERY connection, including plain non-relay
ones that never wanted TURN. The RFC 7065 spelling this branch taught
us to parse has nowhere to put credentials, so it produced exactly
that entry - and has_turn_server() then reported TURN as available,
making callers skip their "don't build a guaranteed-dead Relay-only
pc" guard. Drop such entries at the source (with a log naming the
spelling that does carry credentials); has_turn_server() goes back to
a plain scheme test, which is sound once the constructor guarantees a
host and credentials.
Malformed entries are now dropped rather than repaired: an unparsable
port used to be folded back into the host ("host:99999:3478") and an
unbracketed IPv6 literal was split at its last colon, both of which
webrtc-ice rejects - again taking every server down, not just the bad
one.
Also spell `&'static str` on the two associated consts (an elided
lifetime there is a future hard error on the pinned 1.75 toolchain) and
drop a test import left behind when the tests stopped touching config.
Regression test for the cache guard; mutation-checked, as are the
credential and malformed-entry paths.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ExUfAkYbq8UC9pQCiLy8TQ
This commit is contained in:
+152
-90
@@ -205,8 +205,10 @@ impl WebRTCStream {
|
|||||||
|
|
||||||
// Envelope JSON key carrying the local description's ICE transport policy, alongside the
|
// Envelope JSON key carrying the local description's ICE transport policy, alongside the
|
||||||
// RTCSessionDescription fields (see `get_local_endpoint_trickle`).
|
// RTCSessionDescription fields (see `get_local_endpoint_trickle`).
|
||||||
const ICE_POLICY_KEY: &str = "ice_policy";
|
// `'static` spelled out: an elided lifetime here is a warn-by-default future hard error on
|
||||||
const ICE_POLICY_ALL: &str = "all";
|
// the 1.75 toolchain CI pins (elided_lifetimes_in_associated_constant).
|
||||||
|
const ICE_POLICY_KEY: &'static str = "ice_policy";
|
||||||
|
const ICE_POLICY_ALL: &'static str = "all";
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn get_key_for_sdp(sdp: &RTCSessionDescription) -> ResultType<String> {
|
fn get_key_for_sdp(sdp: &RTCSessionDescription) -> ResultType<String> {
|
||||||
@@ -244,6 +246,25 @@ impl WebRTCStream {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a `SESSIONS` entry may be handed to a caller asking for `force_relay`.
|
||||||
|
///
|
||||||
|
/// A hit returns a pc built for the FIRST caller, and two of its properties belong to this
|
||||||
|
/// caller instead: the ICE policy (`rendezvous_mediator` recomputes relay-only per PunchHole,
|
||||||
|
/// so a replayed offer wanting Relay-only must not get an All-policy pc free to pick a direct
|
||||||
|
/// pair), and liveness (the state handler latches Closed and closes the stream before it
|
||||||
|
/// reaches SESSIONS to evict, so the map briefly holds dead entries). `peer_verified` stays
|
||||||
|
/// shared on purpose: it is a fact about the DTLS certificate the entry is keyed by.
|
||||||
|
///
|
||||||
|
/// Both the lookup and the insert-time duplicate check must use this — they read the same key,
|
||||||
|
/// so a test applied at only one of them is not applied at all.
|
||||||
|
fn is_reusable_for(&self, force_relay: bool) -> bool {
|
||||||
|
self.relay_only == force_relay
|
||||||
|
&& !matches!(
|
||||||
|
*self.state_notify.borrow(),
|
||||||
|
WebRTCConnectionState::Closed(_)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn get_key_for_sdp_json(sdp_json: &str) -> ResultType<String> {
|
fn get_key_for_sdp_json(sdp_json: &str) -> ResultType<String> {
|
||||||
if sdp_json.is_empty() {
|
if sdp_json.is_empty() {
|
||||||
@@ -288,10 +309,28 @@ impl WebRTCStream {
|
|||||||
if host.is_empty() {
|
if host.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let username = u.username().to_string();
|
||||||
|
let credential = u.password().unwrap_or_default().to_string();
|
||||||
|
// A TURN server without credentials is not merely useless: webrtc-rs validates every
|
||||||
|
// configured server in `new_peer_connection`, so one credential-less entry makes EVERY
|
||||||
|
// peer connection fail — including plain non-relay ones that never wanted TURN. The
|
||||||
|
// RFC 7065 spelling has nowhere to put credentials, so drop such entries here rather
|
||||||
|
// than let them poison the whole configuration; `turn://user:pass@host:port` carries them.
|
||||||
|
if matches!(u.scheme(), "turn" | "turns") && (username.is_empty() || credential.is_empty())
|
||||||
|
{
|
||||||
|
log::warn!(
|
||||||
|
"Ignoring TURN server without credentials: {}:{}:{} (use {}://user:pass@host:port)",
|
||||||
|
u.scheme(),
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
u.scheme()
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
Some(RTCIceServer {
|
Some(RTCIceServer {
|
||||||
urls: vec![format!("{}:{}:{}", u.scheme(), host, port)],
|
urls: vec![format!("{}:{}:{}", u.scheme(), host, port)],
|
||||||
username: u.username().to_string(),
|
username,
|
||||||
credential: u.password().unwrap_or_default().to_string(),
|
credential,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -311,11 +350,23 @@ impl WebRTCStream {
|
|||||||
.unwrap_or(3478);
|
.unwrap_or(3478);
|
||||||
return Some((format!("[{host}]"), port));
|
return Some((format!("[{host}]"), port));
|
||||||
}
|
}
|
||||||
|
// An unbracketed IPv6 literal has no unambiguous split point — `2001:db8::1` would be cut
|
||||||
|
// at its last colon into host `2001:db8:` port `1` — so require the bracketed form for
|
||||||
|
// those rather than emit a host webrtc-ice cannot resolve.
|
||||||
|
if rest.matches(':').count() > 1 {
|
||||||
|
log::warn!("Ignoring ICE server {rest}: bracket IPv6 literals as [addr]:port");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
match rest.rsplit_once(':') {
|
match rest.rsplit_once(':') {
|
||||||
// Only a numeric tail is a port; anything else is part of the host.
|
|
||||||
Some((host, port)) if !host.is_empty() => match port.parse() {
|
Some((host, port)) if !host.is_empty() => match port.parse() {
|
||||||
Ok(port) => Some((host.to_owned(), port)),
|
Ok(port) => Some((host.to_owned(), port)),
|
||||||
Err(_) => Some((rest.to_owned(), 3478)),
|
// A port that is present but unusable is a typo, not a host: folding it back in
|
||||||
|
// would produce `host:99999:3478`, which webrtc-ice rejects outright — taking
|
||||||
|
// every peer connection down with it, not just this server.
|
||||||
|
Err(_) => {
|
||||||
|
log::warn!("Ignoring ICE server {rest}: invalid port");
|
||||||
|
None
|
||||||
|
}
|
||||||
},
|
},
|
||||||
_ => Some((rest.to_owned(), 3478)),
|
_ => Some((rest.to_owned(), 3478)),
|
||||||
}
|
}
|
||||||
@@ -325,16 +376,14 @@ impl WebRTCStream {
|
|||||||
/// connection (force_relay) can only gather relay candidates, so without a TURN server it can
|
/// connection (force_relay) can only gather relay candidates, so without a TURN server it can
|
||||||
/// never connect — callers use this to skip building a guaranteed-dead pc.
|
/// never connect — callers use this to skip building a guaranteed-dead pc.
|
||||||
pub fn has_turn_server() -> bool {
|
pub fn has_turn_server() -> bool {
|
||||||
|
// `get_ice_server_from_url` is what makes a bare scheme test sufficient: it drops entries
|
||||||
|
// with no host and TURN entries with no credentials, i.e. exactly the ones that would
|
||||||
|
// answer `true` here while being unusable — which is worse than answering `false`, since
|
||||||
|
// the caller skips its "don't build a guaranteed-dead Relay-only pc" guard on our word.
|
||||||
Self::get_ice_servers().iter().any(|s| {
|
Self::get_ice_servers().iter().any(|s| {
|
||||||
s.urls.iter().any(|u| {
|
s.urls
|
||||||
// `scheme:host:port`, built by get_ice_server_from_url. A missing host would
|
.iter()
|
||||||
// still match the scheme while being unusable, and answering `true` for one of
|
.any(|u| u.starts_with("turn:") || u.starts_with("turns:"))
|
||||||
// those is worse than answering `false`: the caller skips its "don't build a
|
|
||||||
// guaranteed-dead Relay-only pc" guard on the strength of it.
|
|
||||||
u.strip_prefix("turn:")
|
|
||||||
.or_else(|| u.strip_prefix("turns:"))
|
|
||||||
.is_some_and(|rest| !rest.starts_with(':'))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,33 +456,14 @@ impl WebRTCStream {
|
|||||||
.cloned()
|
.cloned()
|
||||||
};
|
};
|
||||||
if let Some(cached_stream) = cached {
|
if let Some(cached_stream) = cached {
|
||||||
// A hit hands back a pc built for the FIRST caller. Two of its properties are
|
if cached_stream.is_reusable_for(force_relay) {
|
||||||
// this caller's to decide, so a mismatched entry must not be reused:
|
|
||||||
//
|
|
||||||
// - ICE policy. `rendezvous_mediator` recomputes relay-only per PunchHole, so a
|
|
||||||
// replayed offer asking for Relay-only would otherwise get back an All-policy
|
|
||||||
// pc, free to pick a direct pair — and `is_relayed()` would answer from the
|
|
||||||
// cached handle's own flag, describing a policy nobody asked for.
|
|
||||||
// - liveness. The state handler pushes Closed and closes the stream BEFORE it
|
|
||||||
// reaches SESSIONS to evict, so there is a window where the map still holds a
|
|
||||||
// dead pc whose `wait_for_connect_result` errors immediately.
|
|
||||||
//
|
|
||||||
// `peer_verified` is deliberately still shared: it is an identity fact about
|
|
||||||
// this DTLS certificate, not a per-caller setting, and the entry is keyed by
|
|
||||||
// that certificate's fingerprint.
|
|
||||||
let stale = matches!(
|
|
||||||
*cached_stream.state_notify.borrow(),
|
|
||||||
WebRTCConnectionState::Closed(_)
|
|
||||||
);
|
|
||||||
if cached_stream.relay_only == force_relay && !stale {
|
|
||||||
log::debug!("Start webrtc with cached peer");
|
log::debug!("Start webrtc with cached peer");
|
||||||
return Ok(cached_stream);
|
return Ok(cached_stream);
|
||||||
}
|
}
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"Ignoring cached webrtc peer (relay_only {} != {}, or closed: {})",
|
"Ignoring cached webrtc peer (relay_only {}, wanted {})",
|
||||||
cached_stream.relay_only,
|
cached_stream.relay_only,
|
||||||
force_relay,
|
force_relay
|
||||||
stale
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -706,11 +736,15 @@ impl WebRTCStream {
|
|||||||
let cache_key = Self::cache_key(&key, start_local_offer);
|
let cache_key = Self::cache_key(&key, start_local_offer);
|
||||||
let duplicate = {
|
let duplicate = {
|
||||||
let mut final_lock = SESSIONS.lock().await;
|
let mut final_lock = SESSIONS.lock().await;
|
||||||
if let Some(session) = final_lock.get(&cache_key) {
|
// Same admissibility test as the lookup above, or that lookup is dead code: an entry
|
||||||
Some(session.clone())
|
// rejected there is still in the map when we get here, so returning it unconditionally
|
||||||
} else {
|
// would discard the pc we just built precisely because the cached one was unusable.
|
||||||
final_lock.insert(cache_key, webrtc_stream.clone());
|
match final_lock.get(&cache_key) {
|
||||||
None
|
Some(session) if session.is_reusable_for(force_relay) => Some(session.clone()),
|
||||||
|
_ => {
|
||||||
|
final_lock.insert(cache_key, webrtc_stream.clone());
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(session) = duplicate {
|
if let Some(session) = duplicate {
|
||||||
@@ -1202,7 +1236,6 @@ pub fn is_webrtc_endpoint(endpoint: &str) -> bool {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::config;
|
|
||||||
use crate::webrtc::WebRTCStream;
|
use crate::webrtc::WebRTCStream;
|
||||||
use crate::webrtc::{DEFAULT_ICE_SERVERS, FRAG_MORE, SESSIONS};
|
use crate::webrtc::{DEFAULT_ICE_SERVERS, FRAG_MORE, SESSIONS};
|
||||||
use bytes::{BufMut, Bytes, BytesMut};
|
use bytes::{BufMut, Bytes, BytesMut};
|
||||||
@@ -1213,41 +1246,37 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_webrtc_ice_url() {
|
fn test_webrtc_ice_url() {
|
||||||
assert_eq!(
|
let turn = WebRTCStream::get_ice_server_from_url("turn://123:321@example.com:3478")
|
||||||
WebRTCStream::get_ice_server_from_url("turn://example.com:3478")
|
.expect("credentialed turn is usable");
|
||||||
.unwrap_or_default()
|
assert_eq!(turn.urls[0], "turn:example.com:3478");
|
||||||
.urls[0],
|
assert_eq!(turn.username, "123");
|
||||||
"turn:example.com:3478"
|
assert_eq!(turn.credential, "321");
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
WebRTCStream::get_ice_server_from_url("turn://example.com")
|
|
||||||
.unwrap_or_default()
|
|
||||||
.urls[0],
|
|
||||||
"turn:example.com:3478"
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
WebRTCStream::get_ice_server_from_url("turn://123@example.com")
|
|
||||||
.unwrap_or_default()
|
|
||||||
.username,
|
|
||||||
"123"
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
WebRTCStream::get_ice_server_from_url("turn://123@example.com")
|
|
||||||
.unwrap_or_default()
|
|
||||||
.credential,
|
|
||||||
""
|
|
||||||
);
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
WebRTCStream::get_ice_server_from_url("turn://123:321@example.com")
|
WebRTCStream::get_ice_server_from_url("turn://123:321@example.com")
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.credential,
|
.urls[0],
|
||||||
"321"
|
"turn:example.com:3478"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TURN without both halves of the credential is dropped rather than passed on:
|
||||||
|
// webrtc-rs validates every configured server when the peer connection is built, so
|
||||||
|
// one such entry fails EVERY connection, including those that never wanted TURN.
|
||||||
|
for missing in [
|
||||||
|
"turn://example.com:3478",
|
||||||
|
"turn://example.com",
|
||||||
|
"turn://123@example.com",
|
||||||
|
"turns://example.com:5349",
|
||||||
|
"turn:example.com:3478",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
WebRTCStream::get_ice_server_from_url(missing),
|
||||||
|
None,
|
||||||
|
"credential-less {missing} must not reach the configuration"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// STUN needs no credentials, so both spellings stay usable.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
WebRTCStream::get_ice_server_from_url("stun://example.com:3478")
|
WebRTCStream::get_ice_server_from_url("stun://example.com:3478")
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
@@ -1260,19 +1289,17 @@ mod tests {
|
|||||||
None
|
None
|
||||||
);
|
);
|
||||||
|
|
||||||
// RFC 7065 spelling (`turn:host:port`, no authority) — what TURN docs hand out, and
|
// RFC 7065 spelling (`scheme:host:port`, no authority) — what STUN/TURN docs hand out.
|
||||||
// what users paste. `url` cannot-be-a-base's it, so host/port come out of the path;
|
// `url` cannot-be-a-base's it, so host and port come out of the path; getting this wrong
|
||||||
// getting this wrong produced a hostless "turn::3478" that still passed the TURN gate.
|
// produced a hostless "stun::3478" no ICE agent can resolve.
|
||||||
for (input, expected) in [
|
for (input, expected) in [
|
||||||
("turn:example.com:3478", "turn:example.com:3478"),
|
|
||||||
("turn:example.com", "turn:example.com:3478"),
|
|
||||||
("turns:example.com:5349", "turns:example.com:5349"),
|
|
||||||
("stun:example.com:19302", "stun:example.com:19302"),
|
("stun:example.com:19302", "stun:example.com:19302"),
|
||||||
("turn:[2001:db8::1]:3478", "turn:[2001:db8::1]:3478"),
|
("stun:example.com", "stun:example.com:3478"),
|
||||||
("turn:[2001:db8::1]", "turn:[2001:db8::1]:3478"),
|
("stun:[2001:db8::1]:19302", "stun:[2001:db8::1]:19302"),
|
||||||
|
("stun:[2001:db8::1]", "stun:[2001:db8::1]:3478"),
|
||||||
(
|
(
|
||||||
"turn:example.com:3478?transport=udp",
|
"stun:example.com:19302?transport=udp",
|
||||||
"turn:example.com:3478",
|
"stun:example.com:19302",
|
||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1283,13 +1310,22 @@ mod tests {
|
|||||||
"parsing {input}"
|
"parsing {input}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(WebRTCStream::get_ice_server_from_url("turn:"), None);
|
|
||||||
|
|
||||||
// The gate must not green-light an unusable server: a hostless entry makes the caller
|
// Malformed entries are dropped, never repaired into something webrtc-ice will choke
|
||||||
// skip its "don't build a guaranteed-dead Relay-only pc" guard.
|
// on: a folded-in bad port ("host:99999:3478") or an unbracketed IPv6 split at its last
|
||||||
assert!(!WebRTCStream::parse_ice_servers("turn:")
|
// colon fails peer-connection construction outright, taking every server down with it.
|
||||||
.iter()
|
for bad in [
|
||||||
.any(|s| s.urls.iter().any(|u| u.starts_with("turn:"))));
|
"stun:",
|
||||||
|
"stun:example.com:99999",
|
||||||
|
"stun:example.com:abc",
|
||||||
|
"stun:2001:db8::1",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
WebRTCStream::get_ice_server_from_url(bad),
|
||||||
|
None,
|
||||||
|
"malformed {bad} must be dropped"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parsing is exercised through `parse_ice_servers`, never by rewriting the global
|
// Parsing is exercised through `parse_ice_servers`, never by rewriting the global
|
||||||
@@ -1302,13 +1338,16 @@ mod tests {
|
|||||||
DEFAULT_ICE_SERVERS[0].to_string()
|
DEFAULT_ICE_SERVERS[0].to_string()
|
||||||
);
|
);
|
||||||
|
|
||||||
let parsed = WebRTCStream::parse_ice_servers(",stun://example.com,turn://example.com,sdf");
|
// Unusable entries drop out of the list; the rest of the config still applies.
|
||||||
|
let parsed = WebRTCStream::parse_ice_servers(
|
||||||
|
",stun://example.com,turn://u:p@example.com,turn://nocreds.example.com,sdf",
|
||||||
|
);
|
||||||
assert_eq!(parsed[0].urls[0], "stun:example.com:3478");
|
assert_eq!(parsed[0].urls[0], "stun:example.com:3478");
|
||||||
assert_eq!(parsed[1].urls[0], "turn:example.com:3478");
|
assert_eq!(parsed[1].urls[0], "turn:example.com:3478");
|
||||||
assert_eq!(parsed.len(), 2);
|
assert_eq!(parsed.len(), 2);
|
||||||
|
|
||||||
// TURN-only config still gets the default STUN servers prepended.
|
// TURN-only config still gets the default STUN servers prepended.
|
||||||
let turn_only = WebRTCStream::parse_ice_servers("turn:example.com:3478");
|
let turn_only = WebRTCStream::parse_ice_servers("turn://u:p@example.com:3478");
|
||||||
assert_eq!(turn_only[0].urls[0], DEFAULT_ICE_SERVERS[0].to_string());
|
assert_eq!(turn_only[0].urls[0], DEFAULT_ICE_SERVERS[0].to_string());
|
||||||
assert_eq!(turn_only[1].urls[0], "turn:example.com:3478");
|
assert_eq!(turn_only[1].urls[0], "turn:example.com:3478");
|
||||||
}
|
}
|
||||||
@@ -1587,6 +1626,29 @@ IHR5cCBzcmZseCByYWRkciAwLjAuMC4wIHJwb3J0IDY0MDA4XHJcbmE9ZW5kLW9mLWNhbmRpZGF0ZXNc
|
|||||||
panic!("detached close never evicted the peer connection from SESSIONS");
|
panic!("detached close never evicted the peer connection from SESSIONS");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A replayed offer asking for a different ICE policy must not be handed the cached peer
|
||||||
|
// connection. The lookup and the insert-time duplicate check read the same key, so the test
|
||||||
|
// fails if either one stops applying `is_reusable_for` — which is how the guard was dead
|
||||||
|
// code: the lookup rejected the entry, and the insert handed back the very same one.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_cached_peer_is_not_reused_across_ice_policies() {
|
||||||
|
let offerer = WebRTCStream::new("", false, 20000).await.unwrap();
|
||||||
|
let offer = offerer.get_local_endpoint_trickle().await.unwrap();
|
||||||
|
|
||||||
|
let all_ice = WebRTCStream::new(&offer, false, 20000).await.unwrap();
|
||||||
|
assert!(!all_ice.relay_only);
|
||||||
|
|
||||||
|
let relay_only = WebRTCStream::new(&offer, true, 20000).await.unwrap();
|
||||||
|
assert!(
|
||||||
|
relay_only.relay_only,
|
||||||
|
"a Relay-only request was answered with the cached All-policy peer connection"
|
||||||
|
);
|
||||||
|
|
||||||
|
relay_only.close().await;
|
||||||
|
all_ice.close().await;
|
||||||
|
offerer.close().await;
|
||||||
|
}
|
||||||
|
|
||||||
// The local-candidate channel must close when the pc does. Its only sender lives inside the
|
// The local-candidate channel must close when the pc does. Its only sender lives inside the
|
||||||
// on_ice_candidate handler, which close() does not clear, so without the teardown the
|
// on_ice_candidate handler, which close() does not clear, so without the teardown the
|
||||||
// receiver stays open forever — and the forwarder loop this API asks callers to write holds
|
// receiver stays open forever — and the forwarder loop this API asks callers to write holds
|
||||||
|
|||||||
Reference in New Issue
Block a user