A publisher POSTs /srt or /rist under its room grant and proof and gets a one-time session: for SRT the stream ID and passphrase the gateway's listener admits (live-ingest §13's mapping, its rejection codes), or the gateway calls the publisher's listener where call_allow lets it; for RIST a port of its own, the CNAME its RTCP must give and, in the Main Profile, a PSK passphrase. The link's transport stream is demultiplexed: the first H.264 or HEVC stream and the first AAC stream go to the same packagers, groups, catalog and first-hop receipts as a WHIP session's, one access unit or one ADTS frame an object, the PTS mapped onto the room clock. WAI capabilities wai.video.hevc and wai.audio.aac join the gateway's codecs; WHIP and WHEP keep WebRTC's. Every record interval and at the end, the gateway signs a part of the link's WCL1 record: each sequence number delivered at the first try, recovered, dropped or outstanding, its loss runs, requests, retransmissions, RTT and recovery times, chained part to part and naming the head of each media track's receipt chain; published with receipts on the owner's link track. The corpus wai/live-conformance/links/ comes from joule-moq-ingest-link-vectors, from fixed seeds; tests/link_corpus.rs holds the reader to it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
102 lines
5.1 KiB
Rust
102 lines
5.1 KiB
Rust
//! RIST into a room's tracks, end to end on loopback: a publisher POSTs `/rist` for the
|
|
//! Simple Profile (a port pair of its own and a CNAME) and for the Main Profile (a tunnel
|
|
//! port, a CNAME and a PSK passphrase), and sends a transport stream of H.264 and AAC; a
|
|
//! subscriber at the relay verifies every receipt and gets every unit as sent; the link
|
|
//! record names the profile, the PSK key length and the receive window. A flow with another
|
|
//! CNAME opens no session.
|
|
|
|
#![cfg(feature = "rist")]
|
|
|
|
mod common;
|
|
|
|
use common::*;
|
|
use joule_contrib::lossy::{Impair, Proxy};
|
|
use joule_contrib::rist::{self, Profile, SendConfig};
|
|
use joule_contrib::ts;
|
|
use joule_moq_ingest::frame::Codec;
|
|
use joule_moq_ingest::linkrec;
|
|
use joule_moq_ingest::room::media;
|
|
use std::time::Duration;
|
|
use tokio::time::timeout;
|
|
|
|
async fn open(w: &World, auth: &Auth, profile: &str) -> serde_json::Value {
|
|
let body = serde_json::json!({"profile": profile}).to_string();
|
|
let r = auth.send("POST", &format!("{}/rist", w.base()), &[("content-type", "application/json")], body.as_bytes()).await;
|
|
assert_eq!(r.status, 201, "POST /rist: {}", r.text());
|
|
serde_json::from_slice(&r.body).unwrap()
|
|
}
|
|
|
|
async fn one(profile: Profile) {
|
|
let w = World::start(|c| {
|
|
c.contrib.rist.enabled = true;
|
|
c.contrib.rist.aes = 256;
|
|
c.contrib.rist.buffer_ms = 800;
|
|
c.contrib.record_interval_ms = 1_000;
|
|
})
|
|
.await;
|
|
let auth = w.auth(&w.publisher, 0);
|
|
let s = open(&w, &auth, if profile == Profile::Main { "main" } else { "simple" }).await;
|
|
let port = s["port"].as_u64().unwrap() as u16;
|
|
let target: std::net::SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
|
assert_eq!(s["passphrase"].is_string(), profile == Profile::Main);
|
|
// A wrong CNAME first: no session.
|
|
let stranger = rist::send(target, SendConfig { profile, cname: "not-the-session".into(), secret: s["passphrase"].as_str().map(str::to_string), aes_256: true, ..SendConfig::default() }).await.unwrap();
|
|
let tx = stranger.send.clone().unwrap();
|
|
for _ in 0..30 {
|
|
let mut p = vec![0x47u8; 1316];
|
|
p[1] = 0x1f;
|
|
p[2] = 0xff;
|
|
let _ = tx.send(p).await;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
assert_eq!(w.gateway.gateway.contrib.active.load(std::sync::atomic::Ordering::SeqCst), 0, "no session for another CNAME");
|
|
stranger.close();
|
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
|
|
let (target, px) = if profile == Profile::Main {
|
|
let imp = Impair { loss_ppm: 25_000, spare_first: 16, ..Impair::default() };
|
|
let px = Proxy::start(target, imp, imp, 5).await.unwrap();
|
|
(px.addr, Some(px))
|
|
} else {
|
|
(target, None)
|
|
};
|
|
let cfg = SendConfig { profile, cname: s["cname"].as_str().unwrap().into(), secret: s["passphrase"].as_str().map(str::to_string), aes_256: true, buffer_ms: 1500, ..SendConfig::default() };
|
|
let link = rist::send(target, cfg).await.unwrap();
|
|
// The flow is the session's once its first RTCP names the CNAME; then the tracks are
|
|
// published, and a subscriber joins before the first frame.
|
|
assert!(contrib_live(&w, s["session"].as_str().unwrap()).await, "the session publishes its tracks");
|
|
let watcher = watch(&w).await;
|
|
let sent = send_ts(&link, 3, 30, ts::ST_H264).await;
|
|
tokio::time::sleep(Duration::from_millis(1_500)).await;
|
|
link.close();
|
|
// RIST says nothing when a sender stops: the session ends when the flow goes idle.
|
|
assert!(until(Duration::from_secs(15), || watcher.ended.lock().unwrap().len() == 2).await, "PUBLISH_DONE on both media tracks");
|
|
settle(&watcher, Duration::from_millis(500)).await;
|
|
let video = watcher.tracks.iter().find(|t| t.kind == media::VIDEO).unwrap();
|
|
let audio = watcher.tracks.iter().find(|t| t.kind == media::AUDIO).unwrap();
|
|
let (vf, _) = verified(&watcher, video, &w.gateway_key, Codec::Avc);
|
|
let (af, _) = verified(&watcher, audio, &w.gateway_key, Codec::Aac);
|
|
same(&vf, &sent.video, "every access unit");
|
|
same(&af, &sent.audio, "every ADTS frame");
|
|
let ps = linkrec::verify_series(&w.gateway.gateway.link_records()).expect("the parts verify as a series");
|
|
let last = ps.last().unwrap();
|
|
assert!(last.last && last.ended == linkrec::ended::IDLE);
|
|
let (want_proto, want_cipher) = if profile == Profile::Main { (3, 256) } else { (2, 0) };
|
|
assert!(ps.iter().all(|p| p.protocol == want_proto && p.cipher == want_cipher && p.window == 800_000));
|
|
if px.is_some() {
|
|
let recovered: u64 = ps.iter().map(|p| p.account.delivered - p.account.first_try).sum();
|
|
assert!(recovered > 0, "losses on the tunnel were recovered");
|
|
assert_eq!(ps.iter().map(|p| p.account.dropped).sum::<u64>(), 0);
|
|
}
|
|
w.stop().await;
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn a_simple_profile_flow_reaches_the_relay() {
|
|
timeout(Duration::from_secs(120), one(Profile::Simple)).await.expect("in time");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn a_main_profile_tunnel_under_psk_reaches_the_relay() {
|
|
timeout(Duration::from_secs(120), one(Profile::Main)).await.expect("in time");
|
|
}
|