source_room failed on a loaded host with the gateway's running_skips_object (6 of 12 runs on
the Framework at a load of 67 to 96 on 32 processors, 77b06ba17 in place). The gateway joins the
owner's events track with a subscription from its next object and a fill of its latest group,
each its own stream, and took records as they came: a running receipt of the subscription read
before the fill's last ones of its group was past the run (live-http-egress §3.5), refused as a
skip, and the run broken. live-events §8.1 has the track's stream order be Location order. A
media track joined the same way lost a group joined inside: the end of its stream reached the
egress before the fill brought the group's first objects, and its later parts were withheld
object_missing.
- joule-moq-http, src/order.rs (JoinOrder): for every subscription, its objects and its ends of
groups wait until the fill has brought the track to the largest SUBSCRIBE_OK states, or its
stream is over; on the events track a group's objects wait too while an earlier group's stream
is open. Past 4096 held the earliest goes, counted and logged.
- The room page reads every events track so (source.js EventsOrder, app.js), waiting too for
SUBSCRIBE_OK; a fill that never comes holds nothing past 10 s.
- source_room's door check: an object that waited and passed on its group's receipts had its
running receipt read by the door (Door::runs_read); a group's run-passed objects come before
its receipts-passed ones; no group whose receipts an object waited for passed none on its run.
Under load a group's last but one passed on its receipts too (2 of 12 runs), as §3.2 has it.
- Planted, the order disabled: the unit tests fail, and source_room fails again with
running_skips_object and object_missing (3 of 8 runs). With every track ordered, 20 of 20
runs passed on the Framework at a load of 43 to 57.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
592 lines
31 KiB
Rust
592 lines
31 KiB
Rust
//! The room's own source over MOQT (`[source]`, `src/source.rs`), through the relay and the HTTP
|
|
//! egress gateway (`joule-moq-http`), on loopback. The relay holds the source's video and sound
|
|
//! at its door as `wai dev`'s does (`[[receipts.door]]`, `trust_field = 1`), and the door reads
|
|
//! the source's running receipts: every object passes on its group's run, or, of a group's last,
|
|
//! on its group's receipts where they came first; none waits for the receipts for want of a
|
|
//! running receipt, and none is refused.
|
|
//!
|
|
//! - **Stamps through the relay.** A viewer of the room, with a grant the service issues,
|
|
//! subscribes to the source's video and sound with Sync Support and holds every object it
|
|
//! receives to its stamps: CaptureTimestamp and PlayoutDelay read inside Immutable Properties
|
|
//! under the configured types; the delay the catalog's, within its bound; the instant after
|
|
//! the room began; the capture less the object's own media time (its `tfdt` and durations)
|
|
//! one constant across both tracks, so the source states one capture clock; a HopTime of the
|
|
//! relay's clock, between the capture and the arrival; and the object here before its
|
|
//! instant. Every group read is whole, its objects in order from 0.
|
|
//! - **Running receipts through the gateway.** The gateway, a viewer too, follows the source's
|
|
//! video and sound by their hinted parts; each part served while its group is open is checked
|
|
//! against the run served beside it (each record signed by the source, continuing the one
|
|
//! before, its last stating the part's bytes), and every part, once its group's receipts
|
|
//! have come, against them. Nothing is withheld or refused, no running receipt either (the
|
|
//! gateway reads the source's events track in its order, the fill that joins it before the
|
|
//! subscription's objects), and the gateway's trace checks.
|
|
//! - **Real time** (`a_thirty_frame_room_is_served_in_real_time`): thirty seconds of 30 fps
|
|
//! video and sound through the door, every part served by the gateway within [`BOUND_MS`] of
|
|
//! its capture stamp and none withheld or missing. With it, how long the door held each
|
|
//! object, and how long after it the group's receipts came: what a door that reads no running
|
|
//! receipt holds it for. The measurement is refused on a host whose load is above a quarter
|
|
//! of its processors, and says so; a figure is this host's on this day.
|
|
//!
|
|
//! Every wait is bounded, every listener binds port 0, and everything stops with its test.
|
|
|
|
use ed25519_dalek::SigningKey;
|
|
use joule_moq::draft22::{Parameter, Parameters, StreamHeader, UniEvent};
|
|
use joule_moq::{TrackName, TrackNamespace};
|
|
use joule_moq_http::{Config as GatewayConfig, Gateway};
|
|
use joule_moq_relay::client::{Client, ClientOptions};
|
|
use joule_moq_relay::transport::mux::MuxConfig;
|
|
use joule_moq_relay::transport::{websocket, SharedConnection};
|
|
use joule_moq_room::dev::{DevRoom, Fixture};
|
|
use joule_moq_room::epoch::Role;
|
|
use joule_moq_room::source::{chunk_span, Source};
|
|
use joule_moq_room::tempo::Timing;
|
|
use serde_json::Value;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::net::SocketAddr;
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::{Duration, Instant};
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::time::timeout;
|
|
use wai::live_egress::{check_trace, running, Params};
|
|
use wai::live_events::anchor_track;
|
|
|
|
const STEP: Duration = Duration::from_secs(20);
|
|
const PLAYOUT_MS: u32 = 1000;
|
|
const MAX_DELAY_MS: u32 = 2000;
|
|
/// The bound of the real-time test: a part is served this soon after its capture stamp.
|
|
const BOUND_MS: u64 = 500;
|
|
|
|
fn package() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../../wai/pack-conformance/multi/multi-opus-01/package")
|
|
}
|
|
|
|
fn now_ms() -> u64 {
|
|
joule_moq_room::now_us() / 1000
|
|
}
|
|
|
|
async fn start() -> DevRoom {
|
|
let extra = format!("[source]\npackage = {:?}\nplayout_delay_ms = {PLAYOUT_MS}\nmax_delay_ms = {MAX_DELAY_MS}\n", package());
|
|
DevRoom::start_with(&Fixture { extra, doors: true, ..Fixture::default() }).await.expect("the room and its relay")
|
|
}
|
|
|
|
/// A track's group's holds at the door: each object, what it passed on, and whether it waited.
|
|
type ByGroup<'a> = BTreeMap<(&'a str, u64), Vec<(u64, &'static str, bool)>>;
|
|
|
|
/// The door passed every object of the source on its group's run or its group's receipts, and
|
|
/// none waited for the receipts for want of a running receipt: nothing was refused, no running
|
|
/// receipt was refused and no run contradicted, and each object that waited and passed on its
|
|
/// group's receipts is one whose running receipt the door read, taken into the run or read once
|
|
/// the receipts had closed it. Such an object is one of a group's last: the receipts are sent right
|
|
/// after the group's last object, at a transport priority above the running receipts', and once
|
|
/// they have come nothing more of the group is held on its run (WAI live-http-egress §3.2). So an
|
|
/// object whose running receipt had not reached the door when they came, or whose wait the door
|
|
/// took up again only after they came (a task woken late on a loaded host: the door reads the
|
|
/// group's receipts first), passes on them; with a final object of one frame (33 ms) after it, a
|
|
/// group's last but one meets them too.
|
|
fn door_passed_on_runs(dev: &DevRoom) -> Vec<joule_moq_relay::door::Hold> {
|
|
let s = dev.relay.relay().stats();
|
|
assert!(s.door_admitted_on_run > 0 && s.door_running_taken >= s.door_admitted_on_run, "{s:?}");
|
|
assert_eq!((s.refused_at_door, s.groups_dropped_at_door, s.door_running_refused, s.door_runs_contradicted), (0, 0, 0, 0), "{s:?}");
|
|
let door = dev.relay.relay().door();
|
|
let holds: Vec<_> = door.holds().into_iter().filter(|h| h.track.ends_with("-video--0") || h.track.ends_with("-audio--0")).collect();
|
|
assert!(!holds.is_empty(), "the door held the source's objects");
|
|
let read: BTreeSet<(String, u64, u64)> = door.runs_read().into_iter().filter(|r| r.outcome == "taken" || r.outcome == "ignored").map(|r| (r.track, r.group, r.object)).collect();
|
|
let waited: Vec<_> = holds
|
|
.iter()
|
|
.filter(|h| h.by != "run" && (h.by != "receipts" || (h.waited && !read.contains(&(h.track.clone(), h.group, h.object)))))
|
|
.map(|h| (h.track.rsplit('-').nth(2).unwrap_or(""), h.group, h.object, h.by, h.held))
|
|
.collect();
|
|
assert!(waited.is_empty(), "{} of {} objects waited for their group's receipts with no running receipt read, or were refused: {waited:?}", waited.len(), holds.len());
|
|
// So in each group the objects passed on its run come first and those passed on its
|
|
// receipts after them, and a group whose receipts an object waited for passed objects on its
|
|
// run before: runs that came too late for every object would leave none.
|
|
let mut by_group: ByGroup = BTreeMap::new();
|
|
for h in &holds {
|
|
by_group.entry((h.track.as_str(), h.group)).or_default().push((h.object, h.by, h.waited));
|
|
}
|
|
for (k, mut v) in by_group {
|
|
v.sort_unstable();
|
|
let first = v.iter().position(|x| x.1 != "run").unwrap_or(v.len());
|
|
assert!(v[first..].iter().all(|x| x.1 == "receipts"), "{k:?}: an object passed on its run after one passed on its group's receipts: {v:?}");
|
|
assert!(first > 0 || !v.iter().any(|x| x.2), "{k:?}: every object waited for its group's receipts: {v:?}");
|
|
}
|
|
holds
|
|
}
|
|
|
|
/// A viewer's grant for a fresh key, as `POST /grant` would issue it.
|
|
fn viewer(dev: &DevRoom) -> (Vec<u8>, SigningKey) {
|
|
let key = SigningKey::from_bytes(&{ use rand_core_06::RngCore as _; let mut secret = [0u8; 32]; rand_core_06::OsRng.fill_bytes(&mut secret); secret });
|
|
let issued = dev.svc.state.lock().unwrap().epoch.grant(&dev.svc.keys, key.verifying_key().to_bytes(), Role::Viewer, joule_moq_room::now_s(), None).expect("a viewer's grant");
|
|
(issued.record, key)
|
|
}
|
|
|
|
async fn session(dev: &DevRoom, grant: (Vec<u8>, SigningKey)) -> Client {
|
|
let url = dev.relay_url.clone();
|
|
let dial = || {
|
|
let url = url.clone();
|
|
async move {
|
|
let c = timeout(STEP, websocket::connect(&url, MuxConfig::default())).await.map_err(|_| "no session".to_string())?.map_err(|e| e.to_string())?;
|
|
Ok::<SharedConnection, String>(Arc::new(c))
|
|
}
|
|
};
|
|
let opts = ClientOptions { grants: vec![grant], target: joule_moq_room::proof_target(&dev.relay_url), ..ClientOptions::default() };
|
|
Client::connect_proving(dial, opts).await.expect("a viewer's session")
|
|
}
|
|
|
|
/// The room, the source's key and the generation it publishes in, once it publishes.
|
|
async fn publishing(dev: &DevRoom) -> ([u8; 32], [u8; 32], u64) {
|
|
let src = dev.svc.source.clone().expect("a source");
|
|
let until = Instant::now() + STEP;
|
|
loop {
|
|
if let Some(g) = src.publishing() {
|
|
let room = dev.svc.state.lock().unwrap().epoch.id;
|
|
return (room, Source::key(&dev.svc.keys.clock).verifying_key().to_bytes(), g);
|
|
}
|
|
assert!(Instant::now() < until, "the source did not start publishing");
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
}
|
|
|
|
/// `GET` over a connection of its own: the status, the body and when its last byte came.
|
|
async fn get(addr: SocketAddr, target: &str) -> (u16, Vec<u8>, u64) {
|
|
let mut s = timeout(Duration::from_secs(5), tokio::net::TcpStream::connect(addr)).await.expect("in time").expect("a connection");
|
|
s.write_all(format!("GET /{target} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n").as_bytes()).await.expect("a request");
|
|
let mut buf = Vec::new();
|
|
let mut chunk = vec![0u8; 64 << 10];
|
|
loop {
|
|
if let Some(end) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
|
|
let text = String::from_utf8_lossy(&buf[..end]).to_lowercase();
|
|
let status = text.split(' ').nth(1).and_then(|c| c.parse().ok()).expect("a status");
|
|
if let Some(n) = text.lines().find_map(|l| l.strip_prefix("content-length: ")).and_then(|n| n.trim().parse::<usize>().ok())
|
|
&& buf.len() >= end + 4 + n
|
|
{
|
|
return (status, buf[end + 4..end + 4 + n].to_vec(), now_ms());
|
|
}
|
|
}
|
|
let n = timeout(Duration::from_secs(30), s.read(&mut chunk)).await.unwrap_or_else(|_| panic!("{target}: no answer in time")).expect("a read");
|
|
if n == 0 {
|
|
let end = buf.windows(4).position(|w| w == b"\r\n\r\n").expect("a head");
|
|
let status = String::from_utf8_lossy(&buf[..end]).split(' ').nth(1).and_then(|c| c.parse().ok()).expect("a status");
|
|
return (status, buf[end + 4..].to_vec(), now_ms());
|
|
}
|
|
buf.extend_from_slice(&chunk[..n]);
|
|
}
|
|
}
|
|
|
|
async fn until_ok(addr: SocketAddr, target: &str) -> String {
|
|
let until = Instant::now() + STEP;
|
|
loop {
|
|
let (status, body, _) = get(addr, target).await;
|
|
if status == 200 {
|
|
return String::from_utf8(body).expect("text");
|
|
}
|
|
assert!(Instant::now() < until, "{target}: still {status}");
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
}
|
|
|
|
/// A media playlist's live edge: the open segment's number, its parts listed, the hinted part.
|
|
fn edge(playlist: &str) -> (u64, u64, Option<String>) {
|
|
let (mut msn, mut parts, mut hint) = (0, 0, None);
|
|
let attr = |l: &str| l.split("URI=\"").nth(1).and_then(|r| r.split('"').next()).map(str::to_string);
|
|
for l in playlist.lines() {
|
|
if let Some(n) = l.strip_prefix("#EXT-X-MEDIA-SEQUENCE:") {
|
|
msn = n.parse().expect("a number");
|
|
} else if l.starts_with("#EXT-X-PART:") {
|
|
parts += 1;
|
|
} else if l.starts_with("#EXT-X-PRELOAD-HINT:") {
|
|
hint = attr(l);
|
|
} else if !l.starts_with('#') && !l.is_empty() {
|
|
msn += 1;
|
|
parts = 0;
|
|
}
|
|
}
|
|
(msn, parts, hint)
|
|
}
|
|
|
|
/// The parts a media playlist lists, in order.
|
|
fn listed_parts(playlist: &str) -> Vec<String> {
|
|
playlist.lines().filter(|l| l.starts_with("#EXT-X-PART:")).filter_map(|l| l.split("URI=\"").nth(1).and_then(|r| r.split('"').next()).map(|u| u.trim_start_matches("../").to_string())).collect()
|
|
}
|
|
|
|
fn try_group_object(uri: &str) -> Option<(u64, u64)> {
|
|
let (g, o) = uri.strip_prefix("p/").and_then(|r| r.strip_suffix(".m4s")).and_then(|r| r.split_once('.'))?;
|
|
Some((g.parse().ok()?, o.parse().ok()?))
|
|
}
|
|
|
|
fn group_object(uri: &str) -> (u64, u64) {
|
|
try_group_object(uri).expect("a part URI")
|
|
}
|
|
|
|
struct Room {
|
|
room: [u8; 32],
|
|
owner: [u8; 32],
|
|
}
|
|
|
|
impl Room {
|
|
fn place(&self, track: &str) -> (u64, [u8; 32]) {
|
|
let kind = track.split('/').next().expect("a kind");
|
|
(if kind == "video" { 100 } else { 101 }, anchor_track(&format!("{}-{}-{kind}", hex::encode(self.room), hex::encode(self.owner)), "0"))
|
|
}
|
|
|
|
/// A part checked against the run served beside it; `false` where it has none (it was held
|
|
/// on its group's receipts).
|
|
async fn verify_running(&self, addr: SocketAddr, track: &str, uri: &str, bytes: &[u8]) -> bool {
|
|
let (g, o) = group_object(uri);
|
|
let (status, body, _) = get(addr, &format!("{track}/{uri}.running")).await;
|
|
if status == 404 {
|
|
return false;
|
|
}
|
|
assert_eq!(status, 200, "{track}/{uri}: its run");
|
|
let v: Value = serde_json::from_slice(&body).expect("JSON");
|
|
let records = v["records"].as_array().expect("records");
|
|
assert_eq!(records.len() as u64, o + 1, "{uri}: one record for each position to its own");
|
|
let (alias, anchor) = self.place(track);
|
|
let mut leaves = Vec::new();
|
|
let mut last = None;
|
|
for (i, r) in records.iter().enumerate() {
|
|
let record = hex::decode(r.as_str().expect("hex")).expect("hex");
|
|
let place = running::Place { room: &self.room, owner: &self.owner, track: &anchor, track_alias: alias, group: g, object: i as u64 };
|
|
let r = running::check(&record, &place).unwrap_or_else(|why| panic!("{uri}: record {i}: {why}"));
|
|
assert_eq!(r.prior, wai::jwp_delivery::root(&leaves), "{uri}: record {i} continues the run");
|
|
leaves.push(r.leaf);
|
|
last = Some(r);
|
|
}
|
|
assert_eq!(last.expect("a record").content_hash, wai::http_binding::content_hash(bytes), "{uri}: the bytes are what the source stated");
|
|
true
|
|
}
|
|
|
|
/// A part checked against its receipt index and group receipt, under the source's key.
|
|
async fn verify_receipts(&self, addr: SocketAddr, track: &str, uri: &str, bytes: &[u8]) {
|
|
use wai::http_binding::receipts::{read_group_file, ReceiptIndex};
|
|
let (status, body, _) = get(addr, &format!("{track}/{uri}.receipts")).await;
|
|
assert_eq!(status, 200, "{track}/{uri}: its receipt index");
|
|
let index = ReceiptIndex::from_bytes(&body).expect("an index");
|
|
let item = index.delivery[0].as_ref().expect("a delivery item");
|
|
let (status, file, _) = get(addr, &format!("{track}/p/rcpt/{}.json", hex::encode(item.property.group_receipt))).await;
|
|
assert_eq!(status, 200, "{uri}: its group receipt");
|
|
let group = read_group_file(&item.property.group_receipt, &file).expect("a group receipt under its name");
|
|
assert_eq!(group.signer_pubkey, self.owner);
|
|
item.object_receipt.accept(&group, bytes).unwrap_or_else(|e| panic!("{uri}: {e}"));
|
|
}
|
|
}
|
|
|
|
async fn gateway(dev: &DevRoom, room: &Room) -> Gateway {
|
|
let src = dev.svc.source.clone().expect("a source");
|
|
let longest = src.tracks.iter().map(|t| t.longest_group_ms()).max().unwrap_or(1000);
|
|
let params = Params { target_duration_s: longest.div_ceil(1000).max(1), part_target_ms: 250, window: 6, latency_target_ms: 1500 };
|
|
let cfg = GatewayConfig {
|
|
relay_url: dev.relay_url.clone(),
|
|
room: room.room,
|
|
owner: hex::encode(room.owner),
|
|
publisher: None,
|
|
listen: "127.0.0.1:0".parse().unwrap(),
|
|
params,
|
|
grant: Some(viewer(dev)),
|
|
record_trace: true,
|
|
catalog_wait: Duration::from_secs(10),
|
|
breaks: None,
|
|
};
|
|
let until = Instant::now() + STEP;
|
|
loop {
|
|
match Gateway::start(cfg.clone()).await {
|
|
Ok(g) => return g,
|
|
Err(e) => {
|
|
assert!(Instant::now() < until, "the gateway: {e}");
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One object as a viewer received it.
|
|
struct Got {
|
|
track: &'static str,
|
|
group: u64,
|
|
object: u64,
|
|
timing: Timing,
|
|
span: (u64, u64),
|
|
arrived_ms: u64,
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn the_rooms_source_is_stamped_and_served_under_running_receipts() {
|
|
timeout(Duration::from_secs(150), stamped_and_running()).await.expect("in time");
|
|
}
|
|
|
|
async fn stamped_and_running() {
|
|
let dev = start().await;
|
|
dev.relay.relay().door().record_holds();
|
|
let (room, owner, _) = publishing(&dev).await;
|
|
let src = dev.svc.source.clone().expect("a source");
|
|
let types = src.cfg.types();
|
|
|
|
// GET /room names the source.
|
|
let (status, body, _) = get(dev.http, "room").await;
|
|
assert_eq!(status, 200);
|
|
let v: Value = serde_json::from_slice(&body).expect("JSON");
|
|
assert_eq!(v["source"]["principal"].as_str(), Some(hex::encode(owner).as_str()));
|
|
// The room's grants, the source's among them, each within its role, as a page checks them
|
|
// before it enters (live-rooms §4.3).
|
|
let descriptor = wai::live_room::Descriptor::parse(&hex::decode(v["descriptor"].as_str().expect("a descriptor")).expect("hex")).expect("the descriptor reads");
|
|
let grants: Vec<Vec<u8>> = v["grants"].as_array().expect("grants").iter().map(|g| hex::decode(g.as_str().expect("hex")).expect("hex")).collect();
|
|
let held = wai::live_room::check_grants(&descriptor, &descriptor.room_id(), &grants).unwrap_or_else(|(i, r)| panic!("grant {i}: {}", r.code()));
|
|
assert!(held.iter().any(|g| g.grant.principal == owner), "the source's grant is among the room's");
|
|
// And the types its stamps are written under, which a page reads them by.
|
|
assert_eq!((v["source"]["types"]["playout_delay"].as_u64(), v["source"]["types"]["capture_timestamp"].as_u64(), v["source"]["types"]["hop_time"].as_u64()), (Some(types.playout_delay), Some(types.capture_timestamp), Some(types.hop_time)));
|
|
|
|
// A viewer subscribes to both tracks with Sync Support.
|
|
let c = Arc::new(session(&dev, viewer(&dev)).await);
|
|
let mut aliases = BTreeMap::new();
|
|
let mut subscriptions = Vec::new();
|
|
for kind in ["video", "audio"] {
|
|
let ns = TrackNamespace::from_strs([hex::encode(room).as_str(), hex::encode(owner).as_str(), kind]);
|
|
let until = Instant::now() + STEP;
|
|
let ok = loop {
|
|
match c.subscribe(ns.clone(), TrackName::from_str("0"), Parameters::new().with(Parameter::SyncSupport)).await {
|
|
Ok(s) => break s,
|
|
Err(e) => {
|
|
assert!(Instant::now() < until, "SUBSCRIBE {kind}: {e}");
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
}
|
|
};
|
|
aliases.insert(ok.0.track_alias, if kind == "video" { "video" } else { "audio" });
|
|
subscriptions.push(ok);
|
|
}
|
|
let got: Arc<Mutex<Vec<Got>>> = Arc::default();
|
|
let opened: Arc<Mutex<BTreeSet<(&'static str, u64)>>> = Arc::default();
|
|
let reader = {
|
|
let (c, got, opened, aliases) = (c.clone(), got.clone(), opened.clone(), aliases.clone());
|
|
tokio::spawn(async move {
|
|
while let Some(mut r) = c.next_stream(Duration::from_secs(30)).await {
|
|
let (got, opened, aliases) = (got.clone(), opened.clone(), aliases.clone());
|
|
tokio::spawn(async move {
|
|
let Ok(StreamHeader::Subgroup(h)) = r.start().await else { return };
|
|
let Some(track) = aliases.get(&h.track_alias).copied() else { return };
|
|
opened.lock().unwrap().insert((track, h.group_id));
|
|
while let Ok(Some(UniEvent::SubgroupObject(o))) = r.next().await {
|
|
let timing = Timing::read_as(&o.properties, &types).unwrap_or_else(|e| panic!("{track} {}/{}: {e}", h.group_id, o.object_id));
|
|
let span = chunk_span(&o.payload).expect("a CMAF chunk");
|
|
got.lock().unwrap().push(Got { track, group: h.group_id, object: o.object_id, timing, span, arrived_ms: now_ms() });
|
|
}
|
|
});
|
|
}
|
|
})
|
|
};
|
|
|
|
// The gateway, meanwhile, follows the video by its hinted parts.
|
|
let r = Room { room, owner };
|
|
let gw = gateway(&dev, &r).await;
|
|
let addr = gw.addr;
|
|
let (mut served, mut on_runs) = (0, 0);
|
|
for track in ["video/0", "audio/0"] {
|
|
let mut playlist = until_ok(addr, &format!("{track}/media.m3u8")).await;
|
|
for _ in 0..16 {
|
|
let (msn, parts, hint) = edge(&playlist);
|
|
let hint = hint.expect("a live playlist hints its next part");
|
|
let reload = format!("{track}/media.m3u8?_HLS_msn={msn}&_HLS_part={parts}");
|
|
let part = format!("{track}/{hint}");
|
|
let ((status, body, _), (rs, next, _)) = tokio::join!(get(addr, &part), get(addr, &reload));
|
|
assert_eq!(rs, 200, "{reload}");
|
|
playlist = String::from_utf8(next).expect("text");
|
|
if status == 200 {
|
|
served += 1;
|
|
if r.verify_running(addr, track, &hint, &body).await {
|
|
on_runs += 1;
|
|
} else {
|
|
r.verify_receipts(addr, track, &hint, &body).await;
|
|
}
|
|
} else {
|
|
assert_eq!(status, 404, "{hint}");
|
|
}
|
|
}
|
|
}
|
|
assert!(served >= 24 && on_runs * 10 >= served * 8, "{served} parts served, {on_runs} of them held on their runs");
|
|
|
|
// Every part of every closed segment against its group's receipts.
|
|
let mut closed = 0;
|
|
for track in ["video/0", "audio/0"] {
|
|
let playlist = until_ok(addr, &format!("{track}/media.m3u8")).await;
|
|
for s in playlist.lines().filter(|l| l.starts_with("s/")) {
|
|
let (status, list, _) = get(addr, &format!("{track}/{s}.parts")).await;
|
|
assert_eq!(status, 200, "{track}/{s}.parts");
|
|
let list: Value = serde_json::from_slice(&list).expect("JSON");
|
|
for p in list["parts"].as_array().expect("parts") {
|
|
let uri = p["uri"].as_str().expect("a URI").trim_start_matches("../");
|
|
let (status, bytes, _) = get(addr, &format!("{track}/{uri}")).await;
|
|
assert_eq!(status, 200, "{track}/{uri}");
|
|
r.verify_receipts(addr, track, uri, &bytes).await;
|
|
closed += 1;
|
|
}
|
|
}
|
|
}
|
|
assert!(closed >= 10, "{closed} parts of closed segments verified against their receipts");
|
|
assert!(gw.withheld().is_empty(), "{:?}", gw.withheld());
|
|
assert!(gw.running_refused().is_empty(), "{:?}", gw.running_refused());
|
|
let trace = gw.trace().expect("a trace");
|
|
let checked = check_trace(&trace).unwrap_or_else(|why| panic!("the gateway's own trace: {why}"));
|
|
assert!(checked.withheld == 0 && checked.running_refused == 0, "{checked:?}");
|
|
|
|
// What the viewer received, held to its stamps.
|
|
reader.abort();
|
|
let got = std::mem::take(&mut *got.lock().unwrap());
|
|
let opened = opened.lock().unwrap().clone();
|
|
assert!(got.len() >= 40, "{} objects received", got.len());
|
|
let t0_ms = dev.svc.state.lock().unwrap().epoch.t0_us / 1000;
|
|
let scale = |track: &str| src.tracks.iter().find(|t| t.kind == track).expect("a track").timescale as u64;
|
|
let mut clock = None;
|
|
let mut by_group: BTreeMap<(&str, u64), Vec<u64>> = BTreeMap::new();
|
|
for o in &got {
|
|
let at = format!("{} {}/{}", o.track, o.group, o.object);
|
|
let (capture, delay, hop) = (o.timing.capture_ms.expect("a CaptureTimestamp"), o.timing.delay_ms.expect("a PlayoutDelay"), o.timing.hop_ms.expect("a HopTime"));
|
|
assert_eq!(delay, PLAYOUT_MS, "{at}: the delay the catalog states");
|
|
assert!(delay <= MAX_DELAY_MS && capture + delay as u64 >= t0_ms, "{at}");
|
|
let media_end = (o.span.0 + o.span.1) * 1000 / scale(o.track);
|
|
let c = capture - media_end;
|
|
assert_eq!(*clock.get_or_insert(c), c, "{at}: one capture clock across the source");
|
|
assert!(hop + 2 >= capture && hop <= o.arrived_ms, "{at}: a HopTime of the relay's clock, {capture} <= {hop} <= {}", o.arrived_ms);
|
|
assert!(o.arrived_ms <= capture + delay as u64, "{at}: arrived {} ms after its instant", o.arrived_ms - capture - delay as u64);
|
|
by_group.entry((o.track, o.group)).or_default().push(o.object);
|
|
}
|
|
// Each group whose stream was read from its start, after the first of each track, is whole
|
|
// and in order (the last may still be open). A track's first group may have been open when
|
|
// the viewer's SUBSCRIBE landed, and is sent from the object then: in order from there.
|
|
let firsts: BTreeMap<&str, u64> = by_group.keys().fold(BTreeMap::new(), |mut m, (t, g)| {
|
|
m.entry(*t).or_insert(*g);
|
|
m
|
|
});
|
|
let mut whole = 0;
|
|
for ((track, g), mut objects) in by_group {
|
|
objects.sort_unstable();
|
|
assert!(opened.contains(&(track, g)));
|
|
let from = if firsts.get(track) == Some(&g) { objects[0] } else { 0 };
|
|
assert_eq!(objects, (from..from + objects.len() as u64).collect::<Vec<_>>(), "{track} {g}: its objects in order from {from}");
|
|
whole += 1;
|
|
}
|
|
assert!(whole >= 4, "{whole} groups read");
|
|
door_passed_on_runs(&dev);
|
|
|
|
drop(gw);
|
|
drop(subscriptions);
|
|
c.close(0, "done");
|
|
dev.stop().await;
|
|
}
|
|
|
|
/// The host's one-minute load for each processor, where the system states it.
|
|
fn load_per_core() -> Option<f64> {
|
|
let one: f64 = match std::fs::read_to_string("/proc/loadavg") {
|
|
Ok(s) => s.split_whitespace().next()?.parse().ok()?,
|
|
Err(_) => {
|
|
let out = std::process::Command::new("sysctl").args(["-n", "vm.loadavg"]).output().ok()?;
|
|
String::from_utf8_lossy(&out.stdout).trim().trim_start_matches('{').split_whitespace().next()?.parse().ok()?
|
|
}
|
|
};
|
|
Some(one / std::thread::available_parallelism().ok()?.get() as f64)
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn a_thirty_frame_room_is_served_in_real_time() {
|
|
match load_per_core() {
|
|
Some(l) if l <= 0.25 => {}
|
|
other => {
|
|
println!("not measured: the host's load for each processor is {other:?}, above a quarter; a real-time bound is measured on an idle host only");
|
|
return;
|
|
}
|
|
}
|
|
timeout(Duration::from_secs(120), real_time()).await.expect("in time");
|
|
}
|
|
|
|
async fn real_time() {
|
|
let dev = start().await;
|
|
dev.relay.relay().door().record_holds();
|
|
let (room, owner, _) = publishing(&dev).await;
|
|
let src = dev.svc.source.clone().expect("a source");
|
|
assert_eq!(src.tracks[0].framerate, 30);
|
|
let r = Room { room, owner };
|
|
let gw = gateway(&dev, &r).await;
|
|
let addr = gw.addr;
|
|
let began = Instant::now();
|
|
let mut tasks = Vec::new();
|
|
for track in ["video/0", "audio/0"] {
|
|
tasks.push(tokio::spawn(async move {
|
|
// Each part this client was served live, with when its last byte came.
|
|
let mut served: Vec<((u64, u64), u64)> = Vec::new();
|
|
let mut playlist = until_ok(addr, &format!("{track}/media.m3u8")).await;
|
|
while began.elapsed() < Duration::from_secs(30) {
|
|
// As a client does: a part the playlist lists that it has not fetched yet (one
|
|
// whose hint it never had, at a group's start) it fetches now.
|
|
if let Some(&(last, _)) = served.last() {
|
|
for uri in listed_parts(&playlist) {
|
|
let Some(at) = try_group_object(&uri) else { continue };
|
|
if at > last && served.iter().all(|(x, _)| *x != at) {
|
|
let (status, _, done) = get(addr, &format!("{track}/{uri}")).await;
|
|
assert_eq!(status, 200, "{track}/{uri}: listed");
|
|
served.push((at, done));
|
|
}
|
|
}
|
|
}
|
|
let (msn, parts, hint) = edge(&playlist);
|
|
let hint = hint.expect("a hinted part");
|
|
let reload = format!("{track}/media.m3u8?_HLS_msn={msn}&_HLS_part={parts}");
|
|
let part = format!("{track}/{hint}");
|
|
let ((status, _, done), (rs, next, _)) = tokio::join!(get(addr, &part), get(addr, &reload));
|
|
assert_eq!(rs, 200, "{reload}");
|
|
playlist = String::from_utf8(next).expect("text");
|
|
if status == 200 && served.last().is_none_or(|(x, _)| *x < group_object(&hint)) {
|
|
served.push((group_object(&hint), done));
|
|
}
|
|
}
|
|
(track, served)
|
|
}));
|
|
}
|
|
let mut late = Vec::new();
|
|
for t in tasks {
|
|
let (track, served) = t.await.expect("a follower");
|
|
assert!(served.len() >= 250, "{track}: {} parts served in thirty seconds", served.len());
|
|
let sent = src.sent.lock().unwrap().clone();
|
|
// Every part from the first served to the last, in the order the source sent them.
|
|
let mut order: Vec<(u64, u64)> = sent.keys().filter(|(t, _, _)| t == track).map(|(_, g, o)| (*g, *o)).collect();
|
|
order.sort_unstable();
|
|
let (first, last) = (served[0].0, served[served.len() - 1].0);
|
|
let owed: Vec<(u64, u64)> = order.into_iter().filter(|x| *x >= first && *x <= last).collect();
|
|
let got: Vec<(u64, u64)> = served.iter().map(|x| x.0).collect();
|
|
assert_eq!(got, owed, "{track}: every part, none missing");
|
|
for ((g, o), done) in served {
|
|
let (capture, _) = sent[&(track.to_string(), g, o)];
|
|
late.push(done.saturating_sub(capture));
|
|
assert!(done <= capture + BOUND_MS, "{track} {g}/{o}: served {} ms after its capture stamp", done - capture);
|
|
}
|
|
}
|
|
assert!(gw.withheld().is_empty(), "{:?}", gw.withheld());
|
|
late.sort_unstable();
|
|
println!(
|
|
"measured on this host, this run, over loopback, capture stamp to served: n={} median={} ms p95={} ms max={} ms (bound {BOUND_MS} ms)",
|
|
late.len(),
|
|
late[late.len() / 2],
|
|
late[late.len() * 95 / 100],
|
|
late[late.len() - 1]
|
|
);
|
|
// The door's hold of each object, against when its group's receipts came after it.
|
|
let holds = door_passed_on_runs(&dev);
|
|
let mut held: Vec<u128> = holds.iter().map(|h| h.held.as_micros()).collect();
|
|
let mut without: Vec<u128> = holds.iter().filter_map(|h| h.receipts_after).map(|d| d.as_micros()).collect();
|
|
held.sort_unstable();
|
|
without.sort_unstable();
|
|
assert!(without.len() * 10 >= holds.len() * 9, "{} of {} objects' group receipts came", without.len(), holds.len());
|
|
let q = |v: &[u128], p: usize| v[(v.len() * p / 100).min(v.len() - 1)];
|
|
println!(
|
|
"measured on this host, this run, the door's hold of an object (arrival at the door to passed): on its run n={} median={} us p95={} us max={} us; until its group's receipts (a door that reads no running receipt) n={} median={} ms p95={} ms max={} ms",
|
|
held.len(),
|
|
q(&held, 50),
|
|
q(&held, 95),
|
|
held[held.len() - 1],
|
|
without.len(),
|
|
q(&without, 50) / 1000,
|
|
q(&without, 95) / 1000,
|
|
without[without.len() - 1] / 1000
|
|
);
|
|
drop(gw);
|
|
dev.stop().await;
|
|
}
|