open-standards/joule-code/joule-code-rs/crates/joule-moq-relay/tests/tempo_hop.rs
Transaction Science af4b1529f7
All checks were successful
joule-moq-http live HTTP egress / joule-moq-http · does this change touch it (push) Successful in 23s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Successful in 28s
joule-moq-room room service / joule-moq-room · does this change touch it (push) Successful in 19s
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Successful in 38s
WAI Studio / does this change touch it (push) Successful in 16s
wai-app and Create / wai-app · does this change touch it (push) Successful in 57s
wai-fleet service / does this change touch it (push) Successful in 59s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 24s
joule-code conformance gate / joule-moq-relay, every target (push) Successful in 5m34s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Successful in 53s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Successful in 2m12s
joule-moq-http live HTTP egress / joule-moq-http, every target (push) Successful in 3m2s
joule-code conformance gate / Build + round-trip C1-C4 vectors (push) Successful in 4m19s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Successful in 2m9s
wai-fleet service / service · linux (push) Successful in 2m34s
joule-code conformance gate / joule-moq-ingest, every target (push) Successful in 7m36s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Successful in 9m46s
wai release binaries build on Windows / the seven release binaries · windows (push) Successful in 20m23s
joule-moq-room room service / joule-moq-room, every target (push) Successful in 8m53s
WAI Studio / Studio in WebKit · linux (push) Successful in 14m30s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 54s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 6m13s
joule-code conformance gate / joule-moq-relay, its own energy on a counter (macos) (push) Successful in 52s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Successful in 1m0s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Successful in 8m6s
wai-fleet service / service · macos (push) Successful in 42s
WAI Studio / Studio in Chromium and Firefox · macos (push) Successful in 10m29s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Successful in 16m23s
Relay robustness: denial-of-service limits, HopTime and Sync Support, the DVR store as MoQ files, a load harness with a signed record, and a switch through the relay
The relay is held to draft-englishm-moq-relay-dos-02 consideration by consideration (the
README's table, which also says what is not bounded). New limits: the sessions refused at
SETUP that are held open, in all and by address, the oldest shed first; subscriptions,
published tracks and namespaces a session; requests a second a session, refused
EXCESSIVE_LOAD with a Retry Interval.

What the MoQ timing package left unwired:
- the relay rewrites TEMPO's HopTime as it forwards, for a subscription that carried the
  draft's Sync Support parameter, negotiated by a Setup Option at -23 and -22 (-18 and -16
  carry none; `stamp = "always"` covers them). The draft's code points are unassigned: the
  placeholders are in joule_moq::tempo alone, and [tempo] sets the three property types;
- joule-moq-room's tempo gains a Stamper and a Receiver, and a test publishes stamped
  objects on a session and holds each to the room at a subscriber through the relay;
- the DVR store writes its window as .moq and .dat files, read back and verified against
  the receipts from the files alone, agreeing with a FETCH byte for byte.

A load harness to draft-evens-moq-bench-00's method (joule_moq_relay::bench,
examples/relay_bench.rs): profiles, the draft's messages and metrics, and a signed, dated
measurement record of the host that ran it, used as a regression guard with stated
tolerances; it compares a host only with itself. A subscriber is switched between two
renditions through the relay by joule-moq's switching plan: every frame once, on a Group
boundary.

Planted faults, each caught: a flood of refused SETUPs (and, with the bounds removed, all
held); a session past each count and past its rate (and, with the limit removed, served); a
switch mid-group; timing claims off the instant, past the bound and outside Immutable
Properties; a data file and a .moq file cut short, a data file outside its directory, a
changed payload; a record changed after signing and a run claimed for another host.

Left: the rewind subscription filter (draft-duke-moq-subscribe-rewind-02), probe tracks at
the relay (the library is in joule-moq), TOO_FAR_BEHIND, FEC tracks and V3C publishing.

Co-Authored-By: Claude <[email protected]>
2026-11-10 23:31:46 -05:00

174 lines
8.6 KiB
Rust

