open-standards/joule-code/joule-code-rs/crates/joule-moq-relay/tests/common/mod.rs
Transaction Science 74d7cc0a06
Some checks failed
joule-moq-room room service / joule-moq-room · does this change touch it (push) Successful in 16s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Successful in 27s
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Successful in 20s
joule-moq-agent AI participants / joule-moq-agent · does this change touch it (push) Successful in 30s
wai-app and Create / wai-app · does this change touch it (push) Successful in 6m41s
joule-moq-http live HTTP egress / joule-moq-http · does this change touch it (push) Successful in 6m52s
WAI Studio / does this change touch it (push) Successful in 6m15s
wai-fleet service / does this change touch it (push) Successful in 6m35s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Has been cancelled
WAI Studio / Studio in Chromium and Firefox · macos (push) Has been cancelled
WAI Studio / Studio in WebKit · linux (push) Has been cancelled
wai release binaries build on Windows / the seven release binaries · windows (push) Has been cancelled
wai-fleet service / service · linux (push) Has been cancelled
wai-fleet service / service · macos (push) Has been cancelled
WAI examples / WAI examples · does this change touch them (push) Has been cancelled
WAI examples / WAI examples · portable · linux (push) Has been cancelled
WAI examples / WAI examples · the archive, the readiness check, the browser and Apple · macos (push) Has been cancelled
WAI examples / WAI examples · android-app · linux-arm64 (push) Has been cancelled
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Has been cancelled
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Has been cancelled
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Has been cancelled
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Has been cancelled
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Has been cancelled
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Has been cancelled
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Has been cancelled
joule-moq-room room service / joule-moq-room, every target (push) Has been cancelled
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Has been cancelled
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Has been cancelled
joule-moq-http live HTTP egress / joule-moq-http, every target (push) Has been cancelled
joule-moq-agent AI participants / joule-moq-agent, every target (push) Has been cancelled
joule-code conformance gate / Build + round-trip C1-C4 vectors (push) Has been cancelled
joule-code conformance gate / joule-moq-relay, every target (push) Has been cancelled
joule-code conformance gate / joule-moq-relay, its own energy on a counter (macos) (push) Has been cancelled
joule-code conformance gate / joule-moq-ingest, every target (push) Has been cancelled
joule-moq-relay: WebTransport over HTTP/2 (draft-ietf-webtrans-http2-16), behind the feature webtransport-h2
A TCP listener (webtransport_h2_listen): TLS 1.3 with ALPN h2 and the UDP endpoint's certificate.
The relay's SETTINGS carry SETTINGS_ENABLE_CONNECT_PROTOCOL 1 (RFC 8441), SETTINGS_WT_ENABLED 1
(§3.1) and its initial limits (§4.3.1: its QUIC windows and stream counts). An extended CONNECT with
:protocol webtransport to the relay's path opens a session, its draft chosen from
WT-Available-Protocols and named in WT-Protocol as over HTTP/3, its answer stating
WebTransport-Init (§4.3.2); 405, 501, 400, 404 and 403 refuse the rest, and a refused request's
capsules are never read (§3.2). A connection idle ten seconds, and each at shutdown, gets a GOAWAY.

The session is the CONNECT stream's DATA cut into capsules and multiplexed as the WebSocket
binding is, under -16's own rules (mux Profile::Http2): data flow control for the session and
each stream both ways, credit granted back as data is read, WT_DATA_BLOCKED and
WT_STREAM_DATA_BLOCKED; a limit capsule that does not raise its limit is WT_FLOW_CONTROL_ERROR; a
reset's Reliable Size is the bytes sent and is checked; WT_STOP_SENDING is answered with
WT_RESET_STREAM; a capsule for a stream in no state to take it is WT_STREAM_STATE_ERROR. A session
error resets the CONNECT stream with its HTTP/2 code; -16 registers those as 0xTBD, so they are
placeholders in transport::capsule::h2_error (WAI decision D78, as D8 has it). The binding's own
rules are unchanged.

The HTTP/2 layer is h2 0.4.20 (MIT), vendored (vendor/h2/VENDORED.md) with one change: settings
of identifiers it does not interpret are sent and read, which SETTINGS_WT_ENABLED and the initial
limits need; patched into the joule-code workspace alone. No other workspace enables the feature,
and their locks are unchanged.

