832 lines
35 KiB
Rust
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));
|
|
}
|
|
}
|