//! Playout timing through the relay (`draft-nandakumar-moq-tempo-00` §2.3, §2.4, §3.5): the
//! relay declares that it reads the Sync Support parameter, and rewrites the HopTime of an
//! object it forwards with its own clock for a subscription that carried the parameter, at
//! -23 and at -22, leaving the object's Immutable Properties (its CaptureTimestamp and
//! PlayoutDelay) and its payload as they came.
//!
//! - a subscriber that asked receives a HopTime of the relay's clock, not the publisher's; one
//! that did not, on the same track, receives the publisher's;
//! - a short HopTime (the draft's §4.1) is rewritten short;
//! - an object with no HopTime is given none;
//! - with `stamp = "always"` a subscriber at -18, which carries no such parameter, is stamped;
//! - with `stamp = "off"` the relay declares nothing, forwards HopTime as it came, and a
//! SUBSCRIBE that carries the parameter all the same closes its session (an unknown
//! parameter: INVALID_PARAMETER at -23);
//! - with another `hop_time_type` configured the default type's property is not touched.
mod common;
use common::*;
use joule_moq::draft::Draft;
use joule_moq::draft22::data::{SubgroupHeader, SubgroupId, SubgroupObject};
use joule_moq::draft22::kvp::{KeyValuePair, Properties};
use joule_moq::draft22::{varint, ObjectStatus, Parameter, Parameters};
use joule_moq::tempo::{TempoTypes, CAPTURE_TIMESTAMP, HOP_TIME, PLAYOUT_DELAY, TEMPO_OPTION};
use joule_moq_relay::client::{Client, ClientOptions};
use joule_moq_relay::config::HopStamp;
use joule_moq_relay::transport::TransportError;
use std::time::Duration;
use tokio::time::timeout;
/// The publisher's HopTime: a clock a day behind, so the relay's is told from it.
fn stale() -> u64 {
joule_moq_relay::relay::now_unix_ms() - 86_400_000
}
fn vi(v: u64) -> Vec<u8> {
let mut b = Vec::new();
varint::put(&mut b, v);
b
}
fn timed(hop: Option<u64>) -> Properties {
let mut inner = Properties::new();
inner.push(KeyValuePair::bytes(PLAYOUT_DELAY, 200u32.to_be_bytes().to_vec()));
inner.push(KeyValuePair::bytes(CAPTURE_TIMESTAMP, vi(1_700_000_000_000)));
let mut p = Properties::new();
if let Some(h) = hop {
p.push(KeyValuePair::bytes(HOP_TIME, vi(h)));
}
p.set_immutable(&inner).unwrap();
p
}
const ALIAS: u64 = 7;
async fn send(p: &Client, g: u64, props: Properties) {
let h = SubgroupHeader { track_alias: ALIAS, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(128), properties: true, end_of_group: true, first_object: true };
p.send_subgroup(h, &[SubgroupObject { object_id: 0, properties: props, status: ObjectStatus::Normal, payload: format!("frame {g}").into_bytes() }]).await.expect("the object");
}
/// Groups 0..n as one subscriber received them: each object's properties.
async fn received(c: &Client, n: usize) -> Vec<Properties> {
let mut got = collect(c, STEP, |g| g.len() >= n).await;
assert_eq!(got.len(), n, "every group arrived");
got.sort_by_key(|g| g.header.group_id);
got.iter()
.enumerate()
.map(|(g, s)| {
assert_eq!(s.objects[0].payload, format!("frame {g}").into_bytes(), "the payload as sent");
s.objects[0].properties.clone()
})
.collect()
}
fn asking() -> Parameters {
Parameters::new().with(Parameter::SyncSupport)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_relay_rewrites_hop_time_for_a_subscriber_that_asked() {
timeout(Duration::from_secs(180), run()).await.expect("the test finishes in time");
}
async fn run() {
let types = TempoTypes::default();
let space = ns(&["live", "timed"]);
let track = name("video");
// ── stamp = "asked" (the default) ──
let server = relay(|_| {}).await;
let (p, _pe) = connect(&server, Via::Quic, ClientOptions::default()).await;
let _pub = p.publish(space.clone(), track.clone(), ALIAS, Parameters::new(), track_props(128, None)).await.unwrap();
let (a23, _e1) = connect(&server, Via::QuicDraft(Draft::D23), ClientOptions::default()).await;
let (a22, _e2) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let (plain, _e3) = connect(&server, Via::Quic, ClientOptions::default()).await;
for c in [&a23, &a22, &plain] {
assert!(c.peer_setup.options.extension(TEMPO_OPTION).is_some(), "the relay declares that it reads Sync Support");
}
let _s1 = a23.subscribe(space.clone(), track.clone(), asking()).await.expect("Sync Support at -23");
let _s2 = a22.subscribe(space.clone(), track.clone(), asking()).await.expect("Sync Support at -22");
let _s3 = plain.subscribe(space.clone(), track.clone(), Parameters::new()).await.unwrap();
let old = stale();
let before = joule_moq_relay::relay::now_unix_ms();
send(&p, 0, timed(Some(old))).await;
send(&p, 1, timed(Some(old & 16_383))).await; // a short timestamp
send(&p, 2, timed(None)).await;
for c in [&a23, &a22] {
let got = received(c, 3).await;
let after = joule_moq_relay::relay::now_unix_ms();
let hop = types.hop_time(&got[0]).expect("a HopTime");
assert!(hop >= before && hop <= after, "the relay's clock as it forwarded: {before} <= {hop} <= {after}");
let short = types.hop_time(&got[1]).expect("a HopTime");
assert!(short < 16_384, "a short timestamp stays short");
assert!((before..=after).any(|t| t & 16_383 == short), "and is the relay's clock's low 14 bits");
assert_eq!(types.hop_time(&got[2]), None, "the relay adds no HopTime of its own");
for g in &got {
assert_eq!(g.immutable().unwrap(), timed(None).immutable().unwrap(), "Immutable Properties as the publisher sent them");
}
}
let got = received(&plain, 3).await;
assert_eq!(types.hop_time(&got[0]), Some(old), "a subscriber that did not ask receives the publisher's HopTime");
assert_eq!(got[0].immutable().unwrap(), timed(None).immutable().unwrap());
for c in [&a23, &a22, &plain, &p] {
c.close(0, "done");
}
server.shutdown().await;
// ── stamp = "always": a subscriber at -18 has no parameter to ask with ──
let server = relay(|c| c.tempo.stamp = HopStamp::Always).await;
let (p, _pe) = connect(&server, Via::Quic, ClientOptions::default()).await;
let _pub = p.publish(space.clone(), track.clone(), ALIAS, Parameters::new(), track_props(128, None)).await.unwrap();
let (old18, _e) = connect(&server, Via::QuicDraft(Draft::D18), ClientOptions::default()).await;
let _s = old18.subscribe(space.clone(), track.clone(), Parameters::new()).await.unwrap();
let before = joule_moq_relay::relay::now_unix_ms();
send(&p, 0, timed(Some(old))).await;
let got = received(&old18, 1).await;
assert!(types.hop_time(&got[0]).expect("a HopTime") >= before, "stamped without asking");
old18.close(0, "done");
p.close(0, "done");
server.shutdown().await;
// ── stamp = "off", and another type configured ──
for other_type in [false, true] {
let server = relay(|c| {
if other_type {
c.tempo.stamp = HopStamp::Always;
c.tempo.hop_time_type = 0x3a7b;
} else {
c.tempo.stamp = HopStamp::Off;
}
})
.await;
let (p, _pe) = connect(&server, Via::Quic, ClientOptions::default()).await;
let _pub = p.publish(space.clone(), track.clone(), ALIAS, Parameters::new(), track_props(128, None)).await.unwrap();
let (c, _e) = connect(&server, Via::QuicDraft(Draft::D23), ClientOptions::default()).await;
assert_eq!(c.peer_setup.options.extension(TEMPO_OPTION).is_none(), !other_type);
let _s = c.subscribe(space.clone(), track.clone(), Parameters::new()).await.unwrap();
send(&p, 0, timed(Some(old))).await;
let got = received(&c, 1).await;
assert_eq!(types.hop_time(&got[0]), Some(old), "HopTime forwarded as it came");
if !other_type {
// The parameter was not negotiated: an unknown one.
let (bad, _e) = connect(&server, Via::QuicDraft(Draft::D23), ClientOptions::default()).await;
let _ = bad.request(&joule_moq::draft22::ControlMessage::Subscribe(joule_moq::draft22::TrackRequest { request_id: bad.next_request_id(), namespace: space.clone(), name: track.clone(), params: asking() })).await;
match timeout(STEP, bad.conn.closed()).await.expect("closed in time") {
TransportError::Closed { code, .. } => assert_eq!(code, joule_moq::draft23::codes::SessionError::INVALID_PARAMETER.0),
other => panic!("{other:?}"),
}
}
c.close(0, "done");
p.close(0, "done");
server.shutdown().await;
}
}