Files
netdesk/native/core/tests/p2p_loopback.rs
T

1724 lines
67 KiB
Rust

//! Phase 2 of the direct-P2P build (see P2P-PLAN.md): prove a raw WebRTC
//! DataChannel forms and round-trips between two `RTCPeerConnection`s *in one
//! process*, with no signaling server and no NAT — pure 127.0.0.1 / host-LAN
//! ICE candidates.
//!
//! The in-process signaling shim is two pairs of `tokio::sync::mpsc` channels:
//! one carries each side's trickled ICE candidates to the other peer, and the
//! SDP offer/answer is handed across directly in the test body (only two
//! messages, strictly ordered). This exercises the exact `on_ice_candidate` →
//! `to_json()` → `add_ice_candidate` path that the Phase 4 native<->native
//! trickle upgrade uses over the real relay. Early remote candidates are
//! "buffered" simply by the unbounded channel — we only start draining a
//! peer's inbound candidate queue AFTER its remote description is set, so
//! `add_ice_candidate` never races ahead of `set_remote_description`.
//!
//! webrtc spins up its own tokio tasks, so these tests MUST run on a
//! multi-thread runtime (`flavor = "multi_thread"`), not the default
//! current-thread `#[tokio::test]`.
//!
//! Run: cargo test -p netdesk-core --target x86_64-pc-windows-gnu \
//! --test p2p_loopback -- --nocapture
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use tokio::sync::mpsc;
use tokio::time::timeout;
use async_trait::async_trait;
use std::sync::atomic::{AtomicBool, Ordering};
use netdesk_core::crypto::{gen_host_id, new_host_credentials};
use netdesk_core::host::{run_host_with, HostConfig, HostControl, HostEvent, HostFrame};
use netdesk_core::{upgrade_relay, NdError, P2pControl, P2pRole, Session, Transport, TransportMsg};
use netdesk_core::rtc::{
answer_nontrickle, answer_offer, apply_answer, build_api, build_api_web, create_web_channels,
new_pc, offer_nontrickle, rtc_config, upgrade_offerer, RtcIceServerJson,
};
use std::collections::HashMap;
use std::sync::Mutex as StdMutex;
use serde_json::Value;
use netdesk_core::signaling::connect_with;
use netdesk_core::transport::WebRtcChannel;
use netdesk_core::{ConnectConfig, SessionEvent};
use webrtc::data_channel::data_channel_init::RTCDataChannelInit;
use webrtc::data_channel::data_channel_message::DataChannelMessage;
use webrtc::data_channel::RTCDataChannel;
use webrtc::ice_transport::ice_candidate::{RTCIceCandidate, RTCIceCandidateInit};
use webrtc::peer_connection::peer_connection_state::RTCPeerConnectionState;
use webrtc::peer_connection::RTCPeerConnection;
/// Drain one peer's inbound trickled-candidate queue into the peer connection.
/// Spawned only after that peer's remote description is set, so every
/// `add_ice_candidate` is valid; the unbounded channel is the "early candidate"
/// buffer.
async fn drain_candidates(
pc: Arc<RTCPeerConnection>,
mut rx: mpsc::UnboundedReceiver<RTCIceCandidateInit>,
) {
while let Some(cand) = rx.recv().await {
if let Err(e) = pc.add_ice_candidate(cand).await {
eprintln!("add_ice_candidate: {e}");
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn datachannel_forms_and_roundtrips() {
// No ICE servers: on loopback the agents gather host candidates (127.0.0.1
// + the machine's LAN addresses) and connect directly, server-free.
let api = build_api().expect("build_api");
let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("offerer pc");
let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("answerer pc");
// --- signaling shim: candidate wires (offerer->answerer, answerer->offerer).
let (off_ice_tx, off_ice_rx) = mpsc::unbounded_channel::<RTCIceCandidateInit>();
let (ans_ice_tx, ans_ice_rx) = mpsc::unbounded_channel::<RTCIceCandidateInit>();
// --- event wires back to the test body.
let (off_open_tx, mut off_open_rx) = mpsc::channel::<()>(1);
let (ans_open_tx, mut ans_open_rx) = mpsc::channel::<()>(1);
let (off_recv_tx, mut off_recv_rx) = mpsc::channel::<Vec<u8>>(4);
let (ans_recv_tx, mut ans_recv_rx) = mpsc::channel::<Vec<u8>>(4);
// ============ register ALL handlers BEFORE create_offer/set_remote ============
// Offerer: forward its gathered candidates toward the answerer.
{
let tx = off_ice_tx.clone();
pc_off.on_ice_candidate(Box::new(move |c: Option<RTCIceCandidate>| {
let tx = tx.clone();
Box::pin(async move {
if let Some(c) = c {
if let Ok(init) = c.to_json() {
let _ = tx.send(init);
}
}
})
}));
}
pc_off.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| {
Box::pin(async move { eprintln!("offerer pc state: {s}"); })
}));
// Offerer creates the reliable+ordered channel it will own.
let dc = pc_off
.create_data_channel(
"netdesk",
Some(RTCDataChannelInit {
ordered: Some(true),
..Default::default()
}),
)
.await
.expect("create_data_channel");
// Offerer's channel: on open, announce + send the ping; on message, forward
// whatever comes back to the test body.
{
let open_tx = off_open_tx.clone();
let dc_send = dc.clone();
dc.on_open(Box::new(move || {
let open_tx = open_tx.clone();
let dc_send = dc_send.clone();
Box::pin(async move {
let _ = open_tx.send(()).await;
let _ = dc_send.send(&Bytes::from_static(b"ping")).await;
})
}));
let recv_tx = off_recv_tx.clone();
dc.on_message(Box::new(move |msg: DataChannelMessage| {
let recv_tx = recv_tx.clone();
Box::pin(async move {
let _ = recv_tx.send(msg.data.to_vec()).await;
})
}));
}
// Answerer: forward its gathered candidates toward the offerer.
{
let tx = ans_ice_tx.clone();
pc_ans.on_ice_candidate(Box::new(move |c: Option<RTCIceCandidate>| {
let tx = tx.clone();
Box::pin(async move {
if let Some(c) = c {
if let Ok(init) = c.to_json() {
let _ = tx.send(init);
}
}
})
}));
}
pc_ans.on_peer_connection_state_change(Box::new(move |s: RTCPeerConnectionState| {
Box::pin(async move { eprintln!("answerer pc state: {s}"); })
}));
// Answerer binds the incoming channel: on open announce; on message forward
// to the test body AND echo "pong" back so the round-trip closes.
{
let open_tx = ans_open_tx.clone();
let recv_tx = ans_recv_tx.clone();
pc_ans.on_data_channel(Box::new(move |dc2: Arc<webrtc::data_channel::RTCDataChannel>| {
let open_tx = open_tx.clone();
let recv_tx = recv_tx.clone();
Box::pin(async move {
let open_tx2 = open_tx.clone();
dc2.on_open(Box::new(move || {
let open_tx2 = open_tx2.clone();
Box::pin(async move {
let _ = open_tx2.send(()).await;
})
}));
let recv_tx2 = recv_tx.clone();
let dc_echo = dc2.clone();
dc2.on_message(Box::new(move |msg: DataChannelMessage| {
let recv_tx2 = recv_tx2.clone();
let dc_echo = dc_echo.clone();
Box::pin(async move {
let _ = recv_tx2.send(msg.data.to_vec()).await;
let _ = dc_echo.send(&Bytes::from_static(b"pong")).await;
})
}));
})
}));
}
// ============ negotiate (offer/answer handed across directly) ============
let offer = pc_off.create_offer(None).await.expect("create_offer");
pc_off
.set_local_description(offer.clone())
.await
.expect("offerer set_local"); // starts offerer ICE gathering
pc_ans
.set_remote_description(offer)
.await
.expect("answerer set_remote(offer)");
// Answerer's remote desc is set -> safe to add the offerer's candidates.
tokio::spawn(drain_candidates(pc_ans.clone(), off_ice_rx));
let answer = pc_ans.create_answer(None).await.expect("create_answer");
pc_ans
.set_local_description(answer.clone())
.await
.expect("answerer set_local"); // starts answerer ICE gathering
pc_off
.set_remote_description(answer)
.await
.expect("offerer set_remote(answer)");
// Offerer's remote desc is set -> safe to add the answerer's candidates.
tokio::spawn(drain_candidates(pc_off.clone(), ans_ice_rx));
// ============ assert: both ends open + the payload round-trips ============
let t = Duration::from_secs(20);
timeout(t, off_open_rx.recv())
.await
.expect("offerer DataChannel open timed out")
.expect("offerer open channel closed");
timeout(t, ans_open_rx.recv())
.await
.expect("answerer DataChannel open timed out")
.expect("answerer open channel closed");
let got_ping = timeout(t, ans_recv_rx.recv())
.await
.expect("answerer recv timed out")
.expect("answerer recv channel closed");
assert_eq!(got_ping, b"ping", "answerer should receive the ping intact");
let got_pong = timeout(t, off_recv_rx.recv())
.await
.expect("offerer recv timed out")
.expect("offerer recv channel closed");
assert_eq!(got_pong, b"pong", "offerer should receive the echoed pong intact");
// Tidy up (also stops the drain tasks when the senders drop).
pc_off.close().await.expect("close offerer");
pc_ans.close().await.expect("close answerer");
println!("P2P_LOOPBACK_OK datachannel formed + round-tripped server-free");
}
// ========================= Phase 3: full host<->viewer over the DataChannel =========================
/// Register the trickle-ICE candidate shim on both peers and drive the
/// offer/answer exchange. Caller must have registered every dc / on_data_channel
/// handler BEFORE calling this (candidates + the data channel must be in place
/// before set_local_description / set_remote_description).
async fn drive_negotiation(pc_off: &Arc<RTCPeerConnection>, pc_ans: &Arc<RTCPeerConnection>) {
let (off_ice_tx, off_ice_rx) = mpsc::unbounded_channel::<RTCIceCandidateInit>();
let (ans_ice_tx, ans_ice_rx) = mpsc::unbounded_channel::<RTCIceCandidateInit>();
{
let tx = off_ice_tx.clone();
pc_off.on_ice_candidate(Box::new(move |c: Option<RTCIceCandidate>| {
let tx = tx.clone();
Box::pin(async move {
if let Some(c) = c {
if let Ok(i) = c.to_json() {
let _ = tx.send(i);
}
}
})
}));
}
{
let tx = ans_ice_tx.clone();
pc_ans.on_ice_candidate(Box::new(move |c: Option<RTCIceCandidate>| {
let tx = tx.clone();
Box::pin(async move {
if let Some(c) = c {
if let Ok(i) = c.to_json() {
let _ = tx.send(i);
}
}
})
}));
}
let offer = pc_off.create_offer(None).await.expect("create_offer");
pc_off
.set_local_description(offer.clone())
.await
.expect("offerer set_local");
// Munge the SDP the peer receives to advertise max-message-size:262144
// (same as the production offer_nontrickle path), so whole ~200KB frames
// are accepted.
let offer_rx = webrtc::peer_connection::sdp::session_description::RTCSessionDescription::offer(
netdesk_core::advertise_max_message_size(offer.sdp),
)
.expect("offer munge");
pc_ans
.set_remote_description(offer_rx)
.await
.expect("answerer set_remote(offer)");
tokio::spawn(drain_candidates(pc_ans.clone(), off_ice_rx));
let answer = pc_ans.create_answer(None).await.expect("create_answer");
pc_ans
.set_local_description(answer.clone())
.await
.expect("answerer set_local");
let answer_rx =
webrtc::peer_connection::sdp::session_description::RTCSessionDescription::answer(
netdesk_core::advertise_max_message_size(answer.sdp),
)
.expect("answer munge");
pc_off
.set_remote_description(answer_rx)
.await
.expect("offerer set_remote(answer)");
tokio::spawn(drain_candidates(pc_off.clone(), ans_ice_rx));
}
/// Bring up a connected pair of `WebRtcChannel`s on loopback: the viewer side
/// is the offerer that creates the single reliable+ordered channel, the host
/// side binds it via on_data_channel. Both channels are open on return. The pcs
/// are returned so the caller keeps them alive (and can close them).
async fn connected_channel_pair() -> (
WebRtcChannel,
WebRtcChannel,
Arc<RTCPeerConnection>,
Arc<RTCPeerConnection>,
) {
let api = build_api().expect("build_api");
let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("viewer pc"); // offerer
let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("host pc"); // answerer
let (off_open_tx, mut off_open_rx) = mpsc::channel::<()>(1);
let (ans_open_tx, mut ans_open_rx) = mpsc::channel::<()>(1);
let (host_dc_tx, mut host_dc_rx) = mpsc::channel::<Arc<RTCDataChannel>>(1);
// Viewer owns the channel.
let dc = pc_off
.create_data_channel(
"netdesk",
Some(RTCDataChannelInit {
ordered: Some(true),
..Default::default()
}),
)
.await
.expect("create_data_channel");
{
let tx = off_open_tx.clone();
dc.on_open(Box::new(move || {
let tx = tx.clone();
Box::pin(async move {
let _ = tx.send(()).await;
})
}));
}
// Host binds the incoming channel + reports when it opens.
{
let open_tx = ans_open_tx.clone();
let dc_tx = host_dc_tx.clone();
pc_ans.on_data_channel(Box::new(move |d: Arc<RTCDataChannel>| {
let open_tx = open_tx.clone();
let dc_tx = dc_tx.clone();
Box::pin(async move {
let open_tx2 = open_tx.clone();
d.on_open(Box::new(move || {
let t = open_tx2.clone();
Box::pin(async move {
let _ = t.send(()).await;
})
}));
let _ = dc_tx.send(d).await;
})
}));
}
drive_negotiation(&pc_off, &pc_ans).await;
let t = Duration::from_secs(20);
timeout(t, off_open_rx.recv())
.await
.expect("viewer dc open timed out")
.expect("viewer open channel closed");
timeout(t, ans_open_rx.recv())
.await
.expect("host dc open timed out")
.expect("host open channel closed");
let host_dc = timeout(t, host_dc_rx.recv())
.await
.expect("host dc never arrived")
.expect("host dc channel closed");
// Wrap AFTER open and BEFORE any protocol loop runs, so no app message is
// sent before its reassembly handler is installed.
let viewer_chan = WebRtcChannel::new(dc).await;
let host_chan = WebRtcChannel::new(host_dc).await;
(viewer_chan, host_chan, pc_off, pc_ans)
}
/// A ~`n`-byte stand-in Annex-B access unit with a varying byte pattern (so a
/// mis-ordered / dropped fragment is caught by the byte-equality check on top
/// of the AES-GCM auth tag). Starts with a NAL start code for realism.
fn make_big_keyframe(n: usize) -> Vec<u8> {
let mut v = Vec::with_capacity(n);
v.extend_from_slice(&[0, 0, 0, 1, 0x65]); // start code + IDR NAL header
let mut i: usize = 0;
while v.len() < n {
v.push((i % 251) as u8);
i += 1;
}
v.truncate(n);
v
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn host_viewer_over_datachannel() {
let (viewer_chan, host_chan, pc_off, pc_ans) = connected_channel_pair().await;
// ---- host over its DataChannel end ----
let password = "p2p-dc-test-pw";
let (salt_b64, iters, verifier_hex) = new_host_credentials(password);
let host_id = gen_host_id();
let (frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
let hcfg = HostConfig {
ws_url: "p2p://loopback".into(), // unused: transport is supplied directly
host_id: host_id.clone(),
name: "P2PHost".into(),
salt_b64,
iters,
verifier_hex,
width: 1280,
height: 720,
audio: false,
monitors: Vec::new(),
monitor: 0,
perms: Default::default(),
approve: false,
totp_secret: String::new(),
};
let host_task = tokio::spawn(async move {
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_chan)).await
});
// ---- viewer over its DataChannel end ----
let (vtx, mut vrx) = mpsc::unbounded_channel::<SessionEvent>();
let vcfg = ConnectConfig {
ws_url: "p2p://loopback".into(),
host_id: host_id.clone(),
password: password.into(),
totp: String::new(),
takeover: false,
};
let session = connect_with(vcfg, vtx, Box::new(viewer_chan))
.await
.expect("viewer connect+handshake over DataChannel");
// Handshake completed peer-to-peer (no relay): E2E negotiated, size advertised.
match wait_view(&mut vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await {
Some(SessionEvent::Connected { w, h, e2e, .. }) => {
assert_eq!((w, h), (1280, 720), "advertised resolution over DataChannel");
assert!(e2e, "E2E negotiated over DataChannel");
}
_ => panic!("viewer never got Connected over the DataChannel"),
}
assert!(
wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 15)
.await
.is_some(),
"host never saw ViewerConnected over the DataChannel"
);
// Input flows viewer -> host over the ctl path of the channel.
session.mouse(123, 456, 1, 0.0, 0.0);
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await {
Some(HostEvent::Mouse { x, y, buttons, .. }) => {
assert_eq!((x, y, buttons), (123, 456, 1), "mouse routed over DataChannel");
}
_ => panic!("host never received the viewer's mouse over the DataChannel"),
}
session.key("KeyA", true);
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Key { .. }), 10).await {
Some(HostEvent::Key { code, down }) => {
assert_eq!((code.as_str(), down), ("KeyA", true), "key routed over DataChannel");
}
_ => panic!("host never received the viewer's key over the DataChannel"),
}
// A small keyframe fed to the host decrypts to Video on the viewer (one chunk).
let small = vec![0, 0, 0, 1, 0x67, 0x42, 0, 0x28, 0, 0, 0, 1, 0x65, 9, 9, 9];
let _ = frame_tx.send(HostFrame {
data: small.clone(),
keyframe: true,
codec: 0,
});
match wait_view(&mut vrx, |e| matches!(e, SessionEvent::Video { keyframe: true, .. }), 10).await {
Some(SessionEvent::Video { au, .. }) => {
assert_eq!(au, small, "small keyframe AU must survive the DataChannel intact");
}
_ => panic!("small keyframe never arrived as Video over the DataChannel"),
}
// A ~200 KB keyframe exercises fragmentation (>16 KiB -> ~13 chunks) +
// reassembly. If any chunk were mis-ordered or dropped, the rejoined blob
// would fail the AES-GCM auth tag and never emit — so arrival with matching
// bytes proves contiguous reassembly end to end.
let big = make_big_keyframe(200 * 1024);
let _ = frame_tx.send(HostFrame {
data: big.clone(),
keyframe: true,
codec: 0,
});
match wait_view(&mut vrx, |e| matches!(e, SessionEvent::Video { keyframe: true, .. }), 15).await {
Some(SessionEvent::Video { au, .. }) => {
assert_eq!(au.len(), big.len(), "reassembled keyframe length must match");
assert_eq!(au, big, "reassembled keyframe bytes identical (GCM proves contiguity)");
}
_ => panic!("200KB keyframe never reassembled into Video over the DataChannel"),
}
session.close();
drop(frame_tx);
let _ = timeout(Duration::from_secs(5), host_task).await;
pc_off.close().await.ok();
pc_ans.close().await.ok();
println!("P2P_HOST_VIEWER_OVER_DC_OK video+input+200KB keyframe over the DataChannel, E2E intact");
}
// --- waiters (mirror core/tests/host_viewer.rs) ---
async fn wait_host<F: Fn(&HostEvent) -> bool>(
rx: &mut mpsc::UnboundedReceiver<HostEvent>,
pred: F,
secs: u64,
) -> Option<HostEvent> {
let deadline = Duration::from_secs(secs);
loop {
match timeout(deadline, rx.recv()).await {
Ok(Some(ev)) => {
if pred(&ev) {
return Some(ev);
}
}
_ => return None,
}
}
}
async fn wait_view<F: Fn(&SessionEvent) -> bool>(
rx: &mut mpsc::UnboundedReceiver<SessionEvent>,
pred: F,
secs: u64,
) -> Option<SessionEvent> {
let deadline = Duration::from_secs(secs);
loop {
match timeout(deadline, rx.recv()).await {
Ok(Some(ev)) => {
if pred(&ev) {
return Some(ev);
}
}
_ => return None,
}
}
}
// ===================== Phase 4: non-trickle SDP-only negotiation =====================
// The relay carries only rtc.offer.sdp and rtc.answer.sdp (NON-TRICKLE): every
// ICE candidate is embedded in the gathered SDP, with no separate candidate
// message. This test drives offer_nontrickle / answer_nontrickle / apply_answer
// exactly as the relay path will, exchanging ONLY the two SDP strings (no
// candidate shim), and proves the pc still connects + the DataChannel opens.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn nontrickle_sdp_exchange_connects() {
let api = build_api().expect("build_api");
let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("offerer pc");
let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("answerer pc");
let (off_open_tx, mut off_open_rx) = mpsc::channel::<()>(1);
let (ans_open_tx, mut ans_open_rx) = mpsc::channel::<()>(1);
// Offerer creates the channel BEFORE offer_nontrickle (it shapes the offer).
let dc = pc_off
.create_data_channel(
"netdesk",
Some(RTCDataChannelInit {
ordered: Some(true),
..Default::default()
}),
)
.await
.expect("create_data_channel");
{
let tx = off_open_tx.clone();
dc.on_open(Box::new(move || {
let tx = tx.clone();
Box::pin(async move {
let _ = tx.send(()).await;
})
}));
}
{
let tx = ans_open_tx.clone();
pc_ans.on_data_channel(Box::new(move |d: Arc<RTCDataChannel>| {
let tx = tx.clone();
Box::pin(async move {
let t = tx.clone();
d.on_open(Box::new(move || {
let t = t.clone();
Box::pin(async move {
let _ = t.send(()).await;
})
}));
})
}));
}
// The ENTIRE signaling is these three calls + two SDP strings. No candidate
// exchange at all — exactly the relay topology.
let offer_sdp = offer_nontrickle(&pc_off, Duration::from_millis(2500))
.await
.expect("offer_nontrickle");
let answer_sdp = answer_nontrickle(&pc_ans, &offer_sdp, Duration::from_millis(2500))
.await
.expect("answer_nontrickle");
apply_answer(&pc_off, &answer_sdp)
.await
.expect("apply_answer");
// Non-trickle means candidates must be embedded in the SDP.
assert!(
offer_sdp.contains("a=candidate"),
"offer SDP must embed ICE candidates (non-trickle)"
);
assert!(
answer_sdp.contains("a=candidate"),
"answer SDP must embed ICE candidates (non-trickle)"
);
let t = Duration::from_secs(20);
timeout(t, off_open_rx.recv())
.await
.expect("offerer dc open timed out")
.expect("offerer open channel closed");
timeout(t, ans_open_rx.recv())
.await
.expect("answerer dc open timed out")
.expect("answerer open channel closed");
pc_off.close().await.ok();
pc_ans.close().await.ok();
println!("P2P_NONTRICKLE_OK offer/answer SDP (candidates embedded) connected, server-free");
}
// ============ Phase 4: relay-brokered rtc.* handshake -> live pc ============
// Proves upgrade_offerer (viewer) + answer_offer (host) negotiate a real pc
// purely by exchanging rtc.* text through a relay-like pipe — exactly what the
// WSS relay carries between the two session peers, minus the server (so the
// rtc.config.req is skipped via ice_override, as on loopback). A dumb
// bidirectional String pipe stands in for the relay.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rtc_relay_handshake_connects() {
// relay pipe: viewer->host and host->viewer rtc.* text.
let (v2h_tx, mut v2h_rx) = mpsc::unbounded_channel::<String>();
let (h2v_tx, mut h2v_rx) = mpsc::unbounded_channel::<String>();
// viewer's rtc_in: forward only rtc.* Values arriving from the host.
let (v_rtc_tx, mut v_rtc_rx) = mpsc::unbounded_channel::<Value>();
tokio::spawn(async move {
while let Some(s) = h2v_rx.recv().await {
if let Ok(v) = serde_json::from_str::<Value>(&s) {
if v.get("t")
.and_then(|t| t.as_str())
.map(|t| t.starts_with("rtc."))
.unwrap_or(false)
{
let _ = v_rtc_tx.send(v);
}
}
}
});
// host side: wait for rtc.offer off the relay, then answer it.
let h2v_tx_host = h2v_tx.clone();
let host = tokio::spawn(async move {
let api = build_api().expect("host build_api");
loop {
match v2h_rx.recv().await {
Some(s) => {
let v: Value = match serde_json::from_str(&s) {
Ok(v) => v,
Err(_) => continue,
};
if v.get("t").and_then(|t| t.as_str()) == Some("rtc.offer") {
let sid = v.get("sid").and_then(|x| x.as_str()).unwrap_or("").to_string();
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();
let send = {
let tx = h2v_tx_host.clone();
move |m: String| {
let _ = tx.send(m);
}
};
return answer_offer(&api, &sid, &sdp, ice, &send).await;
}
// ignore rtc.config.req + anything else (server-answered, not us)
}
None => return Err(anyhow::anyhow!("relay closed before rtc.offer")),
}
}
});
// viewer side: run the offerer handshake. ice_override Some(vec![]) => no
// rtc.config.req, loopback host candidates only.
let send_v = {
let tx = v2h_tx.clone();
move |m: String| {
let _ = tx.send(m);
}
};
let viewer = upgrade_offerer(
&build_api().expect("viewer build_api"),
"relay-test-sid",
Some(vec![]),
"netdesk",
&send_v,
&mut v_rtc_rx,
)
.await;
let (vpc, vdc) = viewer.expect("viewer upgrade_offerer");
let (hpc, hdc) = host.await.unwrap().expect("host answer_offer");
// Both returned only after pc Connected + DataChannel open. Prove the
// established channel actually carries data end to end.
let (got_tx, mut got_rx) = mpsc::channel::<Vec<u8>>(1);
hdc.on_message(Box::new(move |msg: DataChannelMessage| {
let got_tx = got_tx.clone();
Box::pin(async move {
let _ = got_tx.send(msg.data.to_vec()).await;
})
}));
vdc.send(&Bytes::from_static(b"over-relay"))
.await
.expect("send over negotiated channel");
let got = timeout(Duration::from_secs(10), got_rx.recv())
.await
.expect("round-trip timed out")
.expect("round-trip channel closed");
assert_eq!(got, b"over-relay", "data must cross the relay-negotiated channel");
vpc.close().await.ok();
hpc.close().await.ok();
println!("P2P_RTC_RELAY_OK rtc.offer/rtc.answer over the relay -> live pc + DataChannel");
}
// ===================== Phase 5 harness: in-memory relay =====================
// A dumb bidirectional in-process pipe that behaves like the WSS relay for a
// single viewer<->host pair: every frame one end sends is delivered verbatim to
// the other. Lets the full connect_with / run_host_with stacks run server-free,
// and (next) lets the P2P multiplexer negotiate over it.
struct MemTransport {
tx: mpsc::UnboundedSender<TransportMsg>,
rx: mpsc::UnboundedReceiver<TransportMsg>,
/// When set, this end silently drops outbound binary (simulates the relay
/// media path going away so only a P2P channel can carry video).
drop_binary: Arc<AtomicBool>,
/// When set, this end silently drops outbound rtc.* text (simulates a P2P
/// upgrade that can never negotiate, forcing the relay-only fallback).
drop_rtc: Arc<AtomicBool>,
}
/// Control handles for one relay end: toggle dropping of binary / rtc.* on it.
#[derive(Clone)]
struct MemCtl {
drop_binary: Arc<AtomicBool>,
drop_rtc: Arc<AtomicBool>,
}
impl MemTransport {
fn ctl(&self) -> MemCtl {
MemCtl {
drop_binary: self.drop_binary.clone(),
drop_rtc: self.drop_rtc.clone(),
}
}
}
fn mem_relay_pair() -> (MemTransport, MemTransport) {
let (a_tx, a_rx) = mpsc::unbounded_channel::<TransportMsg>();
let (b_tx, b_rx) = mpsc::unbounded_channel::<TransportMsg>();
// end A writes to b_tx (read by B), end B writes to a_tx (read by A).
(
MemTransport {
tx: b_tx,
rx: a_rx,
drop_binary: Arc::new(AtomicBool::new(false)),
drop_rtc: Arc::new(AtomicBool::new(false)),
},
MemTransport {
tx: a_tx,
rx: b_rx,
drop_binary: Arc::new(AtomicBool::new(false)),
drop_rtc: Arc::new(AtomicBool::new(false)),
},
)
}
#[async_trait]
impl Transport for MemTransport {
async fn send_binary(&mut self, data: Vec<u8>) -> netdesk_core::Result<()> {
if self.drop_binary.load(Ordering::SeqCst) {
return Ok(()); // silently dropped
}
self.tx
.send(TransportMsg::Binary(data))
.map_err(|_| NdError::Connect("mem relay peer gone".into()))
}
async fn send_text(&mut self, text: String) -> netdesk_core::Result<()> {
if self.drop_rtc.load(Ordering::SeqCst) && text.contains("\"t\":\"rtc.") {
return Ok(()); // silently dropped
}
self.tx
.send(TransportMsg::Text(text))
.map_err(|_| NdError::Connect("mem relay peer gone".into()))
}
async fn recv(&mut self) -> Option<netdesk_core::Result<TransportMsg>> {
self.rx.recv().await.map(Ok)
}
}
/// Sanity: the full host<->viewer stack runs over the in-memory relay with NO
/// server and NO P2P — proving the harness faithfully stands in for the WSS
/// relay before we layer the P2P multiplexer on top.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn relay_pair_host_viewer_no_p2p() {
let (viewer_relay, host_relay) = mem_relay_pair();
let password = "mem-relay-pw";
let (salt_b64, iters, verifier_hex) = new_host_credentials(password);
let host_id = gen_host_id();
let (frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
let hcfg = HostConfig {
ws_url: "mem://relay".into(),
host_id: host_id.clone(),
name: "MemHost".into(),
salt_b64,
iters,
verifier_hex,
width: 1600,
height: 900,
audio: false,
monitors: Vec::new(),
monitor: 0,
perms: Default::default(),
approve: false,
totp_secret: String::new(),
};
let host_task = tokio::spawn(async move {
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
});
let (vtx, mut vrx) = mpsc::unbounded_channel::<SessionEvent>();
let vcfg = ConnectConfig {
ws_url: "mem://relay".into(),
host_id: host_id.clone(),
password: password.into(),
totp: String::new(),
takeover: false,
};
let session = connect_with(vcfg, vtx, Box::new(viewer_relay))
.await
.expect("viewer connect over the in-memory relay");
match wait_view(&mut vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await {
Some(SessionEvent::Connected { w, h, e2e, .. }) => {
assert_eq!((w, h), (1600, 900));
assert!(e2e, "E2E over the in-memory relay");
}
_ => panic!("viewer never got Connected over the in-memory relay"),
}
// The viewer advertises VP9 decode in session.begin; the host must surface
// it so the capture loop can safely pick VP9 on constrained links.
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::ViewerConnected { .. }), 15).await {
Some(HostEvent::ViewerConnected { vp9, .. }) => {
assert!(vp9, "host should see the viewer's VP9 capability (negotiation)");
}
_ => panic!("host never saw ViewerConnected over the in-memory relay"),
}
session.mouse(321, 654, 2, 0.0, 0.0);
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10).await {
Some(HostEvent::Mouse { x, y, buttons, .. }) => assert_eq!((x, y, buttons), (321, 654, 2)),
_ => panic!("mouse not routed over the in-memory relay"),
}
let au = vec![0, 0, 0, 1, 0x65, 1, 2, 3, 4, 5];
let _ = frame_tx.send(HostFrame {
data: au.clone(),
keyframe: true,
codec: 0,
});
match wait_view(&mut vrx, |e| matches!(e, SessionEvent::Video { keyframe: true, .. }), 10).await {
Some(SessionEvent::Video { au: got, .. }) => assert_eq!(got, au),
_ => panic!("video not routed over the in-memory relay"),
}
session.close();
drop(frame_tx);
let _ = timeout(Duration::from_secs(5), host_task).await;
println!("MEM_RELAY_OK full host<->viewer stack over an in-memory relay, server-free");
}
// ===================== Phase 5: relay-first upgrade / fallback / downgrade =====================
// Each wraps BOTH ends of the in-memory relay in the P2pRelay multiplexer and
// runs the unchanged connect_with / run_host_with over them. The controllable
// relay lets us prove routing: dropping relay BINARY forces video onto P2P (so
// its arrival proves the upgrade); dropping relay RTC.* forces the relay-only
// fallback; P2pControl::force_relay drives the seamless downgrade.
struct P2pFixture {
session: Session,
vrx: mpsc::UnboundedReceiver<SessionEvent>,
hev_rx: mpsc::UnboundedReceiver<HostEvent>,
frame_tx: mpsc::UnboundedSender<HostFrame>,
host_task: tokio::task::JoinHandle<netdesk_core::Result<()>>,
viewer_ctl: P2pControl,
host_ctl: P2pControl,
}
async fn start_p2p_session(viewer_relay: MemTransport, host_relay: MemTransport) -> P2pFixture {
let viewer_p2p = upgrade_relay(
Box::new(viewer_relay),
P2pRole::Offerer,
Arc::new(build_api().expect("viewer api")),
);
let host_p2p = upgrade_relay(
Box::new(host_relay),
P2pRole::Answerer,
Arc::new(build_api().expect("host api")),
);
let viewer_ctl = viewer_p2p.control();
let host_ctl = host_p2p.control();
let password = "p2p-mux-pw";
let (salt_b64, iters, verifier_hex) = new_host_credentials(password);
let host_id = gen_host_id();
let (frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
let (hev_tx, hev_rx) = mpsc::unbounded_channel::<HostEvent>();
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
let hcfg = HostConfig {
ws_url: "mem://relay".into(),
host_id: host_id.clone(),
name: "MuxHost".into(),
salt_b64,
iters,
verifier_hex,
width: 1024,
height: 768,
audio: false,
monitors: Vec::new(),
monitor: 0,
perms: Default::default(),
approve: false,
totp_secret: String::new(),
};
let host_task = tokio::spawn(async move {
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_p2p)).await
});
let (vtx, vrx) = mpsc::unbounded_channel::<SessionEvent>();
let vcfg = ConnectConfig {
ws_url: "mem://relay".into(),
host_id: host_id.clone(),
password: password.into(),
totp: String::new(),
takeover: false,
};
let session = connect_with(vcfg, vtx, Box::new(viewer_p2p))
.await
.expect("viewer connect over the P2P multiplexer");
P2pFixture {
session,
vrx,
hev_rx,
frame_tx,
host_task,
viewer_ctl,
host_ctl,
}
}
/// Feed `au` as a keyframe up to `tries` times, returning true as soon as the
/// viewer emits a matching Video (used to ride out the few seconds the upgrade
/// takes without a fixed sleep).
async fn feed_until_video(fx: &mut P2pFixture, au: &[u8], tries: u32) -> bool {
for _ in 0..tries {
let _ = fx.frame_tx.send(HostFrame {
data: au.to_vec(),
keyframe: true,
codec: 0,
});
if let Some(SessionEvent::Video { au: got, .. }) =
wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Video { .. }), 1).await
{
if got == au {
return true;
}
}
}
false
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn p2p_upgrade_video_rides_the_channel() {
let (viewer_relay, mut host_relay) = mem_relay_pair();
// Kill the relay BINARY path host->viewer from the start. The rtc.* handshake
// is text, so it still completes; once P2P is up, video can ONLY arrive over
// the DataChannel.
host_relay.drop_binary.store(true, Ordering::SeqCst);
let mut fx = start_p2p_session(viewer_relay, host_relay).await;
match wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await {
Some(SessionEvent::Connected { e2e, .. }) => assert!(e2e, "E2E over the relay handshake"),
_ => panic!("session never connected over the relay"),
}
// ~40 KB keyframe (fragments over the channel). Arrival proves the upgrade
// took over, since the relay binary path is dead.
let au = make_big_keyframe(40 * 1024);
assert!(
feed_until_video(&mut fx, &au, 40).await,
"video never arrived — P2P upgrade did not take over the dead relay binary path"
);
fx.session.close();
drop(fx.frame_tx);
let _ = timeout(Duration::from_secs(5), fx.host_task).await;
println!("P2P_UPGRADE_OK video rode the DataChannel while the relay binary path was dead");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn p2p_fallback_stays_on_relay() {
let (mut viewer_relay, mut host_relay) = mem_relay_pair();
// Drop rtc.* on BOTH ends: the upgrade can never negotiate, so media must
// keep flowing over the relay, uninterrupted.
viewer_relay.drop_rtc.store(true, Ordering::SeqCst);
host_relay.drop_rtc.store(true, Ordering::SeqCst);
let mut fx = start_p2p_session(viewer_relay, host_relay).await;
match wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await {
Some(_) => {}
_ => panic!("session never connected (relay fallback)"),
}
// Video flows over the relay (binary path is open).
let au = vec![0, 0, 0, 1, 0x65, 7, 7, 7, 7];
let _ = fx.frame_tx.send(HostFrame {
data: au.clone(),
keyframe: true,
codec: 0,
});
match wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Video { .. }), 10).await {
Some(SessionEvent::Video { au: got, .. }) => assert_eq!(got, au),
_ => panic!("video did not flow over the relay while the upgrade was failing"),
}
// Input flows over the relay too.
fx.session.mouse(11, 22, 1, 0.0, 0.0);
assert!(
wait_host(&mut fx.hev_rx, |e| matches!(e, HostEvent::Mouse { .. }), 10)
.await
.is_some(),
"input did not flow over the relay during fallback"
);
fx.session.close();
drop(fx.frame_tx);
let _ = timeout(Duration::from_secs(5), fx.host_task).await;
println!("P2P_FALLBACK_OK upgrade could not negotiate; media stayed on the relay");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn p2p_downgrade_reverts_to_relay() {
let (viewer_relay, host_relay) = mem_relay_pair();
let host_bin = host_relay.drop_binary.clone();
let mut fx = start_p2p_session(viewer_relay, host_relay).await;
match wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await {
Some(_) => {}
_ => panic!("session never connected"),
}
// 1) Upgrade: kill the relay binary path, prove video rides P2P.
host_bin.store(true, Ordering::SeqCst);
let au = make_big_keyframe(32 * 1024);
assert!(
feed_until_video(&mut fx, &au, 40).await,
"never upgraded to P2P before the downgrade test"
);
// 2) Downgrade: force both ends back to the relay and re-open the relay
// binary path. The reroute must be seamless — no Ended, video resumes.
fx.viewer_ctl.force_relay();
fx.host_ctl.force_relay();
host_bin.store(false, Ordering::SeqCst);
let au2 = vec![0, 0, 0, 1, 0x65, 9, 8, 7, 6, 5, 4];
assert!(
feed_until_video(&mut fx, &au2, 40).await,
"video did not resume over the relay after the forced downgrade"
);
// The session is still alive (no Ended surfaced during the reroute).
assert!(
!fx.host_task.is_finished(),
"host ended during downgrade — reroute was not seamless"
);
fx.session.close();
drop(fx.frame_tx);
let _ = timeout(Duration::from_secs(5), fx.host_task).await;
println!("P2P_DOWNGRADE_OK forced back to the relay mid-session; video resumed seamlessly");
}
// =============== Phase 6: web 3-channel topology forms + routes per lane ===============
// Proves the native peer can speak the web/Python media/ctl/move topology:
// the offerer creates the three labelled channels, the answerer binds them by
// label, and a probe on each arrives on the matching channel. This is the
// interop wire shape (labels + options match app.js); routing it through the
// P2pRelay engine is layered on top.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn web_channels_form_and_route_per_lane() {
let api = build_api().expect("build_api");
let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("offerer pc");
let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("answerer pc");
// offerer: the three web channels (created before negotiation so they are
// announced in the offer SDP).
let web = create_web_channels(&pc_off).await.expect("create_web_channels");
// answerer: bind each incoming channel by label; record which label each
// received message arrived on.
let (open_tx, mut open_rx) = mpsc::channel::<String>(8);
let got: Arc<StdMutex<HashMap<String, Vec<u8>>>> = Arc::new(StdMutex::new(HashMap::new()));
{
let open_tx = open_tx.clone();
let got = got.clone();
pc_ans.on_data_channel(Box::new(move |d: Arc<RTCDataChannel>| {
let open_tx = open_tx.clone();
let got = got.clone();
let label = d.label().to_string();
Box::pin(async move {
let l_open = label.clone();
let open_tx2 = open_tx.clone();
d.on_open(Box::new(move || {
let l = l_open.clone();
let tx = open_tx2.clone();
Box::pin(async move {
let _ = tx.send(l).await;
})
}));
let l_msg = label.clone();
let got2 = got.clone();
d.on_message(Box::new(move |msg: DataChannelMessage| {
let l = l_msg.clone();
let got2 = got2.clone();
Box::pin(async move {
got2.lock().unwrap().insert(l, msg.data.to_vec());
})
}));
})
}));
}
drive_negotiation(&pc_off, &pc_ans).await;
// all three channels must open on the answerer (labels prove the topology).
let t = Duration::from_secs(20);
let mut opened = std::collections::HashSet::new();
while opened.len() < 3 {
let lbl = timeout(t, open_rx.recv())
.await
.expect("web channel open timed out")
.expect("open channel closed");
opened.insert(lbl);
}
assert!(opened.contains("media") && opened.contains("ctl") && opened.contains("move"),
"expected media/ctl/move, got {opened:?}");
// send a distinct probe on each lane; each must arrive on its own channel.
web.media.send(&Bytes::from_static(b"V")).await.expect("media send");
web.ctl.send(&Bytes::from_static(b"C")).await.expect("ctl send");
web.mov.send(&Bytes::from_static(b"M")).await.expect("move send");
// wait until all three distinct labels have delivered.
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
{
let g = got.lock().unwrap();
if g.len() >= 3 {
assert_eq!(g.get("media").map(|v| v.as_slice()), Some(&b"V"[..]));
assert_eq!(g.get("ctl").map(|v| v.as_slice()), Some(&b"C"[..]));
assert_eq!(g.get("move").map(|v| v.as_slice()), Some(&b"M"[..]));
break;
}
}
if tokio::time::Instant::now() > deadline {
panic!("not all web channels delivered: {:?}", got.lock().unwrap().keys().collect::<Vec<_>>());
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
pc_off.close().await.ok();
pc_ans.close().await.ok();
println!("P2P_WEB3_OK media/ctl/move formed with web labels/options + routed per channel");
}
/// Pins down webrtc-rs 0.17.2's whole-message behaviour, which decides how
/// web/Python interop is possible:
/// * A whole frame up to ~64 KiB round-trips intact in one message.
/// * A whole ~200 KiB frame: the SEND is accepted (we advertise
/// max-message-size:262144, so the sender's cap is lifted) but the RECEIVER
/// never delivers it — webrtc-rs reassembles whole messages only up to the
/// 64 KiB default, with no setting to raise the receive side.
///
/// So the native single channel app-fragments at 16 KiB (each chunk a separate
/// <64 KiB message, reassembled by install_reassembler) — which is why
/// host_viewer_over_datachannel carries a 200 KiB keyframe fine. The web/Python
/// `media` channel instead sends ONE whole large frame (SCTP-fragmented by
/// aiortc/the browser), which webrtc-rs 0.17.2 will not reassemble on receive —
/// the concrete blocker for native<->web/Python video interop (see P2P-PLAN.md).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn webrtc_whole_message_64k_receive_limit() {
let api = build_api().expect("build_api");
let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("offerer pc");
let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("answerer pc");
let (open_tx, mut open_rx) = mpsc::channel::<()>(1);
let (got_tx, mut got_rx) = mpsc::channel::<usize>(8);
let dc = pc_off
.create_data_channel(
"ctl",
Some(RTCDataChannelInit {
ordered: Some(true),
..Default::default()
}),
)
.await
.expect("create channel");
{
let tx = open_tx.clone();
dc.on_open(Box::new(move || {
let tx = tx.clone();
Box::pin(async move {
let _ = tx.send(()).await;
})
}));
}
{
let got_tx = got_tx.clone();
pc_ans.on_data_channel(Box::new(move |d: Arc<RTCDataChannel>| {
let got_tx = got_tx.clone();
Box::pin(async move {
let got_tx = got_tx.clone();
d.on_message(Box::new(move |msg: DataChannelMessage| {
let got_tx = got_tx.clone();
Box::pin(async move {
let _ = got_tx.send(msg.data.len()).await;
})
}));
})
}));
}
drive_negotiation(&pc_off, &pc_ans).await;
timeout(Duration::from_secs(20), open_rx.recv())
.await
.expect("open timeout")
.expect("closed");
// A sub-64 KiB whole message round-trips.
let small = vec![0x5Au8; 60 * 1024];
dc.send(&Bytes::from(small.clone())).await.expect("60K send");
let n = timeout(Duration::from_secs(5), got_rx.recv())
.await
.expect("60K recv timed out")
.expect("closed");
assert_eq!(n, small.len(), "a sub-64KiB whole message must arrive intact");
// A ~200 KiB whole message: send accepted (cap lifted), but NOT received.
let big = make_big_keyframe(200 * 1024);
dc.send(&Bytes::from(big)).await.expect("200K send is accepted");
let late = timeout(Duration::from_secs(3), got_rx.recv()).await;
assert!(
late.is_err() || late.unwrap().is_none(),
"webrtc-rs 0.17.2 does NOT deliver a whole >64KiB message (interop blocker)"
);
pc_off.close().await.ok();
pc_ans.close().await.ok();
println!("P2P_WHOLEMSG_LIMIT_OK <=64KiB whole msg delivers; a whole 200KiB msg is sent but not received");
}
/// THE UNBLOCK: with DETACHED channels (build_api_web) we read with our own
/// 256 KiB buffer, so a whole ~200 KiB message is received intact — exactly
/// what the default on_message path (64 KiB buffer) could not do. This is how a
/// native peer receives whole video frames from the web/Python `media` channel.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn detached_channel_receives_200k_whole() {
let api = build_api_web().expect("build_api_web");
let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("offerer pc");
let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("answerer pc");
// offerer: detach on open, hand the raw channel to the test body to write on.
let (raw_off_tx, mut raw_off_rx) =
mpsc::channel::<Arc<webrtc::data::data_channel::DataChannel>>(1);
let dc = pc_off
.create_data_channel(
"ctl",
Some(RTCDataChannelInit {
ordered: Some(true),
..Default::default()
}),
)
.await
.expect("create channel");
{
let dc_for_detach = dc.clone();
let raw_off_tx = raw_off_tx.clone();
dc.on_open(Box::new(move || {
let dc_for_detach = dc_for_detach.clone();
let raw_off_tx = raw_off_tx.clone();
Box::pin(async move {
if let Ok(raw) = dc_for_detach.detach().await {
let _ = raw_off_tx.send(raw).await;
}
})
}));
}
// answerer: detach on open, read with a 256 KiB buffer, forward whole msgs.
let (got_tx, mut got_rx) = mpsc::channel::<Vec<u8>>(4);
{
let got_tx = got_tx.clone();
pc_ans.on_data_channel(Box::new(move |d: Arc<RTCDataChannel>| {
let got_tx = got_tx.clone();
Box::pin(async move {
let d_for_detach = d.clone();
let got_tx = got_tx.clone();
d.on_open(Box::new(move || {
let d_for_detach = d_for_detach.clone();
let got_tx = got_tx.clone();
Box::pin(async move {
if let Ok(raw) = d_for_detach.detach().await {
let mut buf = vec![0u8; 262144];
loop {
match raw.read_data_channel(&mut buf).await {
Ok((0, _)) => break,
Ok((n, _)) => {
let _ = got_tx.send(buf[..n].to_vec()).await;
}
Err(_) => break,
}
}
}
})
}));
})
}));
}
drive_negotiation(&pc_off, &pc_ans).await;
let t = Duration::from_secs(20);
let raw_off = timeout(t, raw_off_rx.recv())
.await
.expect("offerer detach timed out")
.expect("offerer raw channel closed");
// 200 KB whole frame over the detached channel.
let frame = make_big_keyframe(200 * 1024);
raw_off
.write_data_channel(&Bytes::from(frame.clone()), false)
.await
.expect("200KB detached write");
let got = timeout(Duration::from_secs(10), got_rx.recv())
.await
.expect("200KB detached recv timed out")
.expect("closed");
assert_eq!(got, frame, "detached channel must receive the 200KB frame whole");
pc_off.close().await.ok();
pc_ans.close().await.ok();
println!("P2P_DETACH_OK detached channel received a whole 200KB frame (webrtc-rs 64K read limit bypassed)");
}
/// Diagnostic: does a DETACHED UNRELIABLE media channel (ordered:false,
/// maxPacketLifeTime:150) deliver a whole 200KB frame? (The passing detach test
/// used a reliable ctl channel.)
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn detached_media_unreliable_200k() {
let api = build_api_web().expect("build_api_web");
let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("off");
let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("ans");
let (raw_off_tx, mut raw_off_rx) =
mpsc::channel::<Arc<webrtc::data::data_channel::DataChannel>>(1);
let dc = pc_off
.create_data_channel(
"media",
Some(RTCDataChannelInit {
ordered: Some(false),
max_packet_life_time: Some(150),
..Default::default()
}),
)
.await
.expect("create media");
{
let dcd = dc.clone();
let raw_off_tx = raw_off_tx.clone();
dc.on_open(Box::new(move || {
let dcd = dcd.clone();
let raw_off_tx = raw_off_tx.clone();
Box::pin(async move {
if let Ok(raw) = dcd.detach().await {
let _ = raw_off_tx.send(raw).await;
}
})
}));
}
let (got_tx, mut got_rx) = mpsc::channel::<Vec<u8>>(4);
{
let got_tx = got_tx.clone();
pc_ans.on_data_channel(Box::new(move |d: Arc<RTCDataChannel>| {
let got_tx = got_tx.clone();
Box::pin(async move {
let dd = d.clone();
let got_tx = got_tx.clone();
d.on_open(Box::new(move || {
let dd = dd.clone();
let got_tx = got_tx.clone();
Box::pin(async move {
if let Ok(raw) = dd.detach().await {
tokio::spawn(async move {
let mut buf = vec![0u8; 262144];
loop {
match raw.read_data_channel(&mut buf).await {
Ok((0, _)) => break,
Ok((n, _)) => {
let _ = got_tx.send(buf[..n].to_vec()).await;
}
Err(_) => break,
}
}
});
}
})
}));
})
}));
}
drive_negotiation(&pc_off, &pc_ans).await;
let raw_off = timeout(Duration::from_secs(20), raw_off_rx.recv())
.await
.expect("off detach timeout")
.expect("closed");
let frame = make_big_keyframe(200 * 1024);
raw_off
.write_data_channel(&Bytes::from(frame.clone()), false)
.await
.expect("media write");
let got = timeout(Duration::from_secs(10), got_rx.recv())
.await
.expect("media recv timeout")
.expect("closed");
assert_eq!(got, frame);
pc_off.close().await.ok();
pc_ans.close().await.ok();
println!("P2P_MEDIA_UNREL_OK unreliable media delivered 200KB whole");
}
/// web-mode with detaches done SEQUENTIALLY in the test body (after opens are
/// signalled) instead of concurrently inside on_open handlers — isolating a
/// multi-detach contention. media(video 200KB host->viewer) + ctl(input
/// viewer->host) over the 3-channel topology.
///
/// IGNORED: flaky when BOTH pcs + their detach read-loops share ONE process /
/// tokio runtime (this loopback harness) — multi-channel detach intermittently
/// starves the runtime. In production the viewer and the Python/web host are
/// SEPARATE processes with separate runtimes, so this contention does not
/// occur; that path is verified live instead. Single-channel detach
/// (detached_channel_receives_200k_whole) is reliable.
#[ignore = "multi-channel detach flaky only when both pcs share one runtime (loopback); verified live"]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn web_mode_body_detach() {
use std::collections::HashMap as Map;
let api = build_api_web().expect("build_api_web");
let pc_off = new_pc(&api, rtc_config(vec![])).await.expect("off");
let pc_ans = new_pc(&api, rtc_config(vec![])).await.expect("ans");
let web = create_web_channels(&pc_off).await.expect("web chans");
// offerer open signals (no detach in handler)
let (off_open_tx, mut off_open_rx) = mpsc::channel::<String>(8);
for (lbl, ch) in [("media", &web.media), ("ctl", &web.ctl), ("move", &web.mov)] {
let tx = off_open_tx.clone();
let lbl = lbl.to_string();
ch.on_open(Box::new(move || {
let tx = tx.clone();
let lbl = lbl.clone();
Box::pin(async move {
let _ = tx.send(lbl).await;
})
}));
}
// answerer: collect wrappers by label + open signals (no detach in handler)
let ans_map: Arc<StdMutex<Map<String, Arc<RTCDataChannel>>>> =
Arc::new(StdMutex::new(Map::new()));
let (ans_open_tx, mut ans_open_rx) = mpsc::channel::<String>(8);
{
let ans_map = ans_map.clone();
let ans_open_tx = ans_open_tx.clone();
pc_ans.on_data_channel(Box::new(move |d: Arc<RTCDataChannel>| {
let ans_map = ans_map.clone();
let ans_open_tx = ans_open_tx.clone();
let label = d.label().to_string();
Box::pin(async move {
ans_map.lock().unwrap().insert(label.clone(), d.clone());
let tx = ans_open_tx.clone();
let l = label.clone();
d.on_open(Box::new(move || {
let tx = tx.clone();
let l = l.clone();
Box::pin(async move {
let _ = tx.send(l).await;
})
}));
})
}));
}
drive_negotiation(&pc_off, &pc_ans).await;
// wait until media+ctl opened on both sides
let t = Duration::from_secs(20);
let mut off_o = std::collections::HashSet::new();
while !(off_o.contains("media") && off_o.contains("ctl")) {
off_o.insert(timeout(t, off_open_rx.recv()).await.expect("off open to").expect("c"));
}
let mut ans_o = std::collections::HashSet::new();
while !(ans_o.contains("media") && ans_o.contains("ctl")) {
ans_o.insert(timeout(t, ans_open_rx.recv()).await.expect("ans open to").expect("c"));
}
// detach sequentially in the body
let v_media = web.media.detach().await.expect("v media detach");
let v_ctl = web.ctl.detach().await.expect("v ctl detach");
let (h_media, h_ctl) = {
let m = ans_map.lock().unwrap();
(m.get("media").unwrap().clone(), m.get("ctl").unwrap().clone())
};
let h_media = h_media.detach().await.expect("h media detach");
let h_ctl = h_ctl.detach().await.expect("h ctl detach");
// viewer reads media (video), host reads ctl (input)
let (vid_tx, mut vid_rx) = mpsc::channel::<Vec<u8>>(4);
tokio::spawn(async move {
let mut buf = vec![0u8; 262144];
loop {
match v_media.read_data_channel(&mut buf).await {
Ok((0, _)) => break,
Ok((n, _)) => { let _ = vid_tx.send(buf[..n].to_vec()).await; }
Err(_) => break,
}
}
});
let (inp_tx, mut inp_rx) = mpsc::channel::<Vec<u8>>(4);
tokio::spawn(async move {
let mut buf = vec![0u8; 262144];
loop {
match h_ctl.read_data_channel(&mut buf).await {
Ok((0, _)) => break,
Ok((n, _)) => { let _ = inp_tx.send(buf[..n].to_vec()).await; }
Err(_) => break,
}
}
});
let frame = make_big_keyframe(200 * 1024);
h_media.write_data_channel(&Bytes::from(frame.clone()), false).await.expect("h media write");
let got_vid = timeout(Duration::from_secs(10), vid_rx.recv()).await.expect("vid to").expect("c");
assert_eq!(got_vid, frame, "viewer must receive whole 200KB video on media");
let input = br#"{"t":"i.mouse","x":1,"y":2,"b":1}"#.to_vec();
v_ctl.write_data_channel(&Bytes::from(input.clone()), true).await.expect("v ctl write");
let got_inp = timeout(Duration::from_secs(10), inp_rx.recv()).await.expect("inp to").expect("c");
assert_eq!(got_inp, input, "host must receive input on ctl");
pc_off.close().await.ok();
pc_ans.close().await.ok();
println!("P2P_WEBMODE_OK web topology (body-detach): 200KB video + input over media/ctl");
}
/// Re-upgrade hysteresis: after a forced downgrade, the offerer retries the
/// upgrade after the backoff and media rides P2P again (a transient drop
/// recovers instead of staying on the relay forever).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn p2p_reupgrade_after_drop() {
std::env::set_var("NETDESK_P2P_REUP_MS", "1200"); // short backoff for the test
let (viewer_relay, host_relay) = mem_relay_pair();
let host_bin = host_relay.drop_binary.clone();
let mut fx = start_p2p_session(viewer_relay, host_relay).await;
match wait_view(&mut fx.vrx, |e| matches!(e, SessionEvent::Connected { .. }), 15).await {
Some(_) => {}
_ => panic!("no Connected"),
}
// 1) initial upgrade: drop relay binary -> video can only ride P2P.
host_bin.store(true, Ordering::SeqCst);
let au = make_big_keyframe(32 * 1024);
assert!(feed_until_video(&mut fx, &au, 40).await, "never upgraded initially");
// 2) force a downgrade; re-open the relay so video resumes there.
fx.viewer_ctl.force_relay();
fx.host_ctl.force_relay();
host_bin.store(false, Ordering::SeqCst);
let au2 = vec![0, 0, 0, 1, 0x65, 3, 1, 4, 1, 5, 9];
assert!(feed_until_video(&mut fx, &au2, 40).await, "no relay video after downgrade");
// 3) re-upgrade: after the backoff the offerer retries. Drop relay binary
// again; video arriving proves it rode the RE-upgraded P2P channel.
host_bin.store(true, Ordering::SeqCst);
let au3 = make_big_keyframe(28 * 1024);
assert!(
feed_until_video(&mut fx, &au3, 60).await,
"did not re-upgrade to P2P after the drop (video never returned while relay binary was dead)"
);
std::env::remove_var("NETDESK_P2P_REUP_MS");
fx.session.close();
drop(fx.frame_tx);
let _ = timeout(Duration::from_secs(5), fx.host_task).await;
println!("P2P_REUPGRADE_OK dropped session recovered to P2P after the backoff");
}
/// Adaptive-QoS telemetry: the host periodically probes RTT (net.ping), the
/// viewer echoes (net.pong), and the host emits HostEvent::NetStat with the
/// measured round trip — the signal that drives host/src/qos.rs. Verified over
/// the in-memory relay (no server).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn host_measures_rtt_netstat() {
let (viewer_relay, host_relay) = mem_relay_pair();
let password = "qos-rtt-pw";
let (salt_b64, iters, verifier_hex) = new_host_credentials(password);
let host_id = gen_host_id();
let (_frame_tx, frame_rx) = mpsc::unbounded_channel::<HostFrame>();
let (hev_tx, mut hev_rx) = mpsc::unbounded_channel::<HostEvent>();
let (_hctl_tx, hctl_rx) = mpsc::unbounded_channel::<HostControl>();
let hcfg = HostConfig {
ws_url: "mem://relay".into(),
host_id: host_id.clone(),
name: "QosHost".into(),
salt_b64,
iters,
verifier_hex,
width: 1280,
height: 720,
audio: false,
monitors: Vec::new(),
monitor: 0,
perms: Default::default(),
approve: false,
totp_secret: String::new(),
};
let host_task = tokio::spawn(async move {
run_host_with(hcfg, frame_rx, hev_tx, hctl_rx, Box::new(host_relay)).await
});
let (vtx, mut vrx) = mpsc::unbounded_channel::<SessionEvent>();
let vcfg = ConnectConfig {
ws_url: "mem://relay".into(),
host_id: host_id.clone(),
password: password.into(),
totp: String::new(),
takeover: false,
};
let session = connect_with(vcfg, vtx, Box::new(viewer_relay))
.await
.expect("viewer connect");
assert!(
wait_view(&mut vrx, |e| matches!(e, SessionEvent::Connected { .. }), 10)
.await
.is_some()
);
// Within a couple of probe intervals the host should measure a round trip.
match wait_host(&mut hev_rx, |e| matches!(e, HostEvent::NetStat { .. }), 8).await {
Some(HostEvent::NetStat { rtt_ms, loss }) => {
assert!(rtt_ms < 5000, "loopback RTT should be small, got {rtt_ms}ms");
assert_eq!(loss, 0.0);
println!("QOS_RTT_OK host measured rtt={rtt_ms}ms via net.ping/pong");
}
_ => panic!("host never emitted NetStat (net.ping/pong round-trip failed)"),
}
session.close();
let _ = timeout(Duration::from_secs(5), host_task).await;
}