mirror of
https://github.com/rustdesk/hbb_common.git
synced 2026-10-25 13:40:11 +00:00
Merge pull request #599 from rustdesk/dead-peer-recovery
webrtc: report receive progress and ICE's disconnected hint
This commit is contained in:
4 files changed
+243
No files matched your search
@@ -56,6 +56,7 @@ pub use uuid;
|
|||||||
pub mod fingerprint;
|
pub mod fingerprint;
|
||||||
pub use flexi_logger;
|
pub use flexi_logger;
|
||||||
pub mod log_throttle;
|
pub mod log_throttle;
|
||||||
|
pub mod rx_probe;
|
||||||
pub mod stream;
|
pub mod stream;
|
||||||
pub mod websocket;
|
pub mod websocket;
|
||||||
#[cfg(feature = "webrtc")]
|
#[cfg(feature = "webrtc")]
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
use std::sync::{
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Bytes received from the peer, counted before framing.
|
||||||
|
///
|
||||||
|
/// A message reaches the application only once it is complete, so a large one — a clipboard image
|
||||||
|
/// is the case that occurs in practice — leaves the receive loop with nothing to observe for as
|
||||||
|
/// long as the transfer takes, and a peer that is sending steadily looks identical to one that
|
||||||
|
/// died. This counter moves on every read, so liveness stays observable across such a message.
|
||||||
|
///
|
||||||
|
/// A counter rather than a timestamp on purpose: `Instant::now()` costs an order of magnitude more
|
||||||
|
/// than a relaxed add, and the reader only ever compares successive samples, so the clock is
|
||||||
|
/// needed once per sampling tick instead of once per read.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct RxProbe(Arc<AtomicU64>);
|
||||||
|
|
||||||
|
impl RxProbe {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn add(&self, n: usize) {
|
||||||
|
self.0.fetch_add(n as u64, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changes whenever bytes arrived. Compare successive samples; the absolute value and its
|
||||||
|
/// wrap-around carry no meaning.
|
||||||
|
#[inline]
|
||||||
|
pub fn get(&self) -> u64 {
|
||||||
|
self.0.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clones_share_one_counter() {
|
||||||
|
let probe = RxProbe::new();
|
||||||
|
let cloned = probe.clone();
|
||||||
|
assert_eq!(probe.get(), 0);
|
||||||
|
cloned.add(7);
|
||||||
|
assert_eq!(probe.get(), 7);
|
||||||
|
probe.add(3);
|
||||||
|
assert_eq!(cloned.get(), 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,42 @@ impl Stream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An opaque token that changes whenever bytes have arrived from the peer, a fragment of a
|
||||||
|
/// message that is still incomplete included. Compare successive samples; the value itself
|
||||||
|
/// means nothing. `None` where the transport reports no such thing.
|
||||||
|
///
|
||||||
|
/// A message reaches the caller only once it is whole, so `next()` alone cannot distinguish a
|
||||||
|
/// peer sending a large one — a clipboard image is the case that occurs — from one that died
|
||||||
|
/// mid-message. Sampling this alongside the last received message tells them apart.
|
||||||
|
///
|
||||||
|
/// TCP and WebSocket answer `None` on purpose: this change leaves their existing close, error
|
||||||
|
/// and 30s-timeout behaviour exactly as it was, and giving them a value would alter it.
|
||||||
|
#[inline]
|
||||||
|
pub fn rx_progress(&self) -> Option<u64> {
|
||||||
|
match self {
|
||||||
|
#[cfg(feature = "webrtc")]
|
||||||
|
Stream::WebRTC(s) => Some(s.rx_progress()),
|
||||||
|
#[allow(unreachable_patterns)]
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether ICE has stopped hearing from the peer on a WebRTC transport. Transient and
|
||||||
|
/// recoverable — it says the peer is worth suspecting, never that the session is over — and
|
||||||
|
/// always false for transports this change leaves alone.
|
||||||
|
///
|
||||||
|
/// Maintained by ICE's own task, so unlike anything derived from the session's read loop it
|
||||||
|
/// stays accurate while that loop is busy sending.
|
||||||
|
#[inline]
|
||||||
|
pub fn webrtc_disconnected(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
#[cfg(feature = "webrtc")]
|
||||||
|
Stream::WebRTC(s) => s.is_disconnected(),
|
||||||
|
#[allow(unreachable_patterns)]
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn is_secured(&self) -> bool {
|
pub fn is_secured(&self) -> bool {
|
||||||
match self {
|
match self {
|
||||||
|
|||||||
+155
@@ -48,6 +48,7 @@ use url::Url;
|
|||||||
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::protobuf::Message;
|
use crate::protobuf::Message;
|
||||||
|
use crate::rx_probe::RxProbe;
|
||||||
use crate::sodiumoxide::crypto::secretbox::Key;
|
use crate::sodiumoxide::crypto::secretbox::Key;
|
||||||
use crate::ResultType;
|
use crate::ResultType;
|
||||||
|
|
||||||
@@ -92,6 +93,12 @@ pub struct WebRTCStream {
|
|||||||
// Taken once at construction: `pc.local_description()` grows with every gathered candidate
|
// Taken once at construction: `pc.local_description()` grows with every gathered candidate
|
||||||
// and this must not (why: `UDP_ENDPOINT_BUDGET`). Shared so cloning the struct stays cheap.
|
// and this must not (why: `UDP_ENDPOINT_BUDGET`). Shared so cloning the struct stays cheap.
|
||||||
local_endpoint: Arc<String>,
|
local_endpoint: Arc<String>,
|
||||||
|
// Counts arriving fragments, which `recv_state` cannot answer for: that lock is deliberately
|
||||||
|
// held across the whole reassembly (see the field), so a sampler would block on it.
|
||||||
|
rx_probe: RxProbe,
|
||||||
|
// ICE has stopped hearing from the peer but the connection is not terminal yet. Kept out of
|
||||||
|
// `state_notify` on purpose - see where it is set.
|
||||||
|
disconnected: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a pc `new_inner` built is still wanted, shared by every `NewStreamHandoff` handed out
|
/// Whether a pc `new_inner` built is still wanted, shared by every `NewStreamHandoff` handed out
|
||||||
@@ -240,6 +247,8 @@ impl Clone for WebRTCStream {
|
|||||||
peer_verified: self.peer_verified.clone(),
|
peer_verified: self.peer_verified.clone(),
|
||||||
handoff: self.handoff.clone(),
|
handoff: self.handoff.clone(),
|
||||||
local_endpoint: self.local_endpoint.clone(),
|
local_endpoint: self.local_endpoint.clone(),
|
||||||
|
rx_probe: self.rx_probe.clone(),
|
||||||
|
disconnected: self.disconnected.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,6 +347,10 @@ impl WebRTCStream {
|
|||||||
fn is_reusable_for(&self, force_relay: bool) -> bool {
|
fn is_reusable_for(&self, force_relay: bool) -> bool {
|
||||||
self.relay_only == force_relay
|
self.relay_only == force_relay
|
||||||
&& !self.handoff.closing.load(Ordering::Acquire)
|
&& !self.handoff.closing.load(Ordering::Acquire)
|
||||||
|
// ICE has stopped hearing from the peer on this one. Not terminal, so nothing else
|
||||||
|
// rejects it, but handing it to a caller that is about to judge liveness starts them
|
||||||
|
// off already suspecting a pc they never used.
|
||||||
|
&& !self.is_disconnected()
|
||||||
&& !matches!(
|
&& !matches!(
|
||||||
*self.state_notify.borrow(),
|
*self.state_notify.borrow(),
|
||||||
WebRTCConnectionState::Closed(_)
|
WebRTCConnectionState::Closed(_)
|
||||||
@@ -747,12 +760,20 @@ impl WebRTCStream {
|
|||||||
// pc and the ICE-candidate sender's forwarding task. Upgrade inside the handler; if the pc
|
// pc and the ICE-candidate sender's forwarding task. Upgrade inside the handler; if the pc
|
||||||
// is already gone there is nothing left in SESSIONS to evict.
|
// is already gone there is nothing left in SESSIONS to evict.
|
||||||
let pc_for_close = Arc::downgrade(&pc);
|
let pc_for_close = Arc::downgrade(&pc);
|
||||||
|
// Deliberately NOT a `WebRTCConnectionState` variant: that enum gates send and receive
|
||||||
|
// readiness (`wait_for_connect_result`) and session-cache admissibility, so a fourth state
|
||||||
|
// would have to be given a meaning at each of those. `Disconnected` is transient and must
|
||||||
|
// change neither, so it is reported beside them instead of through them.
|
||||||
|
let disconnected = Arc::new(AtomicBool::new(false));
|
||||||
|
let disconnected_for_state = disconnected.clone();
|
||||||
pc.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| {
|
pc.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| {
|
||||||
let stream_for_close2 = stream_for_close.clone();
|
let stream_for_close2 = stream_for_close.clone();
|
||||||
let on_connection_notify = notify_tx.clone();
|
let on_connection_notify = notify_tx.clone();
|
||||||
let pc_for_close2 = pc_for_close.clone();
|
let pc_for_close2 = pc_for_close.clone();
|
||||||
|
let disconnected_for_state = disconnected_for_state.clone();
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
log::debug!("WebRTC session peer connection state: {}", s);
|
log::debug!("WebRTC session peer connection state: {}", s);
|
||||||
|
Self::note_health(&disconnected_for_state, s);
|
||||||
match s {
|
match s {
|
||||||
// `Disconnected` is a transient, recoverable ICE state (webrtc-ice fires it
|
// `Disconnected` is a transient, recoverable ICE state (webrtc-ice fires it
|
||||||
// after ~5s without consent and returns to `Connected` when traffic resumes).
|
// after ~5s without consent and returns to `Connected` when traffic resumes).
|
||||||
@@ -860,6 +881,8 @@ impl WebRTCStream {
|
|||||||
peer_verified: Arc::new(AtomicBool::new(false)),
|
peer_verified: Arc::new(AtomicBool::new(false)),
|
||||||
handoff: Default::default(),
|
handoff: Default::default(),
|
||||||
local_endpoint: Arc::new(local_endpoint),
|
local_endpoint: Arc::new(local_endpoint),
|
||||||
|
rx_probe: RxProbe::new(),
|
||||||
|
disconnected,
|
||||||
};
|
};
|
||||||
// Insert into the session cache, but never `await pc.close()` while holding this lock:
|
// Insert into the session cache, but never `await pc.close()` while holding this lock:
|
||||||
// `close()` fires the peer-connection-state handler inline, which itself locks SESSIONS,
|
// `close()` fires the peer-connection-state handler inline, which itself locks SESSIONS,
|
||||||
@@ -1163,6 +1186,30 @@ impl WebRTCStream {
|
|||||||
self.send_timeout = ms;
|
self.send_timeout = ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// See `Stream::rx_progress`.
|
||||||
|
#[inline]
|
||||||
|
pub fn rx_progress(&self) -> u64 {
|
||||||
|
self.rx_probe.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track whether ICE still hears from the peer. `Disconnected` is raised about 5s after it
|
||||||
|
/// stops and is cleared again when traffic resumes, so it is a hint to be acted on by whoever
|
||||||
|
/// asks, never a terminal state; the terminal ones are handled by the caller of this and
|
||||||
|
/// deliberately leave the flag alone, since a closed session has no use for a hint.
|
||||||
|
fn note_health(disconnected: &AtomicBool, s: RTCPeerConnectionState) {
|
||||||
|
match s {
|
||||||
|
RTCPeerConnectionState::Disconnected => disconnected.store(true, Ordering::SeqCst),
|
||||||
|
RTCPeerConnectionState::Connected => disconnected.store(false, Ordering::SeqCst),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// See `Stream::webrtc_disconnected`.
|
||||||
|
#[inline]
|
||||||
|
pub fn is_disconnected(&self) -> bool {
|
||||||
|
self.disconnected.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn set_key(&mut self, _key: Key) {
|
pub fn set_key(&mut self, _key: Key) {
|
||||||
// WebRTC traffic is DTLS-encrypted regardless; the secretbox key is unused.
|
// WebRTC traffic is DTLS-encrypted regardless; the secretbox key is unused.
|
||||||
@@ -1382,6 +1429,11 @@ impl WebRTCStream {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Before the framing checks below: a fragment that turns out to be malformed still
|
||||||
|
// proves the peer sent it, which is all this counter claims.
|
||||||
|
if n > 0 {
|
||||||
|
self.rx_probe.add(n);
|
||||||
|
}
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
// Not exclusively a stream reset: webrtc-data maps the empty-message PPIDs to
|
// Not exclusively a stream reset: webrtc-data maps the empty-message PPIDs to
|
||||||
// n == 0 as well and `read` drops the flag that would separate them. Both mean
|
// n == 0 as well and `read` drops the flag that would separate them. Both mean
|
||||||
@@ -2208,6 +2260,109 @@ IHR5cCBzcmZseCByYWRkciAwLjAuMC4wIHJwb3J0IDY0MDA4XHJcbmE9ZW5kLW9mLWNhbmRpZGF0ZXNc
|
|||||||
.expect("gathered-endpoint WebRTC loopback did not complete in time");
|
.expect("gathered-endpoint WebRTC loopback did not complete in time");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The state mapping the health flag is built from. ICE raises `Disconnected` only on a real
|
||||||
|
// consent failure, which an in-process loopback cannot produce, so this drives the mapping
|
||||||
|
// directly; the loopback test below covers the wiring from the flag out to callers.
|
||||||
|
#[test]
|
||||||
|
fn test_note_health_maps_only_the_transient_states() {
|
||||||
|
use crate::webrtc::RTCPeerConnectionState;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
let flag = AtomicBool::new(false);
|
||||||
|
|
||||||
|
WebRTCStream::note_health(&flag, RTCPeerConnectionState::Disconnected);
|
||||||
|
assert!(flag.load(Ordering::SeqCst), "Disconnected must raise the hint");
|
||||||
|
|
||||||
|
WebRTCStream::note_health(&flag, RTCPeerConnectionState::Connected);
|
||||||
|
assert!(!flag.load(Ordering::SeqCst), "Connected must clear it");
|
||||||
|
|
||||||
|
// The terminal states have their own path and must not touch the hint: a session that
|
||||||
|
// ends has no use for one, and clearing it here would hide a peer that went silent first.
|
||||||
|
for terminal in [
|
||||||
|
RTCPeerConnectionState::Failed,
|
||||||
|
RTCPeerConnectionState::Closed,
|
||||||
|
] {
|
||||||
|
WebRTCStream::note_health(&flag, RTCPeerConnectionState::Disconnected);
|
||||||
|
WebRTCStream::note_health(&flag, terminal);
|
||||||
|
assert!(
|
||||||
|
flag.load(Ordering::SeqCst),
|
||||||
|
"{} must leave the hint as it found it",
|
||||||
|
terminal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pins the wiring and the polarity of the health flag: a pair that just connected must not
|
||||||
|
// look suspect.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_webrtc_disconnected_is_false_on_a_live_pair() {
|
||||||
|
let body = async {
|
||||||
|
let (offerer, answerer) = connect_loopback().await;
|
||||||
|
assert!(!offerer.is_disconnected());
|
||||||
|
assert!(!answerer.is_disconnected());
|
||||||
|
offerer.close().await;
|
||||||
|
answerer.close().await;
|
||||||
|
};
|
||||||
|
timeout(Duration::from_secs(30), body)
|
||||||
|
.await
|
||||||
|
.expect("WebRTC loopback did not complete in time");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A message far above the fragment size must show as activity while it is still arriving:
|
||||||
|
// `next()` yields nothing until the last fragment, so a receiver watching only completed
|
||||||
|
// messages cannot tell a peer mid-transfer from one that died.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_webrtc_rx_progress_moves_before_a_large_message_completes() {
|
||||||
|
let body = async {
|
||||||
|
let (mut offerer, mut answerer) = connect_loopback().await;
|
||||||
|
|
||||||
|
offerer.send_raw(b"warmup".to_vec()).await.unwrap();
|
||||||
|
assert_eq!(&answerer.next().await.unwrap().unwrap()[..], b"warmup");
|
||||||
|
let baseline = answerer.rx_progress();
|
||||||
|
|
||||||
|
// Large enough that it cannot land inside one poll of the loop below, so a probe
|
||||||
|
// that only counted whole messages would leave the counter untouched throughout.
|
||||||
|
let big = vec![0x5Au8; 4 * 1024 * 1024];
|
||||||
|
let expected = big.len();
|
||||||
|
let sender = tokio::spawn(async move { offerer.send_raw(big).await.map(|_| offerer) });
|
||||||
|
|
||||||
|
// `next_timeout` returning None leaves the partial message in place, so the counter
|
||||||
|
// is read at a point where nothing has been delivered. No select and no sleeping
|
||||||
|
// sibling: the assertion cannot turn on which branch a poll happened to take.
|
||||||
|
let mut moved_early = false;
|
||||||
|
let mut got = None;
|
||||||
|
for _ in 0..600 {
|
||||||
|
match answerer.next_timeout(20).await {
|
||||||
|
Some(msg) => {
|
||||||
|
got = Some(msg);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if answerer.rx_progress() > baseline {
|
||||||
|
moved_early = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
moved_early,
|
||||||
|
"rx_progress must advance while the message is still incomplete"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
got.expect("message never completed").unwrap().len(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
let offerer = sender.await.unwrap().expect("send");
|
||||||
|
// `WebRTCStream` has no `Drop`, so both pcs stay in `SESSIONS` unless closed here;
|
||||||
|
// `test_cancelled_new_does_not_leak_the_pc` scans that map and would report them.
|
||||||
|
offerer.close().await;
|
||||||
|
answerer.close().await;
|
||||||
|
};
|
||||||
|
timeout(Duration::from_secs(60), body)
|
||||||
|
.await
|
||||||
|
.expect("WebRTC loopback did not complete in time");
|
||||||
|
}
|
||||||
|
|
||||||
// In-process offerer<->answerer loopback exercising the send/next data plane that the framing,
|
// In-process offerer<->answerer loopback exercising the send/next data plane that the framing,
|
||||||
// empty-message, and EOF fixes live in. Connects over host candidates (works offline; any
|
// empty-message, and EOF fixes live in. Connects over host candidates (works offline; any
|
||||||
// configured/default STUN just fails in the background without blocking the host pair).
|
// configured/default STUN just fails in the background without blocking the host pair).
|
||||||
|
|||||||
Reference in new issue
Block a user