tests/relay_webtransport_h2.rs (with the feature, run by the conformance workflow's relay job):
sessions over HTTP/2 beside HTTP/3 with every object verified, each draft chosen alone, a
300,000-byte object past every window, the relay's SETTINGS and answer, its refusals, a client
held to a server's SETTINGS, a session error's reset; and the multiplexer's unit tests of -16's
rules. Fourteen planted faults, each caught.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 06:45:03 -05:00

577 lines
28 KiB
Rust

//! What the relay's integration tests share: a relay on loopback, clients over each
//! transport, publishers that sign their groups, and subscribers that read and verify.
//!
//! Every network address is loopback, and every wait here and in the tests has a hard
//! bound, so no loop can outlive its test.
#![allow(dead_code)]
use ed25519_dalek::SigningKey;
use joule_moq::carriage::{object_properties, ObjectExtras, ReceiptBundle};
use joule_moq::draft22::data::{ObjectStatus as WireStatus, SubgroupHeader, SubgroupId, SubgroupObject};
use joule_moq::draft22::kvp::{prop, KeyValuePair, Properties};
use joule_moq::draft22::{ControlMessage, Parameters, StreamHeader, SubscribeOk, UniEvent};
use joule_moq::messages::ObjectHeader;
use joule_moq::receipt::{ObjectReceipt, ReceiptBuilder, ReceiptVerifier, VerifiedGroup};
use joule_moq::{GroupId, ObjectId, ObjectStatus, TrackAlias, TrackName, TrackNamespace};
use joule_moq_relay::client::{Client, ClientOptions};
use joule_moq_relay::transport::mux::MuxConfig;
use joule_moq_relay::transport::{quic, webtransport, websocket, TransportError};
use joule_moq_relay::wire::{ReqRx, ReqTx, StreamError, UniReader};
use joule_moq_relay::{RelayConfig, RelayServer};
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use std::time::Duration;
use tokio::time::{timeout, Instant};
/// Sessions written from -18's and -16's own messages.
pub mod raw;
/// The bound on any single wait in a test.
pub const STEP: Duration = Duration::from_secs(20);
pub fn loopback() -> std::net::SocketAddr {
"127.0.0.1:0".parse().unwrap()
}
/// A relay on loopback ports, the configuration adjusted by `f`.
pub async fn relay(f: impl FnOnce(&mut RelayConfig)) -> RelayServer {
let mut c = RelayConfig::loopback();
// A session refused at SETUP is held open this long for its client to read the relay's
// SETUP; the tests' clients that stay to see the relay's own close wait it out.
c.limits.setup_refusal_wait_ms = 200;
// With the feature, every test relay serves WebTransport over HTTP/2 beside the rest.
#[cfg(feature = "webtransport-h2")]
{
c.webtransport_h2_listen = Some(loopback());
}
f(&mut c);
RelayServer::start(c).await.expect("the relay starts")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Via {
Quic,
WebTransport,
WebSocket,
/// Native QUIC offering only this draft's ALPN; the session bridged to -22.
QuicDraft(joule_moq::draft::Draft),
/// WebTransport offering only this draft; the session bridged to -22.
WtDraft(joule_moq::draft::Draft),
/// The WebSocket binding offering only this draft (-23 or -22); the session bridged to -22.
WsDraft(joule_moq::draft::Draft),
/// WebTransport over HTTP/2, offering every draft.
#[cfg(feature = "webtransport-h2")]
WtH2,
/// WebTransport over HTTP/2 offering only this draft; the session bridged to -22.
#[cfg(feature = "webtransport-h2")]
WtH2Draft(joule_moq::draft::Draft),
}
/// The transport label a session over `via` has in the relay's summaries.
pub fn transport_label(via: Via) -> &'static str {
match via {
Via::Quic | Via::QuicDraft(_) => "quic",
Via::WebTransport | Via::WtDraft(_) => "webtransport",
Via::WebSocket | Via::WsDraft(_) => "websocket",
#[cfg(feature = "webtransport-h2")]
Via::WtH2 | Via::WtH2Draft(_) => "webtransport-h2",
}
}
/// A client of `server` over `via`. A QUIC client keeps its endpoint, returned with it.
pub async fn connect(server: &RelayServer, via: Via, opts: ClientOptions) -> (Client, Option<quinn::Endpoint>) {
connect_with(server, via, opts, None).await
}
pub async fn connect_with(server: &RelayServer, via: Via, mut opts: ClientOptions, transport: Option<quinn::TransportConfig>) -> (Client, Option<quinn::Endpoint>) {
// A client that proves a grant or a room key holds no challenge of the relay at first: its
// first session's SETUP is refused and given one, and its second proves over it (WAI
// live-rooms §10.5.2). A test that states the challenge itself is taken at its word.
let proving = !opts.grants.is_empty() || !opts.room_keys.is_empty();
if proving && opts.challenge.is_none() && opts.sign_challenge.is_none() {
opts.challenge = challenge_from(server, via, &opts).await;
}
// The session returned is the one whose SETUP states the test's case. Where the relay
// refuses it, the client stays, as one that does not know GRANT_REFUSED does, and the test
// reads the relay's own close: its code and its reason.
opts.ignore_refusal = true;
let (conn, ep) = dial(server, via, transport).await;
let c = Client::connect(conn, opts).await.unwrap_or_else(|e| panic!("SETUP over {via:?}: {e}"));
(c, ep)
}
/// A challenge of `server`'s, as a client that holds none comes by one: a session whose SETUP
/// presents `opts`' grants with no proof, which the relay refuses (or admits as a session that
/// proved nothing), its own SETUP carrying the challenge.
pub async fn challenge_from(server: &RelayServer, via: Via, opts: &ClientOptions) -> Option<[u8; 32]> {
refused_at_setup(server, via, ClientOptions { challenge: None, sign_challenge: None, ignore_refusal: false, lazy_data: false, ..opts.clone() }).await.challenge
}
/// A session whose SETUP states `opts` as they are, which the relay refuses: the reason its
/// `GRANT_REFUSED` gives, and the challenge beside it.
pub async fn refused_at_setup(server: &RelayServer, via: Via, opts: ClientOptions) -> joule_moq_relay::client::Refused {
use joule_moq_relay::client::OpenError;
let (conn, _ep) = dial(server, via, None).await;
match Client::open(conn, ClientOptions { ignore_refusal: false, ..opts }).await {
Err(OpenError::Refused(r)) => r,
Err(e) => panic!("a SETUP over {via:?}: {e}"),
Ok(_) => panic!("a SETUP the relay was to refuse was admitted, over {via:?}"),
}
}
/// A transport session to `server` over `via`, bridged to its draft. A QUIC session keeps its
/// endpoint, returned with it.
pub async fn dial(server: &RelayServer, via: Via, transport: Option<quinn::TransportConfig>) -> (joule_moq_relay::transport::SharedConnection, Option<quinn::Endpoint>) {
use joule_moq_relay::transport::bridge::wrap;
use joule_moq::draft22::Perspective::Client as C;
let quic_addr = server.quic_addr.expect("a UDP endpoint");
let path = server.relay().config().path.clone();
match via {
Via::Quic => {
let ep = quic::client_endpoint(loopback(), vec![server.identity.chain[0].clone()], vec![joule_moq::draft22::ALPN.to_vec()], transport).unwrap();
let conn = timeout(STEP, quic::connect(&ep, quic_addr, "localhost")).await.expect("in time").expect("a QUIC session");
(Arc::new(conn), Some(ep))
}
Via::WebTransport => {
let url = format!("https://127.0.0.1:{}{}", quic_addr.port(), path);
let conn = timeout(STEP, webtransport::connect(&url, Some(server.identity.leaf_sha256), loopback())).await.expect("in time").expect("a WebTransport session");
(Arc::new(conn), None)
}
Via::QuicDraft(d) => {
let ep = quic::client_endpoint(loopback(), vec![server.identity.chain[0].clone()], vec![d.alpn().to_vec()], transport).unwrap();
let (conn, got) = timeout(STEP, quic::connect_any(&ep, quic_addr, "localhost")).await.expect("in time").expect("a QUIC session");
assert_eq!(got, d, "the relay chose the one draft offered");
(wrap(Arc::new(conn), d, C), Some(ep))
}
Via::WtDraft(d) => {
let url = format!("https://127.0.0.1:{}{}", quic_addr.port(), path);
let conn = timeout(STEP, webtransport::connect_draft(&url, Some(server.identity.leaf_sha256), loopback(), d)).await.expect("in time").expect("a WebTransport session");
(wrap(Arc::new(conn), d, C), None)
}
Via::WebSocket => {
let url = format!("ws://{}{}", server.websocket_addr.expect("a WebSocket listener"), path);
let conn = timeout(STEP, websocket::connect(&url, MuxConfig::default())).await.expect("in time").expect("a WebSocket session");
(Arc::new(conn), None)
}
Via::WsDraft(d) => {
let url = format!("ws://{}{}", server.websocket_addr.expect("a WebSocket listener"), path);
let conn = timeout(STEP, websocket::connect_draft(&url, MuxConfig::default(), d)).await.expect("in time").expect("a WebSocket session");
(wrap(Arc::new(conn), d, C), None)
}
#[cfg(feature = "webtransport-h2")]
Via::WtH2 | Via::WtH2Draft(_) => {
use joule_moq_relay::transport::webtransport_h2 as h2t;
let url = format!("https://127.0.0.1:{}{}", server.webtransport_h2_addr.expect("a WebTransport over HTTP/2 listener").port(), path);
let tls = Arc::new(joule_moq_relay::cascade::pinned_tls(server.identity.leaf_sha256).expect("a TLS configuration"));
let offer: Vec<joule_moq::draft::Draft> = match via {
Via::WtH2Draft(d) => vec![d],
_ => joule_moq::draft::Draft::SUPPORTED.to_vec(),
};
let (conn, got) = timeout(STEP, h2t::connect(&url, tls, &offer, h2t::client_config(), h2t::ClientOptions::default())).await.expect("in time").expect("a WebTransport over HTTP/2 session");
if let Via::WtH2Draft(d) = via {
assert_eq!(got, d, "the relay chose the one draft offered");
}
(wrap(Arc::new(conn), got, C), None)
}
}
}
pub fn ns(fields: &[&str]) -> TrackNamespace {
TrackNamespace::from_strs(fields.iter().copied())
}
pub fn name(s: &str) -> TrackName {
TrackName::from_str(s)
}
/// Track properties: a default publisher priority, and an OBJECT_DELIVERY_TIMEOUT.
pub fn track_props(priority: u8, object_timeout_ms: Option<u64>) -> Properties {
let mut p = Properties::new();
p.push(KeyValuePair::varint(prop::DEFAULT_PUBLISHER_PRIORITY, priority as u64));
if let Some(t) = object_timeout_ms {
p.push(KeyValuePair::varint(prop::OBJECT_DELIVERY_TIMEOUT, t));
}
p
}
/// One group of a media track, one object, and its receipts as group `g` of the receipts
/// track carries them (WAI moq-streaming-format §4.1).
pub fn signed_group(key: &SigningKey, receipt_alias: u64, g: u64, payload: &[u8]) -> (ObjectReceipt, ReceiptBundle) {
let hash = ObjectReceipt::hash_payload(payload);
let mut b = ReceiptBuilder::new(TrackAlias(receipt_alias), GroupId(g));
b.add_labelled_object(hash, 0, "test-publisher", "2026-10-17T00:00:00Z", vec![0]).unwrap();
let receipt = ObjectReceipt {
track_alias: TrackAlias(receipt_alias),
group_id: GroupId(g),
object_id: ObjectId(0),
content_hash: hash,
joules_micro: 0,
origin: "test-publisher".into(),
merkle_proof: b.proof_for(0).unwrap(),
energy_class: Some(vec![0]),
};
let group = b.finalize_labelled(key, "test-publisher", "2026-10-17T00:00:01Z", None).unwrap();
(receipt.clone(), ReceiptBundle { group, objects: vec![receipt] })
}
/// A media track and its receipts track, as one publisher sends them.
#[derive(Clone)]
pub struct Tracks {
pub ns: TrackNamespace,
pub media: TrackName,
pub receipts: TrackName,
pub media_alias: u64,
pub receipts_alias: u64,
pub receipt_alias: u64,
pub priority: u8,
}
impl Tracks {
pub fn new(ns: TrackNamespace, media: &str, media_alias: u64, receipt_alias: u64, priority: u8) -> Self {
Tracks { ns, media: name(media), receipts: name(&format!("{media}_receipts")), media_alias, receipts_alias: media_alias + 1, receipt_alias, priority }
}
}
/// Publish group `g` of `t`: the receipts first (no later than the group's first object,
/// moq-streaming-format §4.1), then the object, which carries its receipt as a property.
pub async fn publish_group(c: &Client, key: &SigningKey, t: &Tracks, g: u64, payload: &[u8]) -> ObjectReceipt {
let (receipt, bundle) = signed_group(key, t.receipt_alias, g, payload);
let rh = SubgroupHeader { track_alias: t.receipts_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: false, end_of_group: true, first_object: true };
c.send_subgroup(rh, &[SubgroupObject::normal(0, bundle.to_payload().unwrap())]).await.expect("the receipts");
let header = ObjectHeader {
track_alias: TrackAlias(t.media_alias),
group_id: GroupId(g),
object_id: ObjectId(0),
publisher_priority: t.priority,
status: ObjectStatus::Normal,
payload_len: payload.len() as u64,
mrl_layer: 0,
mrl_total_layers: 0,
wai_envelope: false,
};
let props = object_properties(&header, &ObjectExtras { receipt: Some(receipt.clone()), wai_receipt: None, delivery_timeout_ms: None }).unwrap();
let mh = SubgroupHeader { track_alias: t.media_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: true, end_of_group: true, first_object: true };
c.send_subgroup(mh, &[SubgroupObject { object_id: 0, properties: props, status: WireStatus::Normal, payload: payload.to_vec() }]).await.expect("the object");
receipt
}
/// PUBLISH a media track and its receipts track (§9.8).
pub async fn publish_tracks(c: &Client, t: &Tracks, object_timeout_ms: Option<u64>) -> Vec<(ReqTx, ReqRx)> {
let a = c.publish(t.ns.clone(), t.media.clone(), t.media_alias, Parameters::new(), track_props(t.priority, object_timeout_ms)).await.expect("PUBLISH the media track");
let b = c.publish(t.ns.clone(), t.receipts.clone(), t.receipts_alias, Parameters::new(), track_props(t.priority, None)).await.expect("PUBLISH the receipts track");
vec![a, b]
}
/// Answer the relay's SUBSCRIBEs for `t`'s tracks (the namespace route, §7.6) until both
/// are Established; the streams are returned to keep the subscriptions open.
pub async fn answer_subscribes(c: &Client, t: &Tracks) -> Vec<(ReqTx, ReqRx)> {
let mut held = Vec::new();
let mut seen = (false, false);
let deadline = Instant::now() + STEP;
while !(seen.0 && seen.1) {
let left = deadline.saturating_duration_since(Instant::now());
let (tx, rx, first) = c.next_request(left).await.expect("the relay's SUBSCRIBE in time");
let ControlMessage::Subscribe(r) = first else { panic!("the relay sent {}", first.name()) };
let (alias, props) = if r.name == t.media {
seen.0 = true;
(t.media_alias, track_props(t.priority, None))
} else {
assert_eq!(r.name, t.receipts, "the relay subscribes only to the tracks asked for");
seen.1 = true;
(t.receipts_alias, track_props(t.priority, None))
};
tx.send(&ControlMessage::SubscribeOk(SubscribeOk { track_alias: alias, params: Parameters::new(), track_properties: props })).await.unwrap();
held.push((tx, rx));
}
held
}
/// What a subscriber read from one data stream.
#[derive(Debug)]
pub struct Got {
pub header: SubgroupHeader,
pub objects: Vec<SubgroupObject>,
/// The stream was reset, with this code.
pub reset: Option<u64>,
pub arrived: Instant,
}
/// Read one subgroup stream to its end, each read bounded.
pub async fn read_stream(mut r: UniReader) -> Option<Got> {
let arrived = Instant::now();
let StreamHeader::Subgroup(header) = r.header.clone()? else { return None };
let mut objects = Vec::new();
loop {
match timeout(STEP, r.next()).await {
Ok(Ok(Some(UniEvent::SubgroupObject(o)))) => objects.push(o),
Ok(Ok(Some(_))) => return None,
Ok(Ok(None)) => return Some(Got { header, objects, reset: None, arrived }),
Ok(Err(StreamError::Transport(TransportError::Reset(code)))) => return Some(Got { header, objects, reset: Some(code), arrived }),
Ok(Err(e)) => panic!("a data stream: {e}"),
Err(_) => panic!("a data stream stalled"),
}
}
}
/// Read data streams until `done` holds of what was read, or `limit` passes.
pub async fn collect(c: &Client, limit: Duration, mut done: impl FnMut(&[Got]) -> bool) -> Vec<Got> {
let deadline = Instant::now() + limit;
let mut out = Vec::new();
while !done(&out) {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
break;
}
match c.next_data(left).await {
Some(r) => {
if let Some(g) = read_stream(r).await {
out.push(g);
}
}
None => break,
}
}
out
}
/// Read every data stream at once, each in a task of its own, as a subscriber does that
/// reads its streams concurrently: until `done` holds of what was read and `quiet` has
/// passed with no new stream or object, or `limit` passes. Each read is bounded.
///
/// Reading one stream at a time in the order they arrive can stall: QUIC's
/// connection-level flow control is shared by a session's streams, so the bytes of the
/// stream being waited on can wait for credit that unread later streams hold.
pub async fn collect_concurrent(c: &Client, limit: Duration, quiet: Duration, mut done: impl FnMut(&[Got]) -> bool) -> Vec<Got> {
let deadline = Instant::now() + limit;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Got>();
let mut out = Vec::new();
let mut readers = Vec::new();
let mut last = Instant::now();
loop {
let now = Instant::now();
if now >= deadline || (done(&out) && now.duration_since(last) >= quiet) {
break;
}
let tick = deadline.min(now + quiet).saturating_duration_since(now);
tokio::select! {
s = c.next_stream(tick) => {
if let Some(mut r) = s {
last = Instant::now();
let tx = tx.clone();
readers.push(tokio::spawn(async move {
if let Ok(Ok(_)) = timeout(STEP, r.start()).await
&& let Some(g) = read_stream(r).await
{
let _ = tx.send(g);
}
}));
}
}
g = rx.recv() => {
if let Some(g) = g {
last = Instant::now();
out.push(g);
}
}
}
}
for r in readers {
r.abort();
}
while let Ok(g) = rx.try_recv() {
out.push(g);
}
out
}
/// A subscriber's view of one publisher's tracks: the downstream aliases the relay gave.
pub struct Subscribed {
pub media_alias: u64,
pub receipts_alias: u64,
pub held: Vec<(ReqTx, ReqRx)>,
}
pub async fn subscribe_both(c: &Client, t: &Tracks, media_params: Parameters) -> Subscribed {
subscribe_both_with(c, t, media_params, Parameters::new()).await
}
pub async fn subscribe_both_with(c: &Client, t: &Tracks, media_params: Parameters, receipts_params: Parameters) -> Subscribed {
let (m, mtx, mrx) = c.subscribe(t.ns.clone(), t.media.clone(), media_params).await.expect("SUBSCRIBE the media track");
let (r, rtx, rrx) = c.subscribe(t.ns.clone(), t.receipts.clone(), receipts_params).await.expect("SUBSCRIBE the receipts track");
Subscribed { media_alias: m.track_alias, receipts_alias: r.track_alias, held: vec![(mtx, mrx), (rtx, rrx)] }
}
/// Every media object of `sub` verified against the bundles of its receipts track, under
/// the publisher's key alone. Returns (objects verified, bundles verified, the verified
/// groups by Group ID, the object receipts by Group ID).
pub fn verify(got: &[Got], sub: &Subscribed, publisher: &SigningKey) -> (usize, usize, BTreeMap<u64, VerifiedGroup>, HashMap<u64, ObjectReceipt>) {
let verifier = ReceiptVerifier::with_anchors(vec![publisher.verifying_key()]);
let mut groups = BTreeMap::new();
let mut receipts = HashMap::new();
for g in got.iter().filter(|g| g.header.track_alias == sub.receipts_alias && g.reset.is_none()) {
let b = ReceiptBundle::from_payload(&g.objects[0].payload).expect("a bundle");
let vg = verifier.verify_group(&b.group).expect("the group receipt verifies under the publisher's key");
assert_eq!(b.group.group_id, GroupId(g.header.group_id), "the receipt names its MOQT group");
groups.insert(g.header.group_id, vg);
receipts.insert(g.header.group_id, b.objects[0].clone());
}
let mut objects = 0;
for g in got.iter().filter(|g| g.header.track_alias == sub.media_alias && g.reset.is_none()) {
for o in &g.objects {
let vg = groups.get(&g.header.group_id).expect("the receipts of every delivered group arrived");
let r = &receipts[&g.header.group_id];
verifier.verify_payload(&o.payload, r, vg).expect("the payload verifies against its signed root");
objects += 1;
}
}
(objects, groups.len(), groups, receipts)
}
pub fn payload(tag: &str, g: u64, len: usize) -> Vec<u8> {
let mut v = format!("{tag}:{g}:").into_bytes();
v.resize(len.max(v.len()), (g % 251) as u8);
v
}
/// Wait, bounded, until `f` holds.
pub async fn until(limit: Duration, mut f: impl FnMut() -> bool) -> bool {
let deadline = Instant::now() + limit;
while !f() {
if Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
true
}
/// A directory of its own for a relay's DVR store: in memory (`/dev/shm`) on Linux when the
/// host has one, otherwise in the temporary directory. The store's one writer appends and syncs
/// in turn, and on a disk that is writing back a build's output (a CI job that has just built
/// the workspace, beside other jobs on the same disk) a sync, and every write behind it, can
/// take seconds: a test's bounded wait for what the store holds ([`STEP`]) then times that
/// disk, not the store. tests/dvr.rs failed so with a few groups written in twenty seconds, and
/// tests/cascade.rs's DVR test with the home relay's window short of its last group. What the
/// tests check (the window, FETCH, a restart, the decisions, a takedown, the seek power) is the
/// same on any file system.
pub fn store_dir(name: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let leaf = format!("joule-moq-dvr-{name}-{}-{nanos}", std::process::id());
let shm = std::path::Path::new("/dev/shm");
if cfg!(target_os = "linux") && shm.is_dir() {
let d = shm.join(&leaf);
if std::fs::create_dir_all(&d).is_ok() {
return d;
}
}
let d = std::env::temp_dir().join(leaf);
std::fs::create_dir_all(&d).unwrap();
d
}
/// The scenario every transport runs: two publishers, one reached by the namespace route
/// (PUBLISH_NAMESPACE, the relay's SUBSCRIBE) and one by PUBLISH, and three subscribers
/// of both, all over `via`. Every object reaches every subscriber and verifies against its
/// publisher's signed receipts, which the relay also checks at the door; one subscriber
/// discovers the first namespace by SUBSCRIBE_NAMESPACE; every session's summary is signed.
pub async fn two_publishers_three_subscribers(via: Via) {
two_publishers_three_subscribers_on([via; 5]).await
}
/// [`two_publishers_three_subscribers`] with each session on a transport (and draft) of
/// its own: publisher A, publisher B, then the three subscribers.
pub async fn two_publishers_three_subscribers_on(vias: [Via; 5]) {
let key_a = SigningKey::from_bytes(&[0xa1; 32]);
let key_b = SigningKey::from_bytes(&[0xb2; 32]);
let (ka, kb) = (hex::encode(key_a.verifying_key().to_bytes()), hex::encode(key_b.verifying_key().to_bytes()));
let server = relay(|c| {
c.receipts.door = vec![joule_moq_relay::config::DoorRule { prefix: vec!["live".into()], trust: vec![ka, kb], trust_field: None, hold_ms: 5_000 }];
})
.await;
const GROUPS: u64 = 12;
let ta = Tracks::new(ns(&["live", "a"]), "cam", 10, 8, 64);
let tb = Tracks::new(ns(&["live", "b"]), "cam", 20, 9, 96);
let (pa, _ea) = connect(&server, vias[0], ClientOptions::default()).await;
let (pb, _eb) = connect(&server, vias[1], ClientOptions::default()).await;
let _ns_a = pa.publish_namespace(ta.ns.clone(), Parameters::new()).await.expect("PUBLISH_NAMESPACE");
let _pub_b = publish_tracks(&pb, &tb, None).await;
// One subscriber discovers live/a by prefix (§4.2).
let (s0, _e0) = connect(&server, vias[2], ClientOptions::default()).await;
let (_nstx, mut nsrx) = s0.subscribe_namespace(ns(&["live"])).await.expect("SUBSCRIBE_NAMESPACE");
match timeout(STEP, nsrx.next()).await.expect("NAMESPACE in time").expect("a message") {
Some(ControlMessage::Namespace(suffix)) => assert_eq!(suffix, ns(&["a"]), "NAMESPACE carries the suffix after the prefix"),
other => panic!("expected NAMESPACE, got {other:?}"),
}
let mut subs = vec![s0];
let mut eps = Vec::new();
for via in &vias[3..] {
let (s, e) = connect(&server, *via, ClientOptions::default()).await;
subs.push(s);
eps.push(e);
}
// The first SUBSCRIBE to live/a makes the relay subscribe upstream; publisher A answers.
let answering = {
let ta = ta.clone();
let pa_ref = &pa;
async move { answer_subscribes(pa_ref, &ta).await }
};
let s_first = &subs[0];
let (held_a, first_sub) = tokio::join!(answering, subscribe_both(s_first, &ta, Parameters::new()));
let mut views: Vec<(Subscribed, Subscribed)> = vec![(first_sub, subscribe_both(&subs[0], &tb, Parameters::new()).await)];
for s in &subs[1..] {
views.push((subscribe_both(s, &ta, Parameters::new()).await, subscribe_both(s, &tb, Parameters::new()).await));
}
// Both publish, concurrently.
let pub_a = async {
for g in 0..GROUPS {
publish_group(&pa, &key_a, &ta, g, &payload("a", g, 900)).await;
}
};
let pub_b = async {
for g in 0..GROUPS {
publish_group(&pb, &key_b, &tb, g, &payload("b", g, 1400)).await;
}
};
let reads = futures::future::join_all(subs.iter().map(|s| collect(s, STEP * 2, |got| got.len() as u64 >= 4 * GROUPS)));
let ((), (), got) = tokio::join!(pub_a, pub_b, reads);
for (i, (g, (va, vb))) in got.iter().zip(&views).enumerate() {
let (oa, ba, _, _) = verify(g, va, &key_a);
let (ob, bb, _, _) = verify(g, vb, &key_b);
assert_eq!((oa, ba, ob, bb), (GROUPS as usize, GROUPS as usize, GROUPS as usize, GROUPS as usize), "subscriber {i}: every object and every receipt of both publishers");
for x in g.iter().filter(|x| x.header.track_alias == va.media_alias) {
assert_eq!(x.objects[0].payload, payload("a", x.header.group_id, 900));
}
}
assert_eq!(server.relay().stats().refused_at_door, 0);
drop(held_a);
// Every session ends with a signed summary.
for s in &subs {
s.close(0, "done");
}
pa.close(0, "done");
pb.close(0, "done");
assert!(until(STEP, || server.relay().summaries().len() >= 5).await, "five summaries");
for sum in server.relay().summaries() {
sum.verify().expect("the summary verifies under the relay's key");
assert_eq!(sum.summary.relay_energy.acquisition, "unmetered");
assert_eq!(sum.summary.relay_energy.joules_micro, 0, "0 is the unmetered marker");
let transports: Vec<&str> = vias.iter().map(|v| transport_label(*v)).collect();
assert!(transports.contains(&sum.summary.transport.as_str()), "{}", sum.summary.transport);
}
let as_subscriber: Vec<_> = server.relay().summaries().into_iter().filter(|s| s.summary.tally.subscriber.objects_forwarded > 0).collect();
assert_eq!(as_subscriber.len(), 3);
for s in &as_subscriber {
assert_eq!(s.summary.tally.subscriber.objects_forwarded, 4 * GROUPS, "media and receipts of both publishers");
assert_eq!(s.summary.tally.subscriber.dropped_delivery_timeout, 0);
let signed = s.summary.tally.publisher_signed.get("unmetered").expect("the door verified the media objects' receipts");
assert_eq!(signed.objects, 2 * GROUPS, "the publishers' signed figures, unmetered, for the media objects");
}
server.shutdown().await;
}