Files
netdesk/native/core/src/p2p.rs
T

832 lines
35 KiB
Rust

//! 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<RTCDataChannel>),
Web(Arc<WebDetached>),
}
/// 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<RTCPeerConnection>) -> 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<Result<TransportMsg>>,
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<u8>) -> 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<u8>, 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<Result<TransportMsg>> {
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<dyn Transport>, role: P2pRole, api: Arc<API>) -> P2pRelay {
let (app_out_tx, app_out_rx) = mpsc::unbounded_channel::<(TransportMsg, Lane)>();
let (app_in_tx, app_in_rx) = mpsc::unbounded_channel::<Result<TransportMsg>>();
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<String> {
serde_json::from_str::<Value>(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<RTCPeerConnection>, P2pConn)>;
fn spawn_offerer(
api: Arc<API>,
sid: String,
rtc_out: mpsc::UnboundedSender<String>,
mut rtc_in: mpsc::UnboundedReceiver<Value>,
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<String>,
mut rtc_in: mpsc::UnboundedReceiver<Value>,
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<API>,
sid: String,
offer_sdp: String,
ice: Vec<RtcIceServerJson>,
rtc_out: mpsc::UnboundedSender<String>,
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<RtcIceServerJson>,
rtc_out: mpsc::UnboundedSender<String>,
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<API>,
sid: String,
rtc_out: mpsc::UnboundedSender<String>,
done: DoneTx,
) -> mpsc::UnboundedSender<Value> {
let (tx, rx) = mpsc::unbounded_channel::<Value>();
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<dyn Transport>,
role: P2pRole,
api: Arc<API>,
mut app_out_rx: mpsc::UnboundedReceiver<(TransportMsg, Lane)>,
app_in_tx: mpsc::UnboundedSender<Result<TransportMsg>>,
down_tx: mpsc::UnboundedSender<()>,
mut down_rx: mpsc::UnboundedReceiver<()>,
) {
let mut sid: Option<String> = None;
let mut media_on_p2p = false;
let mut p2p_send: Option<P2pConn> = None;
let mut _pc: Option<Arc<RTCPeerConnection>> = 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::<TransportMsg>();
// rtc.* the upgrade task wants put on the relay.
let (rtc_out_tx, mut rtc_out_rx) = mpsc::unbounded_channel::<String>();
// 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<mpsc::UnboundedSender<Value>> = None;
// Negotiated (pc, connection).
let (done_tx, mut done_rx) =
mpsc::unbounded_channel::<(Arc<RTCPeerConnection>, 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<bool> = 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<Result<TransportMsg>>),
P2pIn(Option<TransportMsg>),
RtcOut(Option<String>),
Done(Option<(Arc<RTCPeerConnection>, 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::<Value>(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<RtcIceServerJson> = 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));
}
}