All checks were successful
joule-moq-room room service / joule-moq-room · does this change touch it (pull_request) Successful in 15s
joule-moq-web browser client / joule-moq-web · does this change touch it (pull_request) Successful in 13s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 3m27s
joule-code conformance gate / joule-moq-ingest, every target (pull_request) Successful in 1m16s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 5s
joule-code conformance gate / joule-moq-relay, every target (pull_request) Successful in 1m32s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 47s
joule-moq-room room service / joule-moq-room, every target (pull_request) Successful in 58s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (pull_request) Successful in 5m52s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 3m23s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 7m35s
The relay sends its SETUP first and reads the client's after it; a client that closed the moment it had the relay's SETUP could close before the relay read its own, a session never served, with no summary. Under load (CI) that race lost the summary about one run in five. Co-Authored-By: Claude Opus 5.5 <[email protected]>
123 lines
6.7 KiB
Rust
123 lines
6.7 KiB
Rust
//! A session over WebTransport is closed by closing the WebTransport session
|
|
//! (`draft-ietf-moq-transport-22` §6.6): the MOQT session termination code and the reason
|
|
//! ride a WT_CLOSE_SESSION capsule on the CONNECT stream (`draft-ietf-webtrans-http3-16`
|
|
//! §6), and the other end reads both exactly, as it does over the WebSocket binding. A QUIC
|
|
//! connection close alone carries an HTTP/3 error code, which no end reads as MOQT's.
|
|
//!
|
|
//! The relay closes a session four ways here: refusing a room grant presented without its
|
|
//! proof, after SETUP; refusing a stream that breaks a rule before SETUP, which it stops only
|
|
//! after the session's close; refusing a session past its session count, before any MOQT
|
|
//! message; and ending an established session at its time limit. And a client closes one,
|
|
//! whose code the relay's signed summary records as the peer's.
|
|
|
|
mod common;
|
|
|
|
use common::*;
|
|
use ed25519_dalek::SigningKey;
|
|
use joule_moq::draft22::{SessionError, Token};
|
|
use joule_moq_relay::client::ClientOptions;
|
|
use joule_moq_relay::config::Grant;
|
|
use joule_moq_relay::transport::{webtransport, Connection, TransportError};
|
|
use joule_moq_relay::RelayServer;
|
|
use std::time::Duration;
|
|
use tokio::time::timeout;
|
|
|
|
/// A record shaped as a room grant (`WRG1`, version 1, a room, the principal), which is
|
|
/// all the relay reads of it: its rule names the record by its SHA-256.
|
|
fn grant_record(room: u8, principal: &SigningKey) -> Vec<u8> {
|
|
let mut g = b"WRG1\x01".to_vec();
|
|
g.extend_from_slice(&[room; 32]);
|
|
g.extend_from_slice(&principal.verifying_key().to_bytes());
|
|
g.extend_from_slice(&[0x5a; 160]);
|
|
g.extend_from_slice(&[0x77; 64]);
|
|
g
|
|
}
|
|
|
|
async fn closed(conn: &dyn Connection, what: &str) -> TransportError {
|
|
timeout(STEP, conn.closed()).await.unwrap_or_else(|_| panic!("{what}: not closed in time"))
|
|
}
|
|
|
|
fn by_relay(code: SessionError, reason: &str) -> TransportError {
|
|
TransportError::Closed { code: code.0, reason: reason.into(), by_peer: true }
|
|
}
|
|
|
|
/// A WebTransport session to `server` with no MOQT on it yet.
|
|
async fn session(server: &RelayServer) -> webtransport::WtConnection {
|
|
let url = format!("https://127.0.0.1:{}{}", server.quic_addr.unwrap().port(), server.relay().config().path);
|
|
timeout(STEP, webtransport::connect(&url, Some(server.identity.leaf_sha256), loopback()))
|
|
.await
|
|
.expect("in time")
|
|
.expect("a WebTransport session")
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn a_session_over_webtransport_closes_with_its_code_and_reason() {
|
|
timeout(Duration::from_secs(120), run()).await.expect("the test finishes in time");
|
|
}
|
|
|
|
async fn run() {
|
|
// A room grant presented as a bare token: the relay refuses the session after SETUP,
|
|
// UNAUTHORIZED (WAI live-rooms §10.5.2).
|
|
let host = SigningKey::from_bytes(&[0x51; 32]);
|
|
let record = grant_record(3, &host);
|
|
let hash = hex::encode(joule_moq_relay::auth::sha256(&record));
|
|
let rooms = relay(|c| {
|
|
c.auth.required = true;
|
|
c.auth.grants = vec![Grant { prefix: vec!["room".into()], token_sha256: hash, publish: true, subscribe: true, proof: true, unlimited: false, seek: false }];
|
|
})
|
|
.await;
|
|
let bare = ClientOptions { tokens: vec![Token::UseValue { token_type: 0, value: record }], ..ClientOptions::default() };
|
|
let (c, _) = connect(&rooms, Via::WebTransport, bare).await;
|
|
assert_eq!(closed(c.conn.as_ref(), "a grant with no proof").await, by_relay(SessionError::UNAUTHORIZED, "live-rooms §10.5: pop_missing"));
|
|
|
|
// A stream that breaks a rule before SETUP: refused, PROTOCOL_VIOLATION. The relay ends
|
|
// that stream only after the session's close, so the client reads the close and never
|
|
// a STOP_SENDING ahead of it.
|
|
let raw = session(&rooms).await;
|
|
let mut s = timeout(STEP, raw.open_uni()).await.expect("in time").expect("a stream");
|
|
s.write_all(&[0x3f; 4]).await.expect("written");
|
|
match closed(&raw, "a stream that breaks a rule").await {
|
|
TransportError::Closed { code, reason, by_peer: true } if code == SessionError::PROTOCOL_VIOLATION.0 => assert!(reason.contains("invalid flags"), "{reason}"),
|
|
other => panic!("a stream that breaks a rule: {other:?}"),
|
|
}
|
|
match timeout(STEP, s.delivered()).await.expect("in time") {
|
|
Err(TransportError::Closed { .. }) => {}
|
|
other => panic!("the relay stopped the stream ahead of the session's close: {other:?}"),
|
|
}
|
|
|
|
// A client closes its session with a code of its own: the relay reads it as the peer's,
|
|
// and its summary records it; the client's own end reads its close as local.
|
|
let (c, _) = connect(&rooms, Via::WebTransport, ClientOptions::default()).await;
|
|
// The client has its handshake once the relay's SETUP arrives; the relay reads the
|
|
// client's SETUP after sending its own, and a session closed before then was never served
|
|
// and gets no summary. So the close waits until the relay holds the session.
|
|
assert!(until(STEP, || rooms.relay().session_diagnostics().len() == 1).await, "the relay holds the session");
|
|
c.close(SessionError::PROTOCOL_VIOLATION.0, "a client's own close");
|
|
assert_eq!(
|
|
closed(c.conn.as_ref(), "the client's close").await,
|
|
TransportError::Closed { code: 0, reason: "closed by this endpoint".into(), by_peer: false }
|
|
);
|
|
let recorded = until(STEP, || {
|
|
rooms.relay().summaries().iter().any(|s| s.summary.transport == "webtransport" && s.summary.close_code == Some(SessionError::PROTOCOL_VIOLATION.0))
|
|
})
|
|
.await;
|
|
let codes: Vec<_> = rooms.relay().summaries().iter().map(|s| (s.summary.transport.clone(), s.summary.close_code)).collect();
|
|
assert!(recorded, "the relay records the client's code: {codes:?}");
|
|
rooms.shutdown().await;
|
|
|
|
// A relay that holds one session at a time, each for three seconds at most.
|
|
let limited = relay(|c| {
|
|
c.limits.max_sessions = 1;
|
|
c.limits.max_session_ms = 3_000;
|
|
})
|
|
.await;
|
|
let (held, _) = connect(&limited, Via::WebTransport, ClientOptions::default()).await;
|
|
assert!(until(STEP, || limited.relay().session_diagnostics().len() == 1).await, "the first session is held");
|
|
// Past its session count: refused before any MOQT message, INTERNAL_ERROR.
|
|
let refused = session(&limited).await;
|
|
assert_eq!(closed(&refused, "a session past the count").await, by_relay(SessionError::INTERNAL_ERROR, "the relay is at its session limit"));
|
|
// At its time limit, an established session is ended with NO_ERROR and the reason, which
|
|
// a FIN on the CONNECT stream alone (code 0, no reason) would not carry.
|
|
assert_eq!(closed(held.conn.as_ref(), "a session at its time limit").await, by_relay(SessionError::NO_ERROR, "the relay's session limit of 3 s"));
|
|
limited.shutdown().await;
|
|
}
|