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
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]>
577 lines
28 KiB
Rust
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;
|
|
}
|