218 lines
8.0 KiB
Rust
218 lines
8.0 KiB
Rust
|
|
//! Media-forwarding integration test — the coverage the README flagged as "Next".
|
||
|
|
//!
|
||
|
|
//! Boots the real sidecar and connects TWO real str0m clients to it over loopback
|
||
|
|
//! UDP (full ICE/DTLS/SRTP, and the data-channel renegotiation the browser does).
|
||
|
|
//! Peer A sends Opus RTP; we assert peer B RECEIVES the forwarded media. No
|
||
|
|
//! browser — the "clients" are str0m `Rtc` instances driven over real sockets, so
|
||
|
|
//! this proves the SFU data plane (accept offer → forward without decode) end to
|
||
|
|
//! end and runs in plain `cargo test`.
|
||
|
|
|
||
|
|
use std::io::{BufRead, BufReader, Write};
|
||
|
|
use std::net::{IpAddr, UdpSocket};
|
||
|
|
use std::os::unix::net::UnixStream;
|
||
|
|
use std::path::PathBuf;
|
||
|
|
use std::thread;
|
||
|
|
use std::time::{Duration, Instant};
|
||
|
|
|
||
|
|
use str0m::change::{SdpAnswer, SdpOffer, SdpPendingOffer};
|
||
|
|
use str0m::channel::ChannelId;
|
||
|
|
use str0m::media::{Direction, MediaKind, MediaTime, Mid};
|
||
|
|
use str0m::net::{Protocol, Receive};
|
||
|
|
use str0m::{Candidate, Event, Input, Output, Rtc};
|
||
|
|
|
||
|
|
use blax_webrtc_sfu::server::{self, Config};
|
||
|
|
|
||
|
|
fn start_sidecar(socket: PathBuf) -> thread::JoinHandle<()> {
|
||
|
|
let config = Config {
|
||
|
|
socket,
|
||
|
|
udp_ip: IpAddr::from([127, 0, 0, 1]),
|
||
|
|
udp_port: 0,
|
||
|
|
public_ip: Some(IpAddr::from([127, 0, 0, 1])),
|
||
|
|
};
|
||
|
|
thread::spawn(move || server::run(config).expect("sidecar to run"))
|
||
|
|
}
|
||
|
|
|
||
|
|
fn control_connect(socket: &PathBuf) -> UnixStream {
|
||
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||
|
|
loop {
|
||
|
|
if let Ok(s) = UnixStream::connect(socket) {
|
||
|
|
return s;
|
||
|
|
}
|
||
|
|
assert!(Instant::now() < deadline, "control socket never came up");
|
||
|
|
thread::sleep(Duration::from_millis(20));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// add_peer over the control socket (what PHP does); returns the answer SDP.
|
||
|
|
fn add_peer(
|
||
|
|
reader: &mut impl BufRead,
|
||
|
|
writer: &mut impl Write,
|
||
|
|
id: u64,
|
||
|
|
room: &str,
|
||
|
|
peer: &str,
|
||
|
|
offer: &str,
|
||
|
|
) -> String {
|
||
|
|
let req = serde_json::json!({"id": id, "cmd": "add_peer", "room": room, "peer": peer, "offer": offer});
|
||
|
|
writeln!(writer, "{req}").unwrap();
|
||
|
|
writer.flush().unwrap();
|
||
|
|
let mut line = String::new();
|
||
|
|
reader.read_line(&mut line).unwrap();
|
||
|
|
let v: serde_json::Value = serde_json::from_str(&line).unwrap();
|
||
|
|
assert_eq!(v["ok"], true, "add_peer failed: {v}");
|
||
|
|
v["answer"].as_str().expect("answer sdp").to_string()
|
||
|
|
}
|
||
|
|
|
||
|
|
struct Peer {
|
||
|
|
rtc: Rtc,
|
||
|
|
socket: UdpSocket,
|
||
|
|
audio_mid: Option<Mid>,
|
||
|
|
cid: Option<ChannelId>,
|
||
|
|
connected: bool,
|
||
|
|
media_recv: usize,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Peer {
|
||
|
|
/// A str0m client offering a data channel (+ an audio track when `with_audio`).
|
||
|
|
fn new(with_audio: bool) -> (Peer, String, SdpPendingOffer) {
|
||
|
|
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||
|
|
socket.set_read_timeout(Some(Duration::from_millis(3))).unwrap();
|
||
|
|
let addr = socket.local_addr().unwrap();
|
||
|
|
|
||
|
|
let mut rtc = Rtc::builder().build();
|
||
|
|
// No OS candidate gathering in str0m: add our host candidate so the offer
|
||
|
|
// carries it (the SFU also learns us from inbound STUN).
|
||
|
|
rtc.add_local_candidate(Candidate::host(addr, "udp").unwrap());
|
||
|
|
|
||
|
|
let mut change = rtc.sdp_api();
|
||
|
|
let audio_mid =
|
||
|
|
with_audio.then(|| change.add_media(MediaKind::Audio, Direction::SendRecv, None, None));
|
||
|
|
change.add_channel("data".to_string());
|
||
|
|
let (offer, pending) = change.apply().expect("offer");
|
||
|
|
|
||
|
|
(
|
||
|
|
Peer { rtc, socket, audio_mid, cid: None, connected: false, media_recv: 0 },
|
||
|
|
offer.to_sdp_string(),
|
||
|
|
pending,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn accept_answer(&mut self, pending: SdpPendingOffer, answer_sdp: &str) {
|
||
|
|
let answer = SdpAnswer::from_sdp_string(answer_sdp).expect("parse answer");
|
||
|
|
self.rtc.sdp_api().accept_answer(pending, answer).expect("accept answer");
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Drain all pending output (send Transmits, handle events); return next timeout.
|
||
|
|
fn poll(&mut self) -> Instant {
|
||
|
|
loop {
|
||
|
|
if !self.rtc.is_alive() {
|
||
|
|
return Instant::now() + Duration::from_millis(50);
|
||
|
|
}
|
||
|
|
match self.rtc.poll_output().expect("poll_output") {
|
||
|
|
Output::Transmit(t) => {
|
||
|
|
let _ = self.socket.send_to(&t.contents, t.destination);
|
||
|
|
}
|
||
|
|
Output::Timeout(t) => return t,
|
||
|
|
Output::Event(e) => self.on_event(e),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn on_event(&mut self, e: Event) {
|
||
|
|
match e {
|
||
|
|
Event::Connected => self.connected = true,
|
||
|
|
Event::ChannelOpen(cid, _) => self.cid = Some(cid),
|
||
|
|
Event::ChannelData(cd) => self.on_channel_data(&cd.data),
|
||
|
|
Event::MediaData(_) => self.media_recv += 1,
|
||
|
|
_ => {}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Data-channel renegotiation: the SFU offers a peer's track over the DC as
|
||
|
|
/// others join; accept it and answer on the same channel (mirrors the browser).
|
||
|
|
fn on_channel_data(&mut self, data: &[u8]) {
|
||
|
|
let Ok(offer) = serde_json::from_slice::<SdpOffer>(data) else { return };
|
||
|
|
let Ok(answer) = self.rtc.sdp_api().accept_offer(offer) else { return };
|
||
|
|
if let Some(cid) = self.cid {
|
||
|
|
if let Some(mut ch) = self.rtc.channel(cid) {
|
||
|
|
let json = serde_json::to_string(&answer).unwrap();
|
||
|
|
let _ = ch.write(false, json.as_bytes());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn recv(&mut self) {
|
||
|
|
let mut buf = vec![0u8; 2000];
|
||
|
|
loop {
|
||
|
|
match self.socket.recv_from(&mut buf) {
|
||
|
|
Ok((n, source)) => {
|
||
|
|
let dest = self.socket.local_addr().unwrap();
|
||
|
|
if let Ok(contents) = (&buf[..n]).try_into() {
|
||
|
|
let _ = self.rtc.handle_input(Input::Receive(
|
||
|
|
Instant::now(),
|
||
|
|
Receive { proto: Protocol::Udp, source, destination: dest, contents },
|
||
|
|
));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
Err(_) => break, // WouldBlock / read timeout
|
||
|
|
}
|
||
|
|
}
|
||
|
|
let _ = self.rtc.handle_input(Input::Timeout(Instant::now()));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Write one Opus packet on the audio track (payload is opaque — the SFU
|
||
|
|
/// forwards without decoding, so B receives these bytes verbatim).
|
||
|
|
fn write_audio(&mut self, seq: u64) {
|
||
|
|
let Some(mid) = self.audio_mid else { return };
|
||
|
|
let Some(w) = self.rtc.writer(mid) else { return };
|
||
|
|
let pt = w.payload_params().find(|p| p.spec().codec.is_audio()).map(|p| p.pt());
|
||
|
|
if let Some(pt) = pt {
|
||
|
|
let _ = w.write(pt, Instant::now(), MediaTime::from_micros(seq * 20_000), vec![0xA5u8; 80]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn two_clients_forward_audio() {
|
||
|
|
let socket = std::env::temp_dir().join(format!("blax-sfu-fwd-{}.sock", std::process::id()));
|
||
|
|
let _ = std::fs::remove_file(&socket);
|
||
|
|
let _sidecar = start_sidecar(socket.clone());
|
||
|
|
|
||
|
|
let ctrl = control_connect(&socket);
|
||
|
|
let mut reader = BufReader::new(ctrl.try_clone().unwrap());
|
||
|
|
let mut writer = ctrl;
|
||
|
|
|
||
|
|
// A sends audio; B (data channel only) receives A's track via renegotiation.
|
||
|
|
let (mut a, a_offer, a_pending) = Peer::new(true);
|
||
|
|
let a_answer = add_peer(&mut reader, &mut writer, 1, "room", "alice", &a_offer);
|
||
|
|
a.accept_answer(a_pending, &a_answer);
|
||
|
|
|
||
|
|
let (mut b, b_offer, b_pending) = Peer::new(false);
|
||
|
|
let b_answer = add_peer(&mut reader, &mut writer, 2, "room", "bob", &b_offer);
|
||
|
|
b.accept_answer(b_pending, &b_answer);
|
||
|
|
|
||
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
||
|
|
let mut seq = 0u64;
|
||
|
|
while Instant::now() < deadline {
|
||
|
|
a.poll();
|
||
|
|
a.recv();
|
||
|
|
b.poll();
|
||
|
|
b.recv();
|
||
|
|
if a.connected {
|
||
|
|
a.write_audio(seq);
|
||
|
|
seq += 1;
|
||
|
|
}
|
||
|
|
if b.media_recv >= 5 {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
thread::sleep(Duration::from_millis(3));
|
||
|
|
}
|
||
|
|
|
||
|
|
assert!(a.connected, "peer A never reached Connected");
|
||
|
|
assert!(b.connected, "peer B never reached Connected");
|
||
|
|
assert!(
|
||
|
|
b.media_recv >= 5,
|
||
|
|
"peer B did not receive forwarded media (received {} packets)",
|
||
|
|
b.media_recv
|
||
|
|
);
|
||
|
|
}
|