//! Relay-first P2P multiplexer with seamless fallback. //! //! [`upgrade_relay`] wraps any [`Transport`] (the WSS relay) and returns another //! `Transport` that the session loop ([`crate::signaling::connect_with`] / //! [`crate::host::run_host_with`]) uses completely unchanged. All the P2P logic //! runs in a background engine task that owns both the relay and, once //! negotiated, the WebRTC DataChannel: //! //! * The relay is LIVE from the first frame and is NEVER torn down while P2P is //! up (warm standby), so the first frame never waits on ICE and a P2P drop is //! invisible to the session. //! * The engine SNIFFS the session `sid` out of the handshake messages flowing //! through it (so neither loop has to hand it over), then drives the //! relay-brokered `rtc.*` handshake ([`crate::rtc::upgrade_offerer`] / //! [`crate::rtc::answer_offer`]). //! * UP edge: when the DataChannel opens AND the nominated ICE pair is DIRECT //! (non-TURN — [`crate::rtc::selected_pair_is_direct`]), media + the //! latency-critical input/keyframe control are routed over the channel; a //! TURN-relayed or unknown pair keeps media on the relay. //! * DOWN edge: if the peer connection fails/closes, media reroutes to the //! still-open relay with no `Ended` and no frame loss. //! //! `rtc.*` and `session.*` ALWAYS ride the relay; only `CHAN_VIDEO` / file //! binary and `i.mouse` / `i.key` / `keyframe` move to P2P when it is direct. use std::sync::Arc; use bytes::Bytes; use serde_json::Value; use tokio::sync::mpsc; use webrtc::api::API; use webrtc::data_channel::RTCDataChannel; use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState; use webrtc::peer_connection::RTCPeerConnection; use webrtc::stats::StatsReportType; use crate::error::{NdError, Result}; use crate::rtc; use crate::rtc::{RtcIceServerJson, WebDetached}; use crate::transport::{install_reassembler, send_fragmented, Lane, Transport, TransportMsg}; /// A negotiated P2P connection: the native single reliable channel (16 KiB /// app-fragmented) or the web 3-channel topology (media/ctl/move, detached, /// whole frames) used to interop with the browser / Python host. enum P2pConn { Single(Arc), Web(Arc), } /// High-frequency, latency-critical control that rides the web `ctl` channel /// (the web viewer's send override routes exactly these; other control stays on /// the relay). Cheap prefix checks, no JSON parse on the hot path. fn is_fast_path_text(t: &str) -> bool { t.contains("\"t\":\"i.mouse\"") || t.contains("\"t\":\"i.key\"") || t.contains("\"t\":\"keyframe\"") } /// Whether the direct-P2P fast path is enabled for this process. DEFAULT-ON: /// the native-peer capability gate keeps P2P to native<->native pairs and every /// other pairing stays on the relay, so default-on cannot break the existing /// web/Python peers. Opt OUT with `NETDESK_P2P=0` (also `false`/`off`/`no`). pub fn p2p_enabled() -> bool { !matches!( std::env::var("NETDESK_P2P").as_deref(), Ok("0") | Ok("false") | Ok("off") | Ok("no") | Ok("OFF") | Ok("FALSE") | Ok("NO") ) } /// Performance-based path health for the active DIRECT P2P pair. /// /// The relay-first state machine already prefers a direct pair and falls back on /// hard failure. This adds the other half of the hybrid: abandon a direct pair /// that is measurably WORSE than a relay hop would be — not just one that /// dropped. It is deliberately conservative (only a *pathologically* high, /// *sustained* RTT trips it; a VPS relay hop is almost always below that), so it /// never fights the normal "direct is faster" case. Feed it the nominated /// candidate pair's RTT (ms) every ~[`HEALTH_INTERVAL`]; `on_rtt` returns true /// once the path has been bad long enough to downgrade. #[derive(Default)] pub struct P2pHealth { bad_streak: u32, } /// How often the engine samples the active pair's RTT. pub const HEALTH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2); /// A direct-pair RTT at/above this (ms) is "bad" — far worse than a relay hop. pub const P2P_RTT_BAD_MS: u32 = 500; /// Consecutive bad samples before downgrading (~10 s at [`HEALTH_INTERVAL`]). pub const P2P_RTT_CONFIRM: u32 = 5; impl P2pHealth { /// Record one RTT sample (ms). `0` means "unknown" (not yet measured) and is /// ignored. Returns true when a downgrade to the relay is warranted. pub fn on_rtt(&mut self, rtt_ms: u32) -> bool { if rtt_ms == 0 { return false; } if rtt_ms >= P2P_RTT_BAD_MS { self.bad_streak += 1; } else { self.bad_streak = 0; } self.bad_streak >= P2P_RTT_CONFIRM } } /// The nominated ICE candidate-pair RTT in ms, or 0 if unknown/not yet measured. async fn p2p_pair_rtt_ms(pc: &Arc) -> u32 { let report = pc.get_stats().await; for v in report.reports.values() { if let StatsReportType::CandidatePair(cp) = v { if cp.nominated && cp.current_round_trip_time > 0.0 { return (cp.current_round_trip_time * 1000.0).round() as u32; } } } 0 } /// Which side of the upgrade handshake this peer drives. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum P2pRole { /// Viewer — sends `rtc.config.req` + the offer. Offerer, /// Host — answers an inbound `rtc.offer`. Answerer, } /// SCTP send-queue depth (bytes) past which a fresh MEDIA (video) frame is /// DROPPED rather than queued — fresh beats late. Only the drop-tolerant Media /// lane drops; Ctl (file bytes / control) and Move are always sent reliably. /// Matches the web host's 256 KiB `_send_bin` threshold. const P2P_MEDIA_CAP: usize = 262144; /// A handle to force the session back onto the relay (the plan's "force-relay /// fast path", and the test hook for the DOWN edge). Cloneable; outlives the /// transport move into the session loop. #[derive(Clone)] pub struct P2pControl { down: mpsc::UnboundedSender<()>, } impl P2pControl { /// Drop any active P2P path and route all media back over the warm relay. pub fn force_relay(&self) { let _ = self.down.send(()); } } /// The app-facing transport the session loop uses unchanged — just the mailbox /// into the engine task. pub struct P2pRelay { out: mpsc::UnboundedSender<(TransportMsg, Lane)>, inn: mpsc::UnboundedReceiver>, down: mpsc::UnboundedSender<()>, _engine: tokio::task::JoinHandle<()>, } impl P2pRelay { /// A control handle to force media back onto the relay. pub fn control(&self) -> P2pControl { P2pControl { down: self.down.clone(), } } } #[async_trait::async_trait] impl Transport for P2pRelay { async fn send_binary(&mut self, data: Vec) -> Result<()> { // No lane hint -> treat as reliable Ctl (never dropped). Callers that // know it is video use send_binary_lane(.., Media). self.out .send((TransportMsg::Binary(data), Lane::Ctl)) .map_err(|_| NdError::Connect("p2p engine stopped".into())) } async fn send_binary_lane(&mut self, data: Vec, lane: Lane) -> Result<()> { self.out .send((TransportMsg::Binary(data), lane)) .map_err(|_| NdError::Connect("p2p engine stopped".into())) } async fn send_text(&mut self, text: String) -> Result<()> { self.out .send((TransportMsg::Text(text), Lane::Ctl)) .map_err(|_| NdError::Connect("p2p engine stopped".into())) } async fn recv(&mut self) -> Option> { self.inn.recv().await } } /// Wrap `relay` with transparent P2P upgrade + fallback. `api` builds the peer /// connection(s). The returned transport is a drop-in for the relay. pub fn upgrade_relay(relay: Box, role: P2pRole, api: Arc) -> P2pRelay { let (app_out_tx, app_out_rx) = mpsc::unbounded_channel::<(TransportMsg, Lane)>(); let (app_in_tx, app_in_rx) = mpsc::unbounded_channel::>(); let (down_tx, down_rx) = mpsc::unbounded_channel::<()>(); let engine = tokio::spawn(engine( relay, role, api, app_out_rx, app_in_tx, down_tx.clone(), down_rx, )); P2pRelay { out: app_out_tx, inn: app_in_rx, down: down_tx, _engine: engine, } } /// Pull `sid` out of a control-text message, if present and non-empty. fn sniff_sid_text(t: &str) -> Option { serde_json::from_str::(t) .ok() .and_then(|v| v.get("sid").and_then(|x| x.as_str()).map(|s| s.to_string())) .filter(|s| !s.is_empty()) } /// Whether this control text rides the P2P channel when it is up. Everything /// does EXCEPT `session.*` (session lifecycle) and `rtc.*` (upgrade signaling), /// which stay on the relay. Keeping ALL app traffic — input, keyframe requests /// AND file.* control — on the one ordered channel alongside the binary data is /// essential: splitting data (P2P) from its `file.done` (relay) lets the text /// race ahead of the slower-fragmented data and truncate the download. Cheap /// substring checks avoid a JSON parse on the hot path (serde emits `t` first). fn route_text_p2p(t: &str) -> bool { !(t.contains("\"t\":\"session.") || t.contains("\"t\":\"rtc.")) } type DoneTx = mpsc::UnboundedSender<(Arc, P2pConn)>; fn spawn_offerer( api: Arc, sid: String, rtc_out: mpsc::UnboundedSender, mut rtc_in: mpsc::UnboundedReceiver, done: DoneTx, ) { tokio::spawn(async move { let send = { let tx = rtc_out.clone(); move |s: String| { let _ = tx.send(s); } }; match rtc::upgrade_offerer(&api, &sid, None, "netdesk", &send, &mut rtc_in).await { Ok((pc, dc)) => { let _ = done.send((pc, P2pConn::Single(dc))); } Err(e) => tracing::debug!("P2P offerer upgrade abandoned: {e}"), } }); } /// WEB-topology offerer (interop with the browser / Python host). Builds its own /// detach-enabled API, negotiates media/ctl/move, and reports a `P2pConn::Web`. fn spawn_offerer_web( sid: String, rtc_out: mpsc::UnboundedSender, mut rtc_in: mpsc::UnboundedReceiver, done: DoneTx, ) { tokio::spawn(async move { let api = match rtc::build_api_web() { Ok(a) => a, Err(e) => { tracing::debug!("build_api_web: {e}"); return; } }; let send = { let tx = rtc_out.clone(); move |s: String| { let _ = tx.send(s); } }; match rtc::upgrade_offerer_web(&api, &sid, None, &send, &mut rtc_in).await { Ok((pc, web)) => { let _ = done.send((pc, P2pConn::Web(Arc::new(web)))); } Err(e) => tracing::debug!("P2P web offerer abandoned: {e}"), } }); } fn spawn_answerer( api: Arc, sid: String, offer_sdp: String, ice: Vec, rtc_out: mpsc::UnboundedSender, done: DoneTx, ) { tokio::spawn(async move { let send = { let tx = rtc_out.clone(); move |s: String| { let _ = tx.send(s); } }; match rtc::answer_offer(&api, &sid, &offer_sdp, ice, &send).await { Ok((pc, dc)) => { let _ = done.send((pc, P2pConn::Single(dc))); } Err(e) => tracing::debug!("P2P answerer abandoned: {e}"), } }); } /// WEB-topology answerer (a browser / web viewer connects to this native host). /// Builds its own detach-enabled API and reports a `P2pConn::Web`. fn spawn_answerer_web( sid: String, offer_sdp: String, ice: Vec, rtc_out: mpsc::UnboundedSender, done: DoneTx, ) { tokio::spawn(async move { let api = match rtc::build_api_web() { Ok(a) => a, Err(e) => { tracing::debug!("build_api_web: {e}"); return; } }; let send = { let tx = rtc_out.clone(); move |s: String| { let _ = tx.send(s); } }; match rtc::answer_offer_web(&api, &sid, &offer_sdp, ice, &send).await { Ok((pc, web)) => { let _ = done.send((pc, P2pConn::Web(Arc::new(web)))); } Err(e) => tracing::debug!("P2P web answerer abandoned: {e}"), } }); } /// Start one offerer upgrade attempt (the initial one, or a re-upgrade after a /// drop). Creates a fresh rtc_in channel, spawns the Single or Web offerer, and /// returns the sender the relay loop forwards inbound rtc.* to. fn start_offerer_attempt( web: bool, api: Arc, sid: String, rtc_out: mpsc::UnboundedSender, done: DoneTx, ) -> mpsc::UnboundedSender { let (tx, rx) = mpsc::unbounded_channel::(); if web { spawn_offerer_web(sid, rtc_out, rx, done); } else { spawn_offerer(api, sid, rtc_out, rx, done); } tx } #[allow(clippy::too_many_arguments)] async fn engine( mut relay: Box, role: P2pRole, api: Arc, mut app_out_rx: mpsc::UnboundedReceiver<(TransportMsg, Lane)>, app_in_tx: mpsc::UnboundedSender>, down_tx: mpsc::UnboundedSender<()>, mut down_rx: mpsc::UnboundedReceiver<()>, ) { let mut sid: Option = None; let mut media_on_p2p = false; let mut p2p_send: Option = None; let mut _pc: Option> = None; let mut next_frame_id: u32 = 0; let mut offerer_started = false; let mut answerer_started = false; // Performance health of the active direct pair, and a latch: once a direct // pair is abandoned for being too slow, stay on the relay for the rest of // the session (don't re-upgrade into the same bad path and flap). let mut health = P2pHealth::default(); let mut health_down = false; // Inbound P2P frames (reassembled) land here; held open for the engine's // lifetime so re-use across the session is clean. Fed by install_reassembler // once a channel is established. let (p2p_in_tx, mut p2p_in_rx) = mpsc::unbounded_channel::(); // rtc.* the upgrade task wants put on the relay. let (rtc_out_tx, mut rtc_out_rx) = mpsc::unbounded_channel::(); // Inbound rtc.* forwarded to the CURRENT offerer upgrade attempt. Re-created // per attempt so a re-upgrade after a drop gets a fresh channel. let mut rtc_in_tx: Option> = None; // Negotiated (pc, connection). let (done_tx, mut done_rx) = mpsc::unbounded_channel::<(Arc, P2pConn)>(); // Re-upgrade (hysteresis): after a P2P drop, retry the upgrade once after a // backoff so a transient drop recovers to P2P instead of staying on the // relay. Default 15s; shorter in tests via NETDESK_P2P_REUP_MS. let reup_ms: u64 = std::env::var("NETDESK_P2P_REUP_MS") .ok() .and_then(|s| s.parse().ok()) .unwrap_or(15000); let (reup_tx, mut reup_rx) = mpsc::unbounded_channel::<()>(); // Offerer topology once decided: Some(false)=native single, Some(true)=web, // None=relay-only. Remembered so a re-upgrade uses the same topology. let mut offerer_mode: Option = None; // Periodic RTT health probe of the active direct pair. let mut health_tick = tokio::time::interval(HEALTH_INTERVAL); health_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); enum Ev { AppOut(Option<(TransportMsg, Lane)>), RelayIn(Option>), P2pIn(Option), RtcOut(Option), Done(Option<(Arc, P2pConn)>), ReUpgrade, Down, Health, } loop { let ev = tokio::select! { m = app_out_rx.recv() => Ev::AppOut(m), m = relay.recv() => Ev::RelayIn(m), m = p2p_in_rx.recv() => Ev::P2pIn(m), s = rtc_out_rx.recv() => Ev::RtcOut(s), d = done_rx.recv() => Ev::Done(d), _ = reup_rx.recv() => Ev::ReUpgrade, _ = down_rx.recv() => Ev::Down, _ = health_tick.tick() => Ev::Health, }; match ev { // ---- outbound from the session loop ---- Ev::AppOut(None) => break, // the session dropped the transport Ev::AppOut(Some((msg, lane))) => { if sid.is_none() { if let TransportMsg::Text(t) = &msg { if let Some(s) = sniff_sid_text(t) { sid = Some(s); } } } match msg { TransportMsg::Binary(b) => match (media_on_p2p, p2p_send.as_ref()) { // native single channel: 16 KiB app-fragments; Media is // drop-tolerant past the queue cap, Ctl/Move reliable. (true, Some(P2pConn::Single(dc))) => { let dc = dc.clone(); if lane == Lane::Media && dc.buffered_amount().await > P2P_MEDIA_CAP { // drop (fresh beats late) } else { let fid = next_frame_id; next_frame_id = next_frame_id.wrapping_add(1); if send_fragmented(&dc, fid, &b).await.is_err() { let _ = relay.send_binary_lane(b, lane).await; let _ = down_tx.send(()); } } } // web topology: video (Media) rides the media channel whole; // everything else (e.g. file upload) stays on the relay, like // the web viewer. Media is drop-tolerant past the cap. (true, Some(P2pConn::Web(web))) => { if lane == Lane::Media { if web.media.buffered_amount() <= P2P_MEDIA_CAP && web .media .write_data_channel(&Bytes::from(b.clone()), false) .await .is_err() { let _ = relay.send_binary_lane(b, lane).await; let _ = down_tx.send(()); } } else { let _ = relay.send_binary_lane(b, lane).await; } } _ => { let _ = relay.send_binary_lane(b, lane).await; } }, TransportMsg::Text(t) => match (media_on_p2p, p2p_send.as_ref()) { (true, Some(P2pConn::Single(dc))) if route_text_p2p(&t) => { let dc = dc.clone(); if dc.send_text(t.clone()).await.is_err() { let _ = relay.send_text(t).await; let _ = down_tx.send(()); } } // web: only input / keyframe rides ctl; other control stays // on the relay (the web viewer's send policy). (true, Some(P2pConn::Web(web))) if is_fast_path_text(&t) => { if web .ctl .write_data_channel(&Bytes::from(t.clone().into_bytes()), true) .await .is_err() { let _ = relay.send_text(t).await; let _ = down_tx.send(()); } } _ => { let _ = relay.send_text(t).await; } }, } } // ---- inbound from the relay ---- Ev::RelayIn(None) => break, // relay closed -> session ends (app recv None) Ev::RelayIn(Some(Err(e))) => { let _ = app_in_tx.send(Err(e)); break; } Ev::RelayIn(Some(Ok(m))) => { let mut forward = true; if let TransportMsg::Text(t) = &m { if let Ok(v) = serde_json::from_str::(t) { let tt = v.get("t").and_then(|x| x.as_str()).unwrap_or("").to_string(); if sid.is_none() { if let Some(s) = v.get("sid").and_then(|x| x.as_str()) { if !s.is_empty() { sid = Some(s.to_string()); } } } if tt.starts_with("rtc.") { forward = false; // rtc.* is ours, never the app's match role { P2pRole::Offerer => { if let Some(tx) = &rtc_in_tx { let _ = tx.send(v); } } P2pRole::Answerer => { if tt == "rtc.offer" && !answerer_started { answerer_started = true; let sid_s = sid.clone().unwrap_or_default(); let is_native = v.get("native").and_then(|x| x.as_bool()) == Some(true); let sdp = v .get("sdp") .and_then(|x| x.as_str()) .unwrap_or("") .to_string(); let ice: Vec = v .get("ice") .and_then(|x| serde_json::from_value(x.clone()).ok()) .unwrap_or_default(); if is_native { // native single-channel offerer. spawn_answerer( api.clone(), sid_s, sdp, ice, rtc_out_tx.clone(), done_tx.clone(), ); } else if std::env::var("NETDESK_P2P_WEB").is_ok() { // web/3-channel offerer (a browser) — answer in // web topology when opted in. spawn_answerer_web( sid_s, sdp, ice, rtc_out_tx.clone(), done_tx.clone(), ); } else { // not opted into web interop: decline so the // pairing stays on the relay. let _ = rtc_out_tx.send( format!(r#"{{"t":"rtc.unavailable","sid":"{sid_s}"}}"#), ); } } } } } else if role == P2pRole::Offerer && !offerer_started && tt == "session.accept" { // A NATIVE host (info.p2p == "native") uses the native // single-channel upgrade. A non-native host (browser / // Python) uses the WEB 3-channel topology, but only when // NETDESK_P2P_WEB is opted in — so the default stays the // safe native-only behavior until the live cross-matrix // is confirmed. let native_host = v .get("info") .and_then(|i| i.get("p2p")) .and_then(|p| p.as_str()) == Some("native"); let web_ok = std::env::var("NETDESK_P2P_WEB").is_ok(); if native_host { offerer_started = true; offerer_mode = Some(false); rtc_in_tx = Some(start_offerer_attempt( false, api.clone(), sid.clone().unwrap_or_default(), rtc_out_tx.clone(), done_tx.clone(), )); } else if web_ok { offerer_started = true; offerer_mode = Some(true); rtc_in_tx = Some(start_offerer_attempt( true, api.clone(), sid.clone().unwrap_or_default(), rtc_out_tx.clone(), done_tx.clone(), )); } } } } if forward { let _ = app_in_tx.send(Ok(m)); } } // ---- inbound from the P2P channel ---- Ev::P2pIn(Some(m)) => { let _ = app_in_tx.send(Ok(m)); } Ev::P2pIn(None) => {} // we hold a sender; this does not fire in practice // ---- rtc.* the upgrade task wants on the relay ---- Ev::RtcOut(Some(s)) => { let _ = relay.send_text(s).await; } Ev::RtcOut(None) => {} // ---- UP edge: a channel was negotiated ---- Ev::Done(Some((new_pc, conn))) => { let direct = rtc::selected_pair_is_direct(&new_pc).await; match &conn { P2pConn::Single(dc) => { // native: 16 KiB app-fragments reassembled by install_reassembler. install_reassembler(dc, p2p_in_tx.clone()); } P2pConn::Web(web) => { // web: whole frames — spawn a detached read-loop per inbound // channel (media = video, ctl = control). Each read is one // complete message (is_string -> Text, else Binary). for raw in [web.media.clone(), web.ctl.clone()] { let tx = p2p_in_tx.clone(); tokio::spawn(async move { let mut buf = vec![0u8; 262144]; loop { match raw.read_data_channel(&mut buf).await { Ok((0, _)) => break, Ok((n, is_string)) => { let msg = if is_string { match String::from_utf8(buf[..n].to_vec()) { Ok(s) => TransportMsg::Text(s), Err(_) => continue, } } else { TransportMsg::Binary(buf[..n].to_vec()) }; let _ = tx.send(msg); } Err(_) => break, } } }); } } } let dtx = down_tx.clone(); new_pc.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| { let dtx = dtx.clone(); Box::pin(async move { if matches!( s, RTCPeerConnectionState::Failed | RTCPeerConnectionState::Disconnected | RTCPeerConnectionState::Closed ) { let _ = dtx.send(()); } }) })); p2p_send = Some(conn); _pc = Some(new_pc); // Only move media off the relay for a DIRECT pair; a TURN-relayed // hop is no better than our own relay. media_on_p2p = direct; tracing::info!(direct, "P2P DataChannel open"); if role == P2pRole::Offerer { let _ = app_in_tx.send(Ok(TransportMsg::Text(format!( r#"{{"t":"p2p.state","active":{media_on_p2p},"direct":{direct}}}"# )))); } } Ev::Done(None) => {} // ---- periodic health probe: abandon a pathologically slow direct // pair for the relay (the "pick the faster path" half of hybrid). ---- Ev::Health => { if media_on_p2p && !health_down { if let Some(pc) = &_pc { let rtt = p2p_pair_rtt_ms(pc).await; if health.on_rtt(rtt) { tracing::warn!( rtt_ms = rtt, "P2P direct pair too slow — downgrading to the relay for the session" ); health_down = true; let _ = down_tx.send(()); } } } } // ---- DOWN edge: pc failed/closed -> fall back to the warm relay ---- Ev::Down => { let was_up = media_on_p2p || p2p_send.is_some(); if was_up { tracing::info!("P2P dropped — continuing over the relay"); } media_on_p2p = false; p2p_send = None; _pc = None; health = P2pHealth::default(); // reset the RTT streak for the next pair if was_up && role == P2pRole::Offerer { let _ = app_in_tx.send(Ok(TransportMsg::Text( r#"{"t":"p2p.state","active":false,"direct":false}"#.to_string(), ))); } // Re-upgrade hysteresis: allow a fresh attempt. The answerer // just re-answers the viewer's next rtc.offer; the offerer // schedules one retry after a backoff. if was_up { answerer_started = false; offerer_started = false; rtc_in_tx = None; // Suppress re-upgrade after a health downgrade: the direct // path proved too slow, so stay on the relay (no flapping). if role == P2pRole::Offerer && offerer_mode.is_some() && !health_down { let reup_tx = reup_tx.clone(); tokio::spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(reup_ms)).await; let _ = reup_tx.send(()); }); } } } // ---- re-upgrade timer fired: retry the offerer upgrade once ---- Ev::ReUpgrade => { if role == P2pRole::Offerer && !offerer_started && p2p_send.is_none() && sid.is_some() { if let Some(web) = offerer_mode { offerer_started = true; rtc_in_tx = Some(start_offerer_attempt( web, api.clone(), sid.clone().unwrap_or_default(), rtc_out_tx.clone(), done_tx.clone(), )); tracing::info!("P2P re-upgrade attempt"); } } } } } } #[cfg(test)] mod health_tests { use super::{P2pHealth, P2P_RTT_CONFIRM}; #[test] fn good_rtt_never_downgrades() { let mut h = P2pHealth::default(); for _ in 0..50 { assert!(!h.on_rtt(30), "a healthy direct pair must stay on P2P"); } } #[test] fn unknown_rtt_is_ignored() { let mut h = P2pHealth::default(); for _ in 0..50 { assert!(!h.on_rtt(0), "0 = not-yet-measured, never a downgrade"); } } #[test] fn sustained_bad_rtt_downgrades_after_confirm() { let mut h = P2pHealth::default(); // Must take the full confirm streak — not a single spike. for i in 1..P2P_RTT_CONFIRM { assert!(!h.on_rtt(800), "should not trip on sample {i} (< confirm)"); } assert!(h.on_rtt(800), "should downgrade once the streak reaches confirm"); } #[test] fn a_good_sample_resets_the_streak() { let mut h = P2pHealth::default(); for _ in 0..(P2P_RTT_CONFIRM - 1) { assert!(!h.on_rtt(900)); } assert!(!h.on_rtt(40), "a healthy sample clears the bad streak"); // Now it must take another full streak to trip. for i in 1..P2P_RTT_CONFIRM { assert!(!h.on_rtt(900), "streak restarted; sample {i} must not trip"); } assert!(h.on_rtt(900)); } }