diff --git a/Cargo.lock b/Cargo.lock index 94a17f3bda..5aca0d7c55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4816,7 +4816,6 @@ dependencies = [ "moq-uring", "procfs 0.18.0", "rand 0.10.2", - "rustls", "serde", "serde_json", "tokio", @@ -4865,6 +4864,7 @@ dependencies = [ "axum-server", "bytes", "hang", + "humantime", "moq-audio", "moq-auth", "moq-hls", @@ -4881,6 +4881,7 @@ dependencies = [ "reqwest", "rustls", "sd-notify", + "serde", "serde_json", "tempfile", "tokio", @@ -5122,6 +5123,7 @@ dependencies = [ "bytesize", "futures", "http-cache-reqwest", + "humantime", "libc", "moq-auth", "moq-net", diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index f30a5269ce..9747c7794c 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -12108,7 +12108,7 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqrequest_query() != 23842) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqrequest_reject() != 57471) { + if (uniffi_moq_ffi_checksum_method_moqrequest_reject() != 2829) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqrequest_set_consume() != 45399) { diff --git a/go/wrapper/server.go b/go/wrapper/server.go index 3a4f25da62..f7b83d7cc3 100644 --- a/go/wrapper/server.go +++ b/go/wrapper/server.go @@ -76,7 +76,7 @@ func (r *Request) Accept(ctx context.Context) (*Session, error) { return &Session{inner: inner}, nil } -// Reject refuses the session with an HTTP status code (default convention: 404). +// Reject refuses the session with an application error code; 401 and 403 map to unauthorized. func (r *Request) Reject(ctx context.Context, code uint16) error { return runErr(ctx, r.inner.Cancel, func(ctx context.Context) error { return r.inner.Reject(ctx, code) diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index c3fcfe5fb1..d4638dc196 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -133,7 +133,7 @@ client = moq.Client( - `.url`, `.path`, `.query`, `.transport`. The query-free path is uniform across transports; the root or missing path is `""`. The encoded query may contain credentials. - `.set_publish(origin)`, `.set_consume(origin)`. Per-request overrides, captured at `accept()`. Raise if the request is already answered, cancelled, or currently accepting. - `await .accept() → Session`. Complete the handshake (hold the result to keep the connection alive). - - `await .reject(code)`. Reject with an HTTP status code. + - `await .reject(code)`. Reject with an application error code; 401 and 403 map to unauthorized. - `.cancel()`. Cancel an in-flight `accept()`/`reject()` call. - **`Session`**. An established connection. Holding it keeps the connection alive; it is also an `async with` context manager that shuts down on exit. - `await .closed()`. Wait until the session closes. diff --git a/py/moq-rs/moq/server.py b/py/moq-rs/moq/server.py index c7294f73f9..3d9687d6b8 100644 --- a/py/moq-rs/moq/server.py +++ b/py/moq-rs/moq/server.py @@ -20,10 +20,10 @@ class Request: """Wraps MoqRequest, an incoming session that can be accepted or rejected. Use `await request.accept()` to complete the handshake, or - `await request.reject(code)` to reject with an HTTP status code. + `await request.reject(code)` to reject with an application error code. Dropping a Request without responding closes the underlying connection - silently; call `reject(code)` to send an explicit HTTP status. + silently; call `reject(code)` to send an explicit MoQ error. """ def __init__(self, inner: MoqRequest) -> None: @@ -75,7 +75,9 @@ async def accept(self) -> Session: return Session(await self._inner.accept()) async def reject(self, code: int) -> None: - """Reject the session with the given HTTP status code. + """Reject the session with the given application error code. + + Codes 401 and 403 map to the protocol's unauthorized error. Raises `Error.AlreadyResponded` if `accept()` or `reject()` has already been called. diff --git a/quest/m1/README.md b/quest/m1/README.md index 55fb0775fa..55a715c516 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -32,7 +32,6 @@ the transport line in m2 assumes a single stack. - [Bindings announce match](/quest/m1/api-origin-scopes.md) - every binding takes a pattern scope and reports the announce match with its captures - [PathPrefixes](/quest/m1/api-path-prefixes.md) - the unused moq_net::PathPrefixes type is deleted before the release - [Route cost](/quest/m1/api-route-cost.md) - `Route::with_hop` and `Cost: From<(u64, u64)>` go; ffi and libmoq build `Hops` and `Cost::from_warm_cold` -- [moq-tokio shapes](/quest/m1/api-tokio-shapes.md) - a `Drop` on `Listener`, a worker `Member` that cannot be cross-wired, `std::time::Duration` fields, one construction idiom, no six-argument merge - [Catalog types](/quest/m1/api-hang-catalog.md) - `hang::Catalog` is the one section list, `Clock` holds a `Timestamp`, `Timeline` folds into `Archive` - [Rendition ownership](/quest/m1/api-mux-rendition.md) - one handle publishes a media track and reports its estimate, instead of five - [Gateway types](/quest/m1/api-gateways.md) - no `anyhow` in a gateway `Error`, `PathOwned` prefixes, `Duration` segments, `moq_rtc::Server::new(config)`, an SRT reject with a reason diff --git a/quest/m1/api-review-gate.md b/quest/m1/api-review-gate.md index fdc40a3872..1374eab9e3 100644 --- a/quest/m1/api-review-gate.md +++ b/quest/m1/api-review-gate.md @@ -19,7 +19,6 @@ quest is deleted too. No code. The list: [Announce event](/quest/m1/api-net-announce.md), [Origin scoping](/quest/m1/api-net-origin.md), [Route cost](/quest/m1/api-route-cost.md), -[moq-tokio shapes](/quest/m1/api-tokio-shapes.md), [Catalog types](/quest/m1/api-hang-catalog.md), [Rendition ownership](/quest/m1/api-mux-rendition.md), [Gateway types](/quest/m1/api-gateways.md), diff --git a/quest/m1/api-tokio-shapes.md b/quest/m1/api-tokio-shapes.md deleted file mode 100644 index 97d90f2f5b..0000000000 --- a/quest/m1/api-tokio-shapes.md +++ /dev/null @@ -1,62 +0,0 @@ -# [M] moq-tokio types make the wrong call impossible - -## Goal - -moq-tokio's first release under this name has shapes the type system -enforces, not doc comments that warn: a listener that closes gracefully when -dropped, a worker whose server and spawner cannot be cross-wired, config -fields typed in `std::time::Duration`, one construction idiom, and no -callback or six-argument merge. The crate is new on main (moq-native is a -tombstone), so every break here is free. - -## Plan - -- `impl Drop for Listener` does the synchronous half of `shutdown()` - (`endpoint.close()`); `async fn close(self)` stays as the opt-in that waits - the grace period. Today `rs/moq-tokio/src/server.rs` has no `Drop`, so an - early return skips the graceful close entirely. -- `worker::Group::members()` returns `Vec>` with - `Member::serve(self, make)` consuming the pair; today it returns - `(Server, Spawner)` tuples and `Spawner::serve(server, make)` accepts any - server, so worker 1's driver on worker 0's thread compiles. `Workers::bind` - takes `server::Config` plus `worker::Config`, not three configs. -- `cli::merge` (five arguments) becomes a `cli::Merge` struct with - `apply(self, parsed)`. The keep closure and `keep_parse_only` methods are - already gone. `pub use usage;` with the same one-line doc `notify` carries, - since `merge` and `answer` take and return `usage` types. -- Every config duration field is `std::time::Duration`; the humantime - newtype stays private to parsing. Embedders write `Duration::ZERO.into()` - today (moq-gst, moq.pro). Then delete `Backoff::{initial, multiplier, max, - timeout}` and `connection::Goaway::redirect` (accessors that duplicate the - field or have no caller) and fold `handover(Option)` into a - `Resolved`. -- One construction idiom: `connect::Config` and `listen::Config` are - `#[non_exhaustive]` with public fields (no struct literal), while - `client::Config` and `server::Config` add `with_*` builders. Drop the - builders and document `Default` plus assignment once. `Server`'s - `with_websocket/with_iroh/with_publisher/with_subscriber/with_stats` become - `server::Config` fields. -- `listen::Config::bind: Option` becomes a `listen::Bind` enum - (`Addr(SocketAddr)` or `Host(String, u16)`) so `fly-global-services:443` - fails at load, not at bind; `lb_id` plus `lb_nonce` become one - `Option`, deleting `LbNonceWithoutId`. -- `Request::close(self, code: u16)` becomes `Request::reject(self, Reject)` - with `Reject::{Unauthorized, Forbidden, App(u16)}`; the verb collides with - `Listener::close` and the u16 is decoded back into an enum inside. -- Cosmetic, in the same pass: `moq_tokio::Deprecated` moves to - `cli::Deprecated`; `websocket::Listener::bind_with_alpns` (no external - caller) matches `tcp`/`unix` with `with_protocols`; `failover::Failure` - becomes crate-private or is renamed so it stops reading as - `accept::Failure`; `moq_tokio::Transport` and `Request` live under - `server::`. The adapter types are already `transport::{Session, SendStream, - RecvStream}`. -- `moq_tokio::crypto::install_default()` so embedders stop copying the - aws-lc-rs provider boilerplate (moq.pro has it in three binaries). - -Public API: breaking on moq-tokio, so on dev. Wire: none. Consumers: -moq-relay, moq-cli, moq-ffi, moq-gst, libmoq tests, moq.pro's edge. - -## Related - -- [Merge dev](/quest/m1/merge-dev.md) - the release that follows is moq-tokio's first -- [Relay embedding](/quest/m2/relay-embed.md) - the embedder surface that builds on these configs diff --git a/quest/m2/relay-embed.md b/quest/m2/relay-embed.md index 4d2d33197f..504c3e0fd5 100644 --- a/quest/m2/relay-embed.md +++ b/quest/m2/relay-embed.md @@ -19,8 +19,7 @@ All additive on `moq-relay` and `moq-tokio`, so on main after the merge: - `Config::parse_and_merge` and the `settings` registry become public (`moq_relay::settings()` plus a `merge_into` that composes an embedder's registry), so the edge's `config.rs` merge and its six clone-and-restore - fields go. This waits on the `cli::Merge` shape from - [moq-tokio shapes](/quest/m1/api-tokio-shapes.md). + fields go. - `auth::Config::public_grant()` and `is_empty()` are public; the edge `mem::take`s the two pattern lists to rebuild the union. - `Relay::with_listeners(self, impl IntoIterator)` so @@ -53,6 +52,5 @@ Public API: additive. Wire: none. ## Related -- [moq-tokio shapes](/quest/m1/api-tokio-shapes.md) - the merge shape this reuses - [Auth embedder](/quest/m2/auth-embedder.md) - the admission half of the same surface - [Server close](/quest/m2/moq-server-close.md) - the listener lifetime that sits beside `with_listeners` diff --git a/rs/libmoq/src/api.rs b/rs/libmoq/src/api.rs index a57c661ffd..c599959ba9 100644 --- a/rs/libmoq/src/api.rs +++ b/rs/libmoq/src/api.rs @@ -1101,13 +1101,13 @@ pub extern "C" fn moq_client_defaults() -> moq_client_config { dst.websocket_delay_ms = millis(websocket.delay); dst.has_websocket_delay = true; - dst.backoff_initial_us = micros(config.connect.backoff.initial()); + dst.backoff_initial_us = micros(config.connect.backoff.initial); dst.has_backoff_initial = true; - dst.backoff_multiplier = config.connect.backoff.multiplier(); + dst.backoff_multiplier = config.connect.backoff.multiplier; dst.has_backoff_multiplier = true; - dst.backoff_max_us = micros(config.connect.backoff.max()); + dst.backoff_max_us = micros(config.connect.backoff.max); dst.has_backoff_max = true; - dst.backoff_timeout_us = micros(config.connect.backoff.timeout()); + dst.backoff_timeout_us = micros(config.connect.backoff.timeout); dst.has_backoff_timeout = true; let quic = config.quic.resolve(); diff --git a/rs/libmoq/src/client.rs b/rs/libmoq/src/client.rs index 160b9a0954..5945822a0c 100644 --- a/rs/libmoq/src/client.rs +++ b/rs/libmoq/src/client.rs @@ -53,19 +53,19 @@ pub unsafe fn parse_client(config: Option<&moq_client_config>) -> Result) -> Result) -> Result crate::client::Config { fn a_null_config_dials_with_the_defaults() { let defaults = crate::client::Config::default(); let parsed = unsafe { crate::parse_client(None) }.expect("NULL is the defaults"); - assert_eq!(parsed.connect.backoff.initial(), defaults.connect.backoff.initial()); + assert_eq!(parsed.connect.backoff.initial, defaults.connect.backoff.initial); assert_eq!( parsed.connect.websocket.resolve().enabled, defaults.connect.websocket.resolve().enabled @@ -3989,13 +3989,10 @@ fn a_zeroed_config_is_the_defaults() { let defaults = crate::client::Config::default(); let parsed = parsed(&client_config()); - assert_eq!(parsed.connect.backoff.initial(), defaults.connect.backoff.initial()); - assert_eq!( - parsed.connect.backoff.multiplier(), - defaults.connect.backoff.multiplier() - ); - assert_eq!(parsed.connect.backoff.max(), defaults.connect.backoff.max()); - assert_eq!(parsed.connect.backoff.timeout(), defaults.connect.backoff.timeout()); + assert_eq!(parsed.connect.backoff.initial, defaults.connect.backoff.initial); + assert_eq!(parsed.connect.backoff.multiplier, defaults.connect.backoff.multiplier); + assert_eq!(parsed.connect.backoff.max, defaults.connect.backoff.max); + assert_eq!(parsed.connect.backoff.timeout, defaults.connect.backoff.timeout); assert_eq!( parsed.connect.websocket.resolve().enabled, defaults.connect.websocket.resolve().enabled @@ -4034,16 +4031,16 @@ fn defaults_report_what_a_zeroed_config_dials() { assert!(config.has_backoff_initial); assert_eq!( config.backoff_initial_us, - expected.connect.backoff.initial().as_micros() as u64 + expected.connect.backoff.initial.as_micros() as u64 ); assert!(config.has_backoff_multiplier); - assert_eq!(config.backoff_multiplier, expected.connect.backoff.multiplier()); + assert_eq!(config.backoff_multiplier, expected.connect.backoff.multiplier); assert!(config.has_backoff_max); - assert_eq!(config.backoff_max_us, expected.connect.backoff.max().as_micros() as u64); + assert_eq!(config.backoff_max_us, expected.connect.backoff.max.as_micros() as u64); assert!(config.has_backoff_timeout); assert_eq!( config.backoff_timeout_us, - expected.connect.backoff.timeout().as_micros() as u64 + expected.connect.backoff.timeout.as_micros() as u64 ); let websocket = expected.connect.websocket.resolve(); @@ -4071,7 +4068,7 @@ fn defaults_report_what_a_zeroed_config_dials() { // And what it reports must round-trip: dialing with it is dialing with the defaults. let expected = crate::client::Config::default(); let reparsed = parsed(&config); - assert_eq!(reparsed.connect.backoff.initial(), expected.connect.backoff.initial()); + assert_eq!(reparsed.connect.backoff.initial, expected.connect.backoff.initial); assert_eq!( reparsed.connect.websocket.resolve().enabled, expected.connect.websocket.resolve().enabled @@ -4089,12 +4086,12 @@ fn zero_with_a_flag_set_is_a_real_value() { config.has_quic_keep_alive = true; let explicit = parsed(&config); - assert_eq!(explicit.connect.backoff.timeout(), std::time::Duration::ZERO); + assert_eq!(explicit.connect.backoff.timeout, std::time::Duration::ZERO); assert_eq!(explicit.quic.keep_alive, std::time::Duration::ZERO); // Without the flags the same zeroes mean nothing at all. let defaults = parsed(&client_config()); - assert_ne!(defaults.connect.backoff.timeout(), std::time::Duration::ZERO); + assert_ne!(defaults.connect.backoff.timeout, std::time::Duration::ZERO); assert_eq!(defaults.quic.keep_alive, std::time::Duration::from_secs(5)); } @@ -4221,14 +4218,11 @@ fn config_quic_and_backoff_knobs_apply() { let parsed = parsed(&config); assert_eq!( - parsed.connect.backoff.initial(), + parsed.connect.backoff.initial, std::time::Duration::from_micros(500_000) ); - assert_eq!(parsed.connect.backoff.multiplier(), 3); - assert_eq!( - parsed.connect.backoff.max(), - std::time::Duration::from_micros(10_000_000) - ); + assert_eq!(parsed.connect.backoff.multiplier, 3); + assert_eq!(parsed.connect.backoff.max, std::time::Duration::from_micros(10_000_000)); assert_eq!(parsed.quic.max_streams, Some(4096)); assert_eq!(parsed.quic.idle_timeout, std::time::Duration::from_millis(15_000)); assert_eq!(parsed.quic.gso, Some(false)); diff --git a/rs/moq-bench/Cargo.toml b/rs/moq-bench/Cargo.toml index 5c64393ea1..9d1a71e456 100644 --- a/rs/moq-bench/Cargo.toml +++ b/rs/moq-bench/Cargo.toml @@ -49,7 +49,6 @@ humantime = { workspace = true } moq-net = { workspace = true } moq-tokio = { workspace = true, default-features = false, features = ["aws-lc-rs"] } rand = { workspace = true } -rustls = { version = "0.23", features = ["aws-lc-rs"], default-features = false } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } diff --git a/rs/moq-bench/src/config.rs b/rs/moq-bench/src/config.rs index 9e58df6d97..065b5e3a12 100644 --- a/rs/moq-bench/src/config.rs +++ b/rs/moq-bench/src/config.rs @@ -29,16 +29,16 @@ pub struct Config { /// Spread connection and subscription startup over this duration to avoid a thundering herd. #[usage(long, env = "MOQ_BENCH_STARTUP", default = "10s", setting = "startup")] - pub startup: moq_tokio::cli::Duration, + pub(crate) startup: crate::duration::Duration, /// Stop the benchmark after this duration. Runs until interrupted if unset. #[usage(long, env = "MOQ_BENCH_DURATION", setting = "duration")] #[serde(default, skip_serializing_if = "Option::is_none")] - pub duration: Option, + pub(crate) duration: Option, /// How often to log throughput stats. #[usage(long, env = "MOQ_BENCH_REPORT", default = "1s", setting = "report")] - pub report: moq_tokio::cli::Duration, + pub(crate) report: crate::duration::Duration, /// Number of connections (A) to establish. Rolled once for the whole run. #[usage(long, env = "MOQ_BENCH_CONNECTIONS", default = "1", setting = "connections")] @@ -150,7 +150,7 @@ impl Config { /// Refused in `parse_and_merge`, before anything reads the config: those /// spellings land on hidden fields that nothing honors, so continuing would dial /// with settings the command line never asked for. - fn deprecated(&self) -> moq_tokio::Deprecated { + fn deprecated(&self) -> moq_tokio::cli::Deprecated { let mut deprecated = self.client.deprecated(); deprecated.extend(self.quic.deprecated()); deprecated @@ -203,8 +203,14 @@ impl Config { // drops, so they are collected from the parse and reported with the file's // own released keys in one message. let mut deprecated = config.deprecated(); - let (mut config, resolved) = moq_tokio::cli::merge(Settings::SETTINGS_REGISTRY, config, &cli_layer, &env, file) - .map_err(|err| anyhow::anyhow!("{err}"))?; + let (mut config, resolved) = moq_tokio::cli::Merge { + registry: Settings::SETTINGS_REGISTRY, + cli: &cli_layer, + env: &env, + file, + } + .apply(config) + .map_err(|err| anyhow::anyhow!("{err}"))?; deprecated.extend(config.deprecated()); anyhow::ensure!(deprecated.is_empty(), "{deprecated}"); config.origins = Some(resolved); diff --git a/rs/moq-bench/src/duration.rs b/rs/moq-bench/src/duration.rs new file mode 100644 index 0000000000..037df6d120 --- /dev/null +++ b/rs/moq-bench/src/duration.rs @@ -0,0 +1,54 @@ +//! Human-readable durations used only while parsing the command line. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct Duration(std::time::Duration); + +impl Duration { + pub(crate) const fn into_std(self) -> std::time::Duration { + self.0 + } +} + +impl From for Duration { + fn from(value: std::time::Duration) -> Self { + Self(value) + } +} + +impl FromStr for Duration { + type Err = humantime::DurationError; + + fn from_str(value: &str) -> Result { + humantime::parse_duration(value).map(Self) + } +} + +impl fmt::Display for Duration { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + humantime::format_duration(self.0).fmt(f) + } +} + +impl Serialize for Duration { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Duration { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(serde::de::Error::custom) + } +} diff --git a/rs/moq-bench/src/host.rs b/rs/moq-bench/src/host.rs index 5303949a60..1849d4d3b7 100644 --- a/rs/moq-bench/src/host.rs +++ b/rs/moq-bench/src/host.rs @@ -14,6 +14,8 @@ //! its `/proc` entry. Combine with the load generator's `--output` to compute CPU //! per connection and CPU per message (see the README). +mod duration; + #[cfg(target_os = "linux")] mod linux { use std::collections::HashMap; @@ -61,7 +63,7 @@ mod linux { /// Stop after this duration. Runs until interrupted (or the targets exit) otherwise. #[usage(long)] - pub duration: Option, + duration: Option, /// Write JSON lines to this file instead of stdout. Truncates on start. #[usage(long, value_hint = usage::ValueHint::FilePath, extensions("jsonl", "json"))] @@ -305,7 +307,7 @@ mod linux { anyhow::ensure!(!targets.is_empty(), "all target processes exited"); let Some(delay) = next_delay( args.interval.0, - args.duration.map(moq_tokio::cli::Duration::into_std), + args.duration.map(crate::duration::Duration::into_std), start.elapsed(), ) else { return Ok(()); diff --git a/rs/moq-bench/src/main.rs b/rs/moq-bench/src/main.rs index f9baf99fa0..02111a2073 100644 --- a/rs/moq-bench/src/main.rs +++ b/rs/moq-bench/src/main.rs @@ -1,5 +1,6 @@ mod config; mod connection; +mod duration; mod range; mod stats; @@ -15,11 +16,7 @@ use rand::RngExt; #[tokio::main] async fn main() -> anyhow::Result<()> { - // TODO: It would be nice to remove this and rely on feature flags only. - // However, some dependency is pulling in `ring` and I don't know why, so meh for now. - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .expect("failed to install default crypto provider"); + moq_tokio::crypto::install_default().expect("failed to install default crypto provider"); let config = Config::load()?; anyhow::ensure!( @@ -101,7 +98,7 @@ async fn main() -> anyhow::Result<()> { }); } - let duration = config.duration.map(moq_tokio::cli::Duration::into_std); + let duration = config.duration.map(crate::duration::Duration::into_std); let stop = async move { match duration { Some(d) => tokio::time::sleep(d).await, diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index fe677270c6..31c61205bf 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-cli/Cargo.toml @@ -94,6 +94,7 @@ axum = { workspace = true } axum-server = { workspace = true } bytes = { workspace = true } hang = { workspace = true } +humantime = { workspace = true } moq-audio = { workspace = true, optional = true, features = ["aac"] } moq-auth = { workspace = true, features = ["serve"] } # `server` enables the HTTP egress server for `moq export hls`; the importer is always available. @@ -112,6 +113,7 @@ moq-video = { workspace = true, optional = true } pollster = { workspace = true, optional = true } reqwest = { workspace = true, features = ["rustls", "json"] } rustls = { version = "0.23", features = ["aws-lc-rs"], default-features = false } +serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } tower-http = { workspace = true } diff --git a/rs/moq-cli/src/args.rs b/rs/moq-cli/src/args.rs index aeac8c263e..0e590ef66d 100644 --- a/rs/moq-cli/src/args.rs +++ b/rs/moq-cli/src/args.rs @@ -367,7 +367,7 @@ pub struct MoqSide { impl MoqSide { /// Every released spelling this invocation used, across all three sections. - fn deprecated(&self) -> moq_tokio::Deprecated { + fn deprecated(&self) -> moq_tokio::cli::Deprecated { let mut found = self.client.deprecated(); found.extend(self.quic.deprecated()); found.extend(self.server.deprecated()); @@ -413,7 +413,9 @@ impl MoqSide { pub fn server_config(&self) -> moq_tokio::listen::Config { let mut config = self.server.clone(); if self.lan() { - config.bind.get_or_insert_with(|| "[::]:0".to_string()); + config + .bind + .get_or_insert_with(|| moq_tokio::listen::Bind::Addr("[::]:0".parse().unwrap())); if config.tls.generate.is_empty() && config.tls.cert.is_empty() { config.tls.generate = vec!["moq-cluster-lan".to_string()]; } @@ -594,7 +596,7 @@ impl Command { /// sharp case, because the listener decides whether to serve TLS at all from the /// canonical `cert`/`generate` fields, so a released `--tls-cert` would leave it /// serving plaintext rather than reaching the builder that refuses. - fn deprecated(&self) -> moq_tokio::Deprecated { + fn deprecated(&self) -> moq_tokio::cli::Deprecated { match self { Self::Import(import) => import.deprecated(), Self::Publish(import) => { @@ -608,7 +610,7 @@ impl Command { found.flag("subscribe", None, "export"); found } - _ => moq_tokio::Deprecated::default(), + _ => moq_tokio::cli::Deprecated::default(), } } @@ -689,11 +691,11 @@ pub struct Import { /// memory matters. Media tracks only -- the catalog and timeline are read at the live edge, /// which is retained unconditionally. #[usage(long)] - pub max_age: Option, + pub max_age: Option, /// The released spelling of [`Self::max_age`]. #[usage(long = "latency-max", hide = true)] - latency_max: Option, + latency_max: Option, /// The single source feeding the Origin. #[usage(subcommand)] @@ -701,8 +703,8 @@ pub struct Import { } impl Import { - fn deprecated(&self) -> moq_tokio::Deprecated { - let mut found = moq_tokio::Deprecated::default(); + fn deprecated(&self) -> moq_tokio::cli::Deprecated { + let mut found = moq_tokio::cli::Deprecated::default(); if self.name.is_some() { found.flag("--name", None, "--broadcast"); } @@ -783,8 +785,8 @@ pub struct Export { } impl Export { - fn deprecated(&self) -> moq_tokio::Deprecated { - let mut found = moq_tokio::Deprecated::default(); + fn deprecated(&self) -> moq_tokio::cli::Deprecated { + let mut found = moq_tokio::cli::Deprecated::default(); if self.name.is_some() { found.flag("--name", None, "--broadcast"); } @@ -838,12 +840,12 @@ impl ExportSink { Self::Fmp4(args) => ( SubscribeFormat::Fmp4, args.container.max_age.into_std(), - args.fragment_duration.map(moq_tokio::cli::Duration::into_std), + args.fragment_duration.map(crate::duration::Duration::into_std), ), Self::Mkv(args) => ( SubscribeFormat::Mkv, args.container.max_age.into_std(), - args.fragment_duration.map(moq_tokio::cli::Duration::into_std), + args.fragment_duration.map(crate::duration::Duration::into_std), ), Self::Ts(args) => (SubscribeFormat::Ts, args.max_age.into_std(), None), Self::Flv(args) => (SubscribeFormat::Flv, args.max_age.into_std(), None), @@ -860,16 +862,16 @@ impl ExportSink { pub struct Container { /// How stale a group may get before it is skipped (e.g. `500ms`, `1s`). #[usage(long, default = "500ms")] - pub max_age: moq_tokio::cli::Duration, + pub max_age: crate::duration::Duration, /// The released spelling of [`Self::max_age`]. #[usage(long = "latency-max", hide = true)] - latency_max: Option, + latency_max: Option, } impl Container { - fn deprecated(&self) -> moq_tokio::Deprecated { - let mut found = moq_tokio::Deprecated::default(); + fn deprecated(&self) -> moq_tokio::cli::Deprecated { + let mut found = moq_tokio::cli::Deprecated::default(); if self.latency_max.is_some() { found.flag("--latency-max", None, "--max-age"); } @@ -887,7 +889,7 @@ pub struct Fragmented { /// Cap the output fragment/cluster duration (e.g. `2s`). /// Defaults to publisher groups for fMP4 and video GOPs for MKV. #[usage(long)] - pub fragment_duration: Option, + pub fragment_duration: Option, } #[cfg(test)] @@ -1522,7 +1524,11 @@ mod tests { assert!(cli.moq.validate().is_ok(), "the LAN mesh is a MoQ side on its own"); let server = cli.moq.server_config(); - assert_eq!(server.bind.as_deref(), Some("[::]:0"), "an ephemeral port"); + assert_eq!( + server.bind.as_ref().map(ToString::to_string).as_deref(), + Some("[::]:0"), + "an ephemeral port" + ); assert_eq!(server.tls.generate, ["moq-cluster-lan"], "a generated certificate"); // An explicit listener wins, so the mesh shares one port and certificate @@ -1539,7 +1545,10 @@ mod tests { ]) .expect("parse"); let server = cli.moq.server_config(); - assert_eq!(server.bind.as_deref(), Some("[::]:4443")); + assert_eq!( + server.bind.as_ref().map(ToString::to_string).as_deref(), + Some("[::]:4443") + ); assert_eq!(server.tls.generate, ["localhost"]); // Without the mesh, nothing is filled in. diff --git a/rs/moq-cli/src/auth.rs b/rs/moq-cli/src/auth.rs index 0ba468b12f..6eeced4201 100644 --- a/rs/moq-cli/src/auth.rs +++ b/rs/moq-cli/src/auth.rs @@ -389,11 +389,11 @@ pub struct Serve { /// How often the relay re-checks each grant. #[usage(long, default = "1m")] - revalidate: moq_tokio::cli::Duration, + revalidate: crate::duration::Duration, /// How long a grant with no bound of its own lasts: anonymous sessions, tokens without `exp`, certificates without one. #[usage(long, default = "1d")] - expires: moq_tokio::cli::Duration, + expires: crate::duration::Duration, /// The most live sessions presenting one token. #[usage(long)] diff --git a/rs/moq-cli/src/complete.rs b/rs/moq-cli/src/complete.rs index ef0d4c8593..a405aae513 100644 --- a/rs/moq-cli/src/complete.rs +++ b/rs/moq-cli/src/complete.rs @@ -649,10 +649,10 @@ mod tests { /// point: the completer builds its client from the same flags the invocation would /// have, so a line that can connect completes and one that cannot does not. fn relay(origin: &moq_net::origin::Producer) -> String { - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let _ = moq_tokio::crypto::install_default(); let mut config = moq_tokio::listen::Config::default(); - config.bind = Some("127.0.0.1:0".to_string()); + config.bind = Some("127.0.0.1:0".parse().unwrap()); config.tls.generate = vec!["localhost".to_string()]; let server = config.init(Default::default()).expect("failed to bind listener"); diff --git a/rs/moq-cli/src/duration.rs b/rs/moq-cli/src/duration.rs new file mode 100644 index 0000000000..037df6d120 --- /dev/null +++ b/rs/moq-cli/src/duration.rs @@ -0,0 +1,54 @@ +//! Human-readable durations used only while parsing the command line. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct Duration(std::time::Duration); + +impl Duration { + pub(crate) const fn into_std(self) -> std::time::Duration { + self.0 + } +} + +impl From for Duration { + fn from(value: std::time::Duration) -> Self { + Self(value) + } +} + +impl FromStr for Duration { + type Err = humantime::DurationError; + + fn from_str(value: &str) -> Result { + humantime::parse_duration(value).map(Self) + } +} + +impl fmt::Display for Duration { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + humantime::format_duration(self.0).fmt(f) + } +} + +impl Serialize for Duration { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Duration { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(serde::de::Error::custom) + } +} diff --git a/rs/moq-cli/src/hls.rs b/rs/moq-cli/src/hls.rs index 005758db6f..754d9252f3 100644 --- a/rs/moq-cli/src/hls.rs +++ b/rs/moq-cli/src/hls.rs @@ -33,7 +33,7 @@ pub struct ExportArgs { /// Minimum media listed in each rendition's playlist window. Keep it within the /// relay's group-cache retention, since segments are fetched from there on request. #[usage(long, default = "16s")] - pub window: moq_tokio::cli::Duration, + pub window: crate::duration::Duration, /// Browser CORS policy for the HLS listener. #[usage(flatten)] diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index c4daf132d6..05650d8ef4 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -9,6 +9,7 @@ mod auth; mod complete; #[cfg(feature = "capture")] mod devices; +mod duration; mod hls; mod moq; mod play; @@ -57,13 +58,14 @@ impl Net { } fn server(&self, config: moq_tokio::listen::Config) -> anyhow::Result { - let server = config.init(self.quic.clone())?; + let mut server = moq_tokio::server::Config::default(); + server.listen = config; + server.quic = self.quic.clone(); #[cfg(feature = "iroh")] - let server = match self.iroh.clone() { - Some(iroh) => server.with_iroh(iroh), - None => server, - }; - Ok(server) + { + server.iroh = self.iroh.clone(); + } + Ok(server.init()?) } } @@ -120,7 +122,7 @@ async fn spawn_server( // The certificate endpoint is for clients dialing a URL, so it follows the // explicit listener rather than the mesh's ephemeral one. if let Some(web_bind) = moq.server.bind.clone() { - tasks.spawn(async move { web::run_web(&web_bind, certificates).await }); + tasks.spawn(async move { web::run_web(web_bind, certificates).await }); } Ok(started) @@ -180,7 +182,7 @@ fn spawn_cluster_serve( } if !is_public_transport(request.transport(), public_quic) { tracing::debug!(path = %request.path(), "refusing a non-peer request on the LAN mesh listener"); - request.close(404).await.ok(); + request.reject(moq_tokio::server::Reject::App(404)).await.ok(); continue; } let auth = auth.clone(); @@ -201,7 +203,7 @@ fn spawn_cluster_serve( /// stage's directions prune the side it does not use, so a subscribe-only export /// never announces what a viewer could not have had anyway. async fn serve_client( - request: moq_tokio::Request, + request: moq_tokio::server::Request, auth: &moq_relay::auth::Auth, origin: &moq_net::origin::Producer, directions: Directions, @@ -211,7 +213,12 @@ async fn serve_client( Ok(lease) => lease, Err(err) => { let status = axum::http::StatusCode::from(&err); - request.close(status.as_u16()).await.ok(); + let reject = match status { + axum::http::StatusCode::UNAUTHORIZED => moq_tokio::server::Reject::Unauthorized, + axum::http::StatusCode::FORBIDDEN => moq_tokio::server::Reject::Forbidden, + status => moq_tokio::server::Reject::App(status.as_u16()), + }; + request.reject(reject).await.ok(); return Err(anyhow::Error::new(err).context("session refused")); } }; @@ -228,7 +235,7 @@ async fn serve_client( .then(|| rooted.as_ref().and_then(|o| o.scope(&token.publish))) .flatten(); if publish.is_none() && subscribe.is_none() { - request.close(403).await.ok(); + request.reject(moq_tokio::server::Reject::Forbidden).await.ok(); anyhow::bail!("grant allows nothing this endpoint serves at {}", token.root); } @@ -244,9 +251,9 @@ async fn serve_client( } /// Whether ordinary clients may use this transport on the shared LAN server. -fn is_public_transport(transport: moq_tokio::Transport, public_quic: bool) -> bool { +fn is_public_transport(transport: moq_tokio::server::Transport, public_quic: bool) -> bool { match transport { - moq_tokio::Transport::Tcp | moq_tokio::Transport::Unix => true, + moq_tokio::server::Transport::Tcp | moq_tokio::server::Transport::Unix => true, _ => public_quic, } } @@ -278,11 +285,7 @@ fn spawn_serve( #[tokio::main] async fn main() -> anyhow::Result<()> { - // TODO: It would be nice to remove this and rely on feature flags only. - // However, some dependency is pulling in `ring` and I don't know why, so meh for now. - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .expect("failed to install default crypto provider"); + moq_tokio::crypto::install_default().expect("failed to install default crypto provider"); let mut cli = Invocation::parse().await; cli.log.init()?; @@ -569,7 +572,7 @@ fn spawn_import( reject_listener_cors(&rtc.cors, "import rtc")?; } - let max_age = import.max_age.map(moq_tokio::cli::Duration::into_std); + let max_age = import.max_age.map(crate::duration::Duration::into_std); // The MoQ side every gateway publishes into, minted per source since each takes it // by value onto its own task. let target = |name: String| crate::moq::ImportTarget { @@ -899,7 +902,7 @@ mod tests { /// client TLS the way the relay does rather than refuse after validate. #[tokio::test] async fn cluster_connect_api_http_attaches_client_tls() { - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let _ = moq_tokio::crypto::install_default(); let invocation = Invocation::try_parse_from([ "moq", "--cluster-connect-api", @@ -938,9 +941,9 @@ mod tests { #[test] fn explicit_stream_listeners_are_public_without_exposing_mesh_quic() { - assert!(is_public_transport(moq_tokio::Transport::Tcp, false)); - assert!(is_public_transport(moq_tokio::Transport::Unix, false)); - assert!(!is_public_transport(moq_tokio::Transport::Quic, false)); - assert!(is_public_transport(moq_tokio::Transport::Quic, true)); + assert!(is_public_transport(moq_tokio::server::Transport::Tcp, false)); + assert!(is_public_transport(moq_tokio::server::Transport::Unix, false)); + assert!(!is_public_transport(moq_tokio::server::Transport::Quic, false)); + assert!(is_public_transport(moq_tokio::server::Transport::Quic, true)); } } diff --git a/rs/moq-cli/src/play/args.rs b/rs/moq-cli/src/play/args.rs index 469f6c5bb4..b4094a2246 100644 --- a/rs/moq-cli/src/play/args.rs +++ b/rs/moq-cli/src/play/args.rs @@ -29,7 +29,7 @@ pub struct Args { /// delay, with a 50ms floor under it, so a smaller value than that does not reach the /// picture either. #[usage(long, default = "100ms")] - pub delay: moq_tokio::cli::Duration, + pub delay: crate::duration::Duration, /// Rendition selection by track name or codec. #[usage(flatten)] diff --git a/rs/moq-cli/src/rtmp.rs b/rs/moq-cli/src/rtmp.rs index 40bfa7385d..0cdc0416c7 100644 --- a/rs/moq-cli/src/rtmp.rs +++ b/rs/moq-cli/src/rtmp.rs @@ -40,11 +40,11 @@ pub struct ExportArgs { /// How stale a group may get before it is skipped. RTMP is unpaced, so this /// bounds buffering, not the wire rate. #[usage(long, default = "500ms")] - pub max_age: moq_tokio::cli::Duration, + pub max_age: crate::duration::Duration, /// The released spelling of [`Self::max_age`]. #[usage(long = "latency-max", hide = true)] - pub(crate) latency_max: Option, + pub(crate) latency_max: Option, } /// Accept incoming RTMP publishes into the Origin as `target.name`; reject plays (import). diff --git a/rs/moq-cli/src/srt.rs b/rs/moq-cli/src/srt.rs index eece118a62..01756fa37b 100644 --- a/rs/moq-cli/src/srt.rs +++ b/rs/moq-cli/src/srt.rs @@ -28,7 +28,7 @@ pub struct Args { /// SRT receive latency: the buffering delay traded for loss-recovery headroom. #[usage(long, default = "500ms")] - pub latency: moq_tokio::cli::Duration, + pub latency: crate::duration::Duration, } /// Accept incoming SRT publishes into the Origin as `target.name`; reject requests (import). diff --git a/rs/moq-cli/src/web.rs b/rs/moq-cli/src/web.rs index 903b21d8fa..c9e8d2e1ee 100644 --- a/rs/moq-cli/src/web.rs +++ b/rs/moq-cli/src/web.rs @@ -57,8 +57,8 @@ pub async fn serve( /// Serve the `/certificate.sha256` self-signed fingerprint over HTTP, so an /// `http://` client can pin a `--listen` server's generated cert. -pub async fn run_web(bind: &str, certificates: moq_tokio::tls::Certificates) -> anyhow::Result<()> { - let listen = tokio::net::lookup_host(bind) +pub async fn run_web(bind: moq_tokio::listen::Bind, certificates: moq_tokio::tls::Certificates) -> anyhow::Result<()> { + let listen = tokio::net::lookup_host(bind.to_string()) .await .context("invalid listen address")? .next() diff --git a/rs/moq-ffi/src/server.rs b/rs/moq-ffi/src/server.rs index f7be63beb1..35afc0feda 100644 --- a/rs/moq-ffi/src/server.rs +++ b/rs/moq-ffi/src/server.rs @@ -94,19 +94,11 @@ impl MoqServer { /// Validated syntactically up-front. DNS hostnames are accepted and resolved /// at `listen()` time. Captured at [`listen`](Self::listen); fails afterwards. pub fn set_bind(&self, addr: String) -> Result<(), MoqError> { - // Mirrors `MoqClient::set_bind` by surfacing parse errors here rather - // than at listen() time. The server takes a String (not SocketAddr) so - // DNS hostnames are allowed; we only check syntactic structure here. - if addr.parse::().is_err() { - let port_ok = addr - .rsplit_once(':') - .is_some_and(|(_, port)| port.parse::().is_ok()); - if !port_ok { - return Err(MoqError::Bind(format!("invalid bind address: {addr}"))); - } - } + let bind = addr + .parse() + .map_err(|_| MoqError::Bind(format!("invalid bind address: {addr}")))?; self.configure_listen(|state| { - state.config.bind = Some(addr); + state.config.bind = Some(bind); }) } @@ -194,7 +186,7 @@ impl MoqServer { } struct RequestState { - request: Option, + request: Option, publish: Option>, consume: Option>, } @@ -215,7 +207,7 @@ pub struct MoqRequest { impl MoqRequest { fn new( - request: moq_tokio::Request, + request: moq_tokio::server::Request, publish: Option>, consume: Option>, ) -> Arc { @@ -326,15 +318,23 @@ impl MoqRequest { .await } - /// Reject the session with the given HTTP status code. + /// Reject the established MoQ session with an application error code. + /// + /// Codes 401 and 403 map to the protocol's unauthorized error; every other + /// code is sent as an application error. /// /// Returns `AlreadyResponded` if `accept()` or `reject()` has already been called. pub async fn reject(&self, code: u16) -> Result<(), MoqError> { self.task .run(move |mut state| async move { let request = state.request.take().ok_or(MoqError::AlreadyResponded)?; + let reject = match code { + 401 => moq_tokio::server::Reject::Unauthorized, + 403 => moq_tokio::server::Reject::Forbidden, + code => moq_tokio::server::Reject::App(code), + }; request - .close(code) + .reject(reject) .await .map_err(|err| MoqError::Reject(format!("{err}")))?; Ok(()) diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index 981cb6a86d..750b3871c2 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -540,10 +540,10 @@ impl MoqClient { pub fn set_backoff(&self, backoff: MoqBackoff) -> Result<(), MoqError> { self.configure(|state| { let mut out = moq_tokio::Backoff::default(); - out.initial = std::time::Duration::from_micros(backoff.initial_us).into(); + out.initial = std::time::Duration::from_micros(backoff.initial_us); out.multiplier = backoff.multiplier; - out.max = std::time::Duration::from_micros(backoff.max_us).into(); - out.timeout = std::time::Duration::from_micros(backoff.timeout_us).into(); + out.max = std::time::Duration::from_micros(backoff.max_us); + out.timeout = std::time::Duration::from_micros(backoff.timeout_us); state.config.backoff = out; }) } diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index ff95fa5ed7..155b374e66 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -3087,6 +3087,10 @@ async fn server_set_bind_validates() { assert!(server.set_bind("127.0.0.1:0".into()).is_ok()); assert!(server.set_bind("[::]:443".into()).is_ok()); assert!(server.set_bind("localhost:4443".into()).is_ok()); + assert!(matches!( + server.set_bind("localhost:443:8443".into()), + Err(crate::error::MoqError::Bind(_)) + )); assert!(matches!( server.set_bind("not-an-address".into()), Err(crate::error::MoqError::Bind(_)) diff --git a/rs/moq-gst/src/sink/session.rs b/rs/moq-gst/src/sink/session.rs index 0fbed7ed06..c5acd8b7b9 100644 --- a/rs/moq-gst/src/sink/session.rs +++ b/rs/moq-gst/src/sink/session.rs @@ -146,7 +146,7 @@ pub struct ResolvedSettings { pub(super) fn connect_config(settings: &ResolvedSettings) -> moq_tokio::connect::Config { let mut config = moq_tokio::connect::Config::default(); config.tls.insecure = Some(settings.tls_disable_verify); - config.backoff.timeout = std::time::Duration::ZERO.into(); + config.backoff.timeout = std::time::Duration::ZERO; config } @@ -156,10 +156,10 @@ pub(super) fn quic_config(settings: &ResolvedSettings) -> moq_tokio::quic::Confi // The properties are optional and the config fields are not: an unset property // leaves the library default rather than overriding it with one of its own. if let Some(idle_timeout) = settings.quic_idle_timeout { - config.idle_timeout = idle_timeout.into(); + config.idle_timeout = idle_timeout; } if let Some(keep_alive) = settings.quic_keep_alive { - config.keep_alive = keep_alive.into(); + config.keep_alive = keep_alive; } config } diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index d1891a93b5..d7cc4d9860 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -67,6 +67,7 @@ bytes = { workspace = true } bytesize = "2.4.2" futures = { workspace = true } http-cache-reqwest = { version = "1.0.0-alpha.6", features = ["manager-moka", "reqwest-middleware", "url-standard"], default-features = false } +humantime = { workspace = true } moq-auth = { workspace = true, features = ["client"] } moq-net = { workspace = true } moq-stats = { workspace = true } diff --git a/rs/moq-relay/examples/embed.rs b/rs/moq-relay/examples/embed.rs index 9ab1cb5a4a..d1723206c9 100644 --- a/rs/moq-relay/examples/embed.rs +++ b/rs/moq-relay/examples/embed.rs @@ -25,7 +25,7 @@ async fn main() -> anyhow::Result<()> { .expect("failed to install default crypto provider"); let mut config = Config::default(); - config.listen.bind = Some("127.0.0.1:0".into()); + config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); config.listen.tls.generate = vec!["localhost".into()]; config.web.http.listen = Some("127.0.0.1:0".parse()?); config.auth.public = vec![moq_auth::Pattern::all()]; diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index b057f77ee3..ffbc05b972 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -434,13 +434,13 @@ impl Admission { /// The `moq_auth::Request` for an accepted transport request: every fact the /// transport knows, nothing parsed on the server's behalf. -pub fn request_for(auth: &Auth, request: &moq_tokio::Request) -> Request { +pub fn request_for(auth: &Auth, request: &moq_tokio::server::Request) -> Request { let transport = match request.transport() { - moq_tokio::Transport::Quic => moq_auth::Transport::Quic, - moq_tokio::Transport::Iroh => moq_auth::Transport::Iroh, - moq_tokio::Transport::WebSocket => moq_auth::Transport::WebSocket, - moq_tokio::Transport::Tcp => moq_auth::Transport::Tcp, - moq_tokio::Transport::Unix => moq_auth::Transport::Unix, + moq_tokio::server::Transport::Quic => moq_auth::Transport::Quic, + moq_tokio::server::Transport::Iroh => moq_auth::Transport::Iroh, + moq_tokio::server::Transport::WebSocket => moq_auth::Transport::WebSocket, + moq_tokio::server::Transport::Tcp => moq_auth::Transport::Tcp, + moq_tokio::server::Transport::Unix => moq_auth::Transport::Unix, // A transport this build does not know is still a session on the wire; the // server sees the same facts either way. other => unreachable!("unknown transport {other}"), diff --git a/rs/moq-relay/src/cache.rs b/rs/moq-relay/src/cache.rs index 3f98c84caa..8c476f034e 100644 --- a/rs/moq-relay/src/cache.rs +++ b/rs/moq-relay/src/cache.rs @@ -60,8 +60,13 @@ pub struct Config { /// cadence. The `capacity` budget is the one that depends on writes: a /// publisher that stops writing pays none of it down, so under memory pressure /// it is repaid by the tracks that are still writing. + #[usage(skip)] + #[serde(with = "crate::duration::serde_option")] + pub duration: Option, + #[usage(long = "cache-duration", env = "MOQ_CACHE_DURATION", setting = "cache.duration")] - pub duration: Option, + #[serde(default, rename = "__cli_duration", skip_serializing_if = "Option::is_none")] + duration_arg: Option, } /// The relay's resolved cache settings: the shared byte-budget pool plus the @@ -98,7 +103,10 @@ impl Config { /// point, say) leaves nothing sampling memory behind. pub fn init(&self) -> anyhow::Result { let capacity = self.capacity.as_deref().map(parse_limit).transpose()?; - let duration = self.duration.map(moq_tokio::cli::Duration::into_std); + let duration = self + .duration_arg + .map(crate::duration::Duration::into_std) + .or(self.duration); let config = cache::Config::default() .with_capacity(capacity) .with_expiry(duration.unwrap_or(cache::DEFAULT_EXPIRY)); @@ -209,7 +217,7 @@ mod tests { fn explicit_duration_configures_both_bounds() { let duration = Duration::from_secs(5); let config = Config { - duration: Some(duration.into()), + duration: Some(duration), ..Default::default() }; let cache = config.init().unwrap(); @@ -300,10 +308,8 @@ mod tests { let before = spawned(); // `--cluster-id 0` is rejected, so the cache is dropped before it is attached. - let cluster = crate::cluster::Config { - id: Some(0), - ..Default::default() - }; + let mut cluster = crate::cluster::Config::default(); + cluster.id = Some(0); assert!(attach(&governed(), cluster).is_err(), "cluster id 0 is rejected"); settle().await; diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index b292bce8ee..6504c543c7 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -777,6 +777,10 @@ pub struct Config { pub tier: Option, /// Released spelling, kept so [`Self::deprecated`] can name that linger is gone. #[doc(hidden)] + #[usage(skip)] + #[serde(with = "crate::duration::serde_option")] + pub linger: Option, + #[usage( name = "cluster-linger", long = "cluster-linger", @@ -784,14 +788,15 @@ pub struct Config { setting = "cluster.linger", hide = true )] - pub linger: Option, + #[serde(default, rename = "__cli_linger", skip_serializing_if = "Option::is_none")] + linger_arg: Option, } impl Config { /// Released spellings this config was parsed from, each paired with what replaced it. - pub fn deprecated(&self) -> moq_tokio::Deprecated { - let mut found = moq_tokio::Deprecated::default(); - if self.linger.is_some() { + pub fn deprecated(&self) -> moq_tokio::cli::Deprecated { + let mut found = moq_tokio::cli::Deprecated::default(); + if self.linger.is_some() || self.linger_arg.is_some() { found.changed( "--cluster-linger", Some("MOQ_CLUSTER_LINGER"), @@ -1951,7 +1956,7 @@ impl Cluster { .connect .clone() .context("internal: LAN dial without Cluster::with_connect")?; - connect.backoff.timeout = std::time::Duration::ZERO.into(); + connect.backoff.timeout = std::time::Duration::ZERO; connect.once = Some(false); let mut bind = connect.resolve().bind; bind.set_port(0); @@ -2888,12 +2893,9 @@ mod tests { #[tokio::test] async fn constructed_origin_keeps_cache_and_handles() { let duration = Duration::from_secs(5); - let cache = crate::cache::Config { - duration: Some(duration.into()), - ..Default::default() - } - .init() - .expect("cache"); + let mut cache = crate::cache::Config::default(); + cache.duration = Some(duration); + let cache = cache.init().expect("cache"); let pool = cache.pool.clone(); let cluster = Cluster::new( @@ -3112,7 +3114,7 @@ mod tests { #[test] fn released_cluster_spellings_are_reported_not_applied() { let config = Config { - linger: Some(std::time::Duration::from_secs(5).into()), + linger: Some(std::time::Duration::from_secs(5)), connect: vec![Peer::new("root.example.com:4443")], ..Default::default() }; @@ -3528,7 +3530,7 @@ mod tests { #[tokio::test] async fn lan_cluster_path_carries_broadcasts_both_ways() { const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let _ = moq_tokio::crypto::install_default(); let node = new_cluster(Config::default()).expect("node cluster"); let fingerprint = new_cluster(Config::default()).expect("fingerprint cluster"); @@ -3537,7 +3539,7 @@ mod tests { _from_node.announce(Default::default()).expect("announce"); let mut listen = moq_tokio::listen::Config::default(); - listen.bind = Some("127.0.0.1:0".to_string()); + listen.bind = Some("127.0.0.1:0".parse().unwrap()); listen.tls.generate = vec!["moq-cluster-lan".to_string()]; let server = listen.init(Default::default()).expect("bind"); let port = server.local_addr().expect("local addr").port(); diff --git a/rs/moq-relay/src/config.rs b/rs/moq-relay/src/config.rs index b37e54c744..8b2f2e5e5f 100644 --- a/rs/moq-relay/src/config.rs +++ b/rs/moq-relay/src/config.rs @@ -94,14 +94,18 @@ pub struct Config { /// this long for clients to reconnect elsewhere before force-closing them; a /// second signal exits immediately. Zero closes them at once, with no GOAWAY /// they would have no time to act on. Defaults to 10 seconds. + #[usage(skip)] + #[serde(with = "crate::duration::serde_duration")] + pub drain_timeout: std::time::Duration, + #[usage( name = "drain-timeout", long = "drain-timeout", env = "MOQ_DRAIN_TIMEOUT", - default = "10s", setting = "drain_timeout" )] - pub drain_timeout: moq_tokio::cli::Duration, + #[serde(default, rename = "__cli_drain_timeout", skip_serializing_if = "Option::is_none")] + drain_timeout_arg: Option, /// If provided, load the configuration from this file. #[serde(default)] @@ -136,7 +140,8 @@ impl Default for Config { stats: Default::default(), cache: Default::default(), internal: Default::default(), - drain_timeout: crate::DEFAULT_DRAIN_TIMEOUT.into(), + drain_timeout: crate::DEFAULT_DRAIN_TIMEOUT, + drain_timeout_arg: None, file: None, origins: None, #[cfg(feature = "iroh")] @@ -179,6 +184,13 @@ pub fn spec() -> &'static usage::spec::Spec<'static> { } impl Config { + /// Resolve the shutdown grace period after command-line overrides. + pub(crate) fn drain_timeout(&self) -> std::time::Duration { + self.drain_timeout_arg + .map(crate::duration::Duration::into_std) + .unwrap_or(self.drain_timeout) + } + /// Parses configuration from CLI arguments, optionally merging with a /// TOML file specified via the positional `file` argument. Also initializes /// the logger. @@ -204,7 +216,7 @@ impl Config { /// Merge CLI, environment, then TOML, then declared defaults. /// /// Precedence is CLI > env > file > defaults, declared in - /// [`moq_tokio::cli::merge`]. Presence comes from what the parser and the + /// [`moq_tokio::cli::Merge`]. Presence comes from what the parser and the /// environment actually supplied, so a file that sets a list to empty or a /// bool to false survives. pub(crate) fn parse_and_merge(args: I) -> anyhow::Result @@ -253,13 +265,13 @@ impl Config { // drops, so they are collected from the parse and reported with the file's // own released keys in one message. let mut deprecated = cli.config.deprecated(); - let (mut config, resolved) = moq_tokio::cli::merge( - crate::settings::Settings::SETTINGS_REGISTRY, - cli.config, - &cli_layer, - &env, + let (mut config, resolved) = moq_tokio::cli::Merge { + registry: crate::settings::Settings::SETTINGS_REGISTRY, + cli: &cli_layer, + env: &env, file, - ) + } + .apply(cli.config) .map_err(|err| anyhow::anyhow!("{err}"))?; deprecated.extend(config.deprecated()); anyhow::ensure!(deprecated.is_empty(), "{deprecated}"); @@ -280,7 +292,7 @@ impl Config { impl Config { /// The released spellings in use across every section, each paired with what /// replaced it. - fn deprecated(&self) -> moq_tokio::Deprecated { + fn deprecated(&self) -> moq_tokio::cli::Deprecated { let mut deprecated = self.quic.deprecated(); deprecated.extend(self.listen.deprecated()); deprecated.extend(self.connect.deprecated()); @@ -569,7 +581,7 @@ duration = "30s" assert_eq!(config.cache.headroom.as_deref(), Some("10%")); assert_eq!( config.cache.duration, - Some(std::time::Duration::from_secs(30).into()), + Some(std::time::Duration::from_secs(30)), "TOML's cache.duration must not be clobbered by the CLI re-parse" ); } @@ -580,7 +592,7 @@ duration = "30s" #[test] fn cache_duration_serde_round_trip() { let set: cache::Config = toml::from_str(r#"duration = "30s""#).expect("deserialize Some"); - assert_eq!(set.duration, Some(std::time::Duration::from_secs(30).into())); + assert_eq!(set.duration, Some(std::time::Duration::from_secs(30))); let unset: cache::Config = toml::from_str("").expect("deserialize absent"); assert_eq!(unset.duration, None); @@ -924,7 +936,10 @@ uid = [1001] let args = vec![std::ffi::OsString::from("moq-relay"), std::ffi::OsString::from(&path)]; let config = Config::parse_and_merge(args).expect("config load"); - assert_eq!(config.listen.bind.as_deref(), Some("[::]:443")); + assert_eq!( + config.listen.bind.as_ref().map(ToString::to_string).as_deref(), + Some("[::]:443") + ); assert_eq!( config.listen.unix.bind.as_deref(), Some(std::path::Path::new("/run/moq/internal.sock")), diff --git a/rs/moq-relay/src/connection.rs b/rs/moq-relay/src/connection.rs index 982631707e..a2a147e7ba 100644 --- a/rs/moq-relay/src/connection.rs +++ b/rs/moq-relay/src/connection.rs @@ -1,7 +1,7 @@ use crate::{auth, cluster}; use axum::http; -use moq_tokio::Request; +use moq_tokio::server::Request; /// An error carrying the HTTP status to send when closing the request. /// @@ -83,7 +83,12 @@ impl Connection { let (lease, registration) = match self.admit().await { Ok(admitted) => admitted, Err(err) => { - let _ = self.request.close(err.status.as_u16()).await; + let reject = match err.status { + http::StatusCode::UNAUTHORIZED => moq_tokio::server::Reject::Unauthorized, + http::StatusCode::FORBIDDEN => moq_tokio::server::Reject::Forbidden, + status => moq_tokio::server::Reject::App(status.as_u16()), + }; + let _ = self.request.reject(reject).await; return Err(err.source); } }; @@ -93,7 +98,7 @@ impl Connection { let grants = match authorize(&self.cluster, lease.token(), role, &transport) { Ok(grants) => grants, Err(err) => { - let _ = self.request.close(http::StatusCode::FORBIDDEN.as_u16()).await; + let _ = self.request.reject(moq_tokio::server::Reject::Forbidden).await; return Err(err); } }; diff --git a/rs/moq-relay/src/duration.rs b/rs/moq-relay/src/duration.rs new file mode 100644 index 0000000000..10686d8901 --- /dev/null +++ b/rs/moq-relay/src/duration.rs @@ -0,0 +1,91 @@ +//! Human-readable duration adapters used only at the CLI and serde boundary. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct Duration(std::time::Duration); + +impl Duration { + pub(crate) const fn into_std(self) -> std::time::Duration { + self.0 + } +} + +impl FromStr for Duration { + type Err = humantime::DurationError; + + fn from_str(value: &str) -> Result { + humantime::parse_duration(value).map(Self) + } +} + +impl fmt::Display for Duration { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + humantime::format_duration(self.0).fmt(f) + } +} + +impl Serialize for Duration { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Duration { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(serde::de::Error::custom) + } +} + +pub(crate) mod serde_duration { + use serde::{Deserialize, Deserializer, Serializer}; + + pub(crate) fn serialize(value: &std::time::Duration, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(&humantime::format_duration(*value)) + } + + pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + humantime::parse_duration(&value).map_err(serde::de::Error::custom) + } +} + +pub(crate) mod serde_option { + use serde::{Deserialize, Deserializer, Serializer}; + + pub(crate) fn serialize(value: &Option, serializer: S) -> Result + where + S: Serializer, + { + match value { + Some(value) => serializer.collect_str(&humantime::format_duration(*value)), + None => serializer.serialize_none(), + } + } + + pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let value = Option::::deserialize(deserializer)?; + value + .map(|value| humantime::parse_duration(&value).map_err(serde::de::Error::custom)) + .transpose() + } +} diff --git a/rs/moq-relay/src/lib.rs b/rs/moq-relay/src/lib.rs index 55264b7304..fd1b264720 100644 --- a/rs/moq-relay/src/lib.rs +++ b/rs/moq-relay/src/lib.rs @@ -15,6 +15,7 @@ pub mod cache; pub mod cluster; mod config; mod connection; +mod duration; mod http_client; pub mod internal; mod listener; diff --git a/rs/moq-relay/src/main.rs b/rs/moq-relay/src/main.rs index f2b13c4d07..63c45416da 100644 --- a/rs/moq-relay/src/main.rs +++ b/rs/moq-relay/src/main.rs @@ -6,11 +6,7 @@ static ALLOC: moq_tokio::jemalloc::tikv_jemallocator::Jemalloc = moq_tokio::jema #[tokio::main] async fn main() -> anyhow::Result<()> { - // TODO: It would be nice to remove this and rely on feature flags only. - // However, some dependency is pulling in `ring` and I don't know why, so meh for now. - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .expect("failed to install default crypto provider"); + moq_tokio::crypto::install_default().expect("failed to install default crypto provider"); // The whole startup sequence lives in `Relay::load` rather than here, so an // embedder gets it by calling one function instead of copying this file. diff --git a/rs/moq-relay/src/relay.rs b/rs/moq-relay/src/relay.rs index cf853e5d6d..43cfa3e40a 100644 --- a/rs/moq-relay/src/relay.rs +++ b/rs/moq-relay/src/relay.rs @@ -90,6 +90,7 @@ impl Relay { /// it. pub async fn load(mut config: Config) -> anyhow::Result { config.resolve()?; + let drain_timeout = config.drain_timeout(); // The name this relay reports in every auth request: the stats node label, // else the cluster node URL, else nothing. let node = config @@ -140,10 +141,12 @@ impl Relay { #[cfg(feature = "_quic")] let workers = match config.runtime.workers() { - Some(worker) if !io_uring => Some( - moq_tokio::worker::Workers::bind(config.listen.clone(), config.quic.clone(), worker) - .context("failed to start the QUIC workers")?, - ), + Some(worker) if !io_uring => { + let mut server = moq_tokio::server::Config::default(); + server.listen = config.listen.clone(); + server.quic = config.quic.clone(); + Some(moq_tokio::worker::Workers::bind(server, worker).context("failed to start the QUIC workers")?) + } _ => None, }; @@ -172,10 +175,20 @@ impl Relay { #[cfg(not(all(target_os = "linux", feature = "_uring")))] let quic_owned_elsewhere = workers_addr.is_some(); + #[cfg(feature = "iroh")] + let iroh = config.iroh.bind(&config.quic).await?; + #[allow(unused_mut)] - let mut server = match quic_owned_elsewhere { - true => config.listen.clone().init_streams()?, - false => config.listen.clone().init(config.quic.clone())?, + let mut server_config = moq_tokio::server::Config::default(); + server_config.listen = config.listen.clone(); + server_config.quic = config.quic.clone(); + #[cfg(feature = "iroh")] + { + server_config.iroh = iroh.clone(); + } + let server = match quic_owned_elsewhere { + true => server_config.init_streams()?, + false => server_config.init()?, }; let client = config.connect.clone().init(config.quic.clone())?; @@ -195,9 +208,9 @@ impl Relay { }; #[cfg(feature = "iroh")] - let (server, client) = match config.iroh.bind(&config.quic).await? { - Some(iroh) => (server.with_iroh(iroh.clone()), client.with_iroh(iroh)), - None => (server, client), + let client = match iroh { + Some(iroh) => client.with_iroh(iroh), + None => client, }; let cache = config.cache.init()?; @@ -229,7 +242,6 @@ impl Relay { // Graceful shutdown: the first signal drains every accepted session with a // GOAWAY; a second signal (or the drain window elapsing) exits. - let drain_timeout = config.drain_timeout.into_std(); let (shutdown_trigger, shutdown) = shutdown::Observer::new(drain_timeout); let sessions = crate::session::Registry::new(); let web = web::Web::new(auth.clone(), cluster.clone(), certificates, config.web) @@ -473,15 +485,13 @@ impl Relay { let quic_workers = { let mut running = futures::stream::FuturesUnordered::new(); if let Some(workers) = workers.as_mut() { - for (server, spawner) in workers.members() { - let index = spawner.index(); + for member in workers.members() { + let index = member.index(); let cluster = cluster.clone(); let auth = auth.clone(); let worker_shutdown = shutdown.clone(); let sessions = sessions.clone(); - let task = spawner.serve(server, move |server| { - serve(server, cluster, auth, worker_shutdown, sessions) - }); + let task = member.serve(move |server| serve(server, cluster, auth, worker_shutdown, sessions)); running.push(async move { match task.await { Ok(res) => res.with_context(|| format!("QUIC worker {index} failed")), diff --git a/rs/moq-relay/src/uring.rs b/rs/moq-relay/src/uring.rs index e7ed342599..76718741ed 100644 --- a/rs/moq-relay/src/uring.rs +++ b/rs/moq-relay/src/uring.rs @@ -740,13 +740,14 @@ async fn serve_connection( }; let role = request.role(); - let grants = match crate::connection::authorize(&serve.cluster, lease.token(), role, &moq_tokio::Transport::Quic) { - Ok(grants) => grants, - Err(err) => { - request.close(moq_net::Error::Unauthorized); - return Err(err); - } - }; + let grants = + match crate::connection::authorize(&serve.cluster, lease.token(), role, &moq_tokio::server::Transport::Quic) { + Ok(grants) => grants, + Err(err) => { + request.close(moq_net::Error::Unauthorized); + return Err(err); + } + }; let peer_hop = request.peer_hop(); let mut request = request.with_stats(grants.stats); @@ -759,7 +760,7 @@ async fn serve_connection( let session = request.ok().await?; let node_connection = peer_hop.map(|origin| serve.cluster.nodes.connect_inbound(id, origin)); - tracing::info!(id, version = %session.version(), transport = %moq_tokio::Transport::Quic, "negotiated"); + tracing::info!(id, version = %session.version(), transport = %moq_tokio::server::Transport::Quic, "negotiated"); // The session handle is Send + Sync however its transport is driven, so // its lifecycle (credential expiry, GOAWAY drain) lives with the timers diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index b38f43047b..659866b1b9 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -960,7 +960,7 @@ mod tests { /// Generate a CA + server cert/key on disk and return the temp paths. /// Modeled after `auth.rs::mtls_fixture`. fn make_named_certs(dir: &TempDir, name: &str, hostname: &str) -> (PathBuf, PathBuf, PathBuf) { - let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let _ = moq_tokio::crypto::install_default(); let ca_kp = KeyPair::generate().unwrap(); let mut ca_params = CertificateParams::new(vec![]).unwrap(); diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 41c08d3373..6c58dddbcb 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -178,7 +178,7 @@ async fn spawn_ws_relay(auth: moq_relay::auth::Auth) -> (u16, tokio::task::JoinH // Stream listeners bind lazily, so this server never opens a socket; only // its certificate handle is used. let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; let certificates = server_config .init(Default::default()) @@ -202,7 +202,7 @@ fn client() -> moq_tokio::Client { let mut config = moq_tokio::connect::Config::default(); config.tls.insecure = Some(true); config.once = Some(true); - config.websocket.delay = Duration::ZERO.into(); + config.websocket.delay = Duration::ZERO; config.bind = Some("127.0.0.1:0".parse().expect("parse bind")); config.init(Default::default()).expect("client init") } @@ -317,7 +317,7 @@ async fn spawn_quic_relay( ) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let mut config = moq_tokio::listen::Config::default(); - config.bind = Some("127.0.0.1:0".to_string()); + config.bind = Some("127.0.0.1:0".parse().unwrap()); config.tls.generate = vec!["localhost".into()]; config.tls.root = root.into_iter().collect(); let server = config.init(Default::default()).expect("server init"); @@ -886,7 +886,7 @@ async fn a_relay_without_an_auth_source_is_decided_by_the_embedder() { let mut config = Config::default(); config.listen.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); // The sessions are gone by the time the trigger fires; no need to wait out the default window. - config.drain_timeout = Duration::from_millis(100).into(); + config.drain_timeout = Duration::from_millis(100); config }; diff --git a/rs/moq-relay/tests/cluster_unknown.rs b/rs/moq-relay/tests/cluster_unknown.rs index 2c53a390bb..4ec3eded44 100644 --- a/rs/moq-relay/tests/cluster_unknown.rs +++ b/rs/moq-relay/tests/cluster_unknown.rs @@ -59,7 +59,7 @@ async fn spawn_relay( fn client(version: Option) -> moq_tokio::Client { let mut config = moq_tokio::connect::Config::default(); config.tls.insecure = Some(true); - config.websocket.delay = Duration::ZERO.into(); + config.websocket.delay = Duration::ZERO; config.bind = Some("127.0.0.1:0".parse().expect("parse bind")); config.version.extend(version); config.init(Default::default()).expect("client init") diff --git a/rs/moq-relay/tests/drills.rs b/rs/moq-relay/tests/drills.rs index b08ef61b40..2d80630351 100644 --- a/rs/moq-relay/tests/drills.rs +++ b/rs/moq-relay/tests/drills.rs @@ -54,7 +54,11 @@ impl RelayHost { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let mut config = Config::default(); - config.listen.bind = Some(format!("127.0.0.1:{}", requested_port.unwrap_or_default())); + config.listen.bind = Some( + format!("127.0.0.1:{}", requested_port.unwrap_or_default()) + .parse() + .unwrap(), + ); config.listen.tls.generate = vec!["localhost".into()]; config.auth.public = vec![moq_auth::Pattern::all()]; @@ -141,15 +145,15 @@ fn client(url: &url::Url) -> moq_tokio::Client { // busy runner has to stall eight times over before a live session is mistaken // for a dead one. let mut quic = moq_tokio::quic::Config::default(); - quic.idle_timeout = Duration::from_secs(2).into(); - quic.keep_alive = Duration::from_millis(250).into(); + quic.idle_timeout = Duration::from_secs(2); + quic.keep_alive = Duration::from_millis(250); // Fast enough to keep a relay bounce inside the drill's budget, paced enough // that the loop is still a backoff. `linger` is derived from `timeout`, so // the give-up budget also sets how long a broadcast survives the gap. - config.backoff.initial = Duration::from_millis(50).into(); - config.backoff.max = Duration::from_millis(200).into(); - config.backoff.timeout = Duration::from_secs(5).into(); + config.backoff.initial = Duration::from_millis(50); + config.backoff.max = Duration::from_millis(200); + config.backoff.timeout = Duration::from_secs(5); config.init(quic).expect("client init") } diff --git a/rs/moq-relay/tests/embed.rs b/rs/moq-relay/tests/embed.rs index ae162bf665..814c07a34c 100644 --- a/rs/moq-relay/tests/embed.rs +++ b/rs/moq-relay/tests/embed.rs @@ -108,12 +108,12 @@ async fn embed_and_stop(mut config: Config) { // No drain window: the sessions are already gone by the time the owner // stops, and the test should not wait out the default. - config.drain_timeout = Duration::ZERO.into(); + config.drain_timeout = Duration::ZERO; let http = config.web.http.listen.expect("http listener configured"); let relay = Relay::load(config.clone()).await.expect("load relay"); let quic = relay.addr().expect("quic listener bound"); // Pin the replacement to the same ports, including a `:0` first bind. - config.listen.bind = Some(quic.to_string()); + config.listen.bind = Some(moq_tokio::listen::Bind::Addr(quic)); // The application handles: in-process workers publish into the origin the // QUIC sessions see, and the trigger stops the owner from any task. Both @@ -216,7 +216,7 @@ async fn embed_and_stop(mut config: Config) { fn http_and_quic(cert: &std::path::Path, key: &std::path::Path, quic_bind: String) -> Config { let mut config = Config::default(); - config.listen.bind = Some(quic_bind); + config.listen.bind = Some(quic_bind.parse().unwrap()); config.listen.tls.cert = vec![cert.to_path_buf()]; config.listen.tls.key = vec![key.to_path_buf()]; config.web.http.listen = Some(format!("127.0.0.1:{}", free_tcp_port()).parse().expect("parse http")); diff --git a/rs/moq-relay/tests/goaway_cluster.rs b/rs/moq-relay/tests/goaway_cluster.rs index 3048d57a94..d06be60583 100644 --- a/rs/moq-relay/tests/goaway_cluster.rs +++ b/rs/moq-relay/tests/goaway_cluster.rs @@ -201,7 +201,7 @@ async fn cluster_migrates_on_upstream_goaway_inner() { let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); // Short handover so the test observes the old session close quickly. - client_config.goaway.handover = Duration::from_secs(2).into(); + client_config.goaway.handover = Duration::from_secs(2); let client = client_config.init(Default::default()).expect("client init"); let mut cluster_config = cluster::Config::default(); @@ -324,7 +324,7 @@ async fn spawn_relay_with_upstream( let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); // Short handover so the test observes the old session close quickly. - client_config.goaway.handover = Duration::from_secs(2).into(); + client_config.goaway.handover = Duration::from_secs(2); let client = client_config.init(Default::default()).expect("client init"); let cluster = cluster::Cluster::new(cluster::Options::new(cluster_config)) @@ -406,7 +406,7 @@ async fn cluster_diamond_goaway_seamless_failover_inner() { let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); // Short handover so the test observes the old session close quickly. - client_config.goaway.handover = Duration::from_secs(2).into(); + client_config.goaway.handover = Duration::from_secs(2); let mid_a_client = client_config.init(Default::default()).expect("mid-a client init"); let (_mid_a_upstream_client, mid_a_upstream) = within( "MID-A connects to TOP", @@ -667,7 +667,7 @@ async fn cluster_reconnects_on_empty_uri_goaway_inner() { let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); // Short handover so the test observes the old session close quickly. - client_config.goaway.handover = Duration::from_secs(2).into(); + client_config.goaway.handover = Duration::from_secs(2); let client = client_config.init(Default::default()).expect("client init"); let mut cluster_config = cluster::Config::default(); @@ -793,11 +793,11 @@ async fn goaway_handover_is_enforced_while_the_replacement_dial_hangs_inner() { let handover = Duration::from_millis(200); let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); - client_config.goaway.handover = handover.into(); + client_config.goaway.handover = handover; // The GOAWAY has to land on a *healthy* session, which is the path that goes // straight into the replacement dial. Below this bar it takes the immediate // redirect path instead, whose sleep polls the drain either way. - client_config.backoff.initial = Duration::from_millis(50).into(); + client_config.backoff.initial = Duration::from_millis(50); let client = client_config.init(Default::default()).expect("client init"); let url: Url = format!("tcp://127.0.0.1:{port}/").parse().expect("parse url"); diff --git a/rs/moq-relay/tests/lan_mesh.rs b/rs/moq-relay/tests/lan_mesh.rs index 5710ad851c..8320ad6d14 100644 --- a/rs/moq-relay/tests/lan_mesh.rs +++ b/rs/moq-relay/tests/lan_mesh.rs @@ -26,7 +26,7 @@ async fn moq_import_cluster_lan_beside_a_relay() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let mut listen = moq_tokio::listen::Config::default(); - listen.bind = Some("127.0.0.1:0".to_string()); + listen.bind = Some("127.0.0.1:0".parse().unwrap()); listen.tls.generate = vec!["localhost".into()]; let server = listen.init(Default::default()).expect("bind"); let port = server.local_addr().expect("addr").port(); diff --git a/rs/moq-relay/tests/runtime_uring.rs b/rs/moq-relay/tests/runtime_uring.rs index 128f87d2a6..354085df15 100644 --- a/rs/moq-relay/tests/runtime_uring.rs +++ b/rs/moq-relay/tests/runtime_uring.rs @@ -80,7 +80,7 @@ fn certificate(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf /// CI container may restrict which cores it may run on. fn uring_config(cert: &std::path::Path, key: &std::path::Path, port: u16) -> Config { let mut config = Config::default(); - config.listen.bind = Some(format!("127.0.0.1:{port}")); + config.listen.bind = Some(format!("127.0.0.1:{port}").parse().unwrap()); config.listen.tls.cert = vec![cert.to_path_buf()]; config.listen.tls.key = vec![key.to_path_buf()]; config.runtime.workers = Some(WORKERS); diff --git a/rs/moq-relay/tests/runtime_workers.rs b/rs/moq-relay/tests/runtime_workers.rs index f1c8552aad..3ef2bf35c8 100644 --- a/rs/moq-relay/tests/runtime_workers.rs +++ b/rs/moq-relay/tests/runtime_workers.rs @@ -45,7 +45,7 @@ fn certificate(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf /// and none of these tests are about placement. fn worker_config(cert: &std::path::Path, key: &std::path::Path, port: u16, workers: u16) -> Config { let mut config = Config::default(); - config.listen.bind = Some(format!("127.0.0.1:{port}")); + config.listen.bind = Some(format!("127.0.0.1:{port}").parse().unwrap()); config.listen.tls.cert = vec![cert.to_path_buf()]; config.listen.tls.key = vec![key.to_path_buf()]; config.runtime.workers = Some(workers); diff --git a/rs/moq-relay/tests/session_revalidate.rs b/rs/moq-relay/tests/session_revalidate.rs index 117ae8edee..52964e808e 100644 --- a/rs/moq-relay/tests/session_revalidate.rs +++ b/rs/moq-relay/tests/session_revalidate.rs @@ -163,7 +163,7 @@ impl Fixture { let port = free_port(); let cluster = cluster::Cluster::new(cluster::Options::default()).expect("cluster init"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; let certificates = server_config .init(Default::default()) @@ -256,7 +256,7 @@ fn client_at(bind: &str) -> moq_tokio::Client { let mut config = moq_tokio::connect::Config::default(); config.tls.insecure = Some(true); config.once = Some(true); - config.websocket.delay = Duration::ZERO.into(); + config.websocket.delay = Duration::ZERO; config.bind = Some(bind.parse().expect("parse bind")); config.init(Default::default()).expect("client init") } diff --git a/rs/moq-relay/tests/shutdown_signal.rs b/rs/moq-relay/tests/shutdown_signal.rs index 30d908fc57..3214f0d52b 100644 --- a/rs/moq-relay/tests/shutdown_signal.rs +++ b/rs/moq-relay/tests/shutdown_signal.rs @@ -122,7 +122,7 @@ fn relay_config() -> (u16, Config) { let mut config = Config::default(); config.listen.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); config.auth = auth; - config.drain_timeout = DRAIN_TIMEOUT.into(); + config.drain_timeout = DRAIN_TIMEOUT; (port, config) } diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index a43014ce6c..912ff4e283 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -55,7 +55,7 @@ async fn build_web_with(web_config: web::Config) -> web::Web { // expose HTTPS or QUIC in this test. Binding QUIC to `[::]:0` picks an // unused UDP port that we ignore. let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; let server = server_config.init(Default::default()).expect("server init"); @@ -118,7 +118,7 @@ async fn spawn_relay() -> (u16, tokio::task::JoinHandle<()>) { async fn spawn_versioned_relay(versions: Vec) -> (u16, tokio::task::JoinHandle<()>) { let port = free_tcp_port(); let mut config = Config::default(); - config.listen.bind = Some("127.0.0.1:0".to_string()); + config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); config.listen.tls.generate = vec!["localhost".into()]; config.listen.version = versions; config.web.ws = true; @@ -148,7 +148,7 @@ fn client_version(version: Option) -> moq_tokio::Client { // redial would re-register with the relay behind the assertions' back. config.once = Some(true); // Zero head start so the WebSocket path runs immediately. - config.websocket.delay = std::time::Duration::ZERO.into(); + config.websocket.delay = std::time::Duration::ZERO; // Every relay in this file listens on IPv4 loopback, so bind the same family // rather than egressing a QUIC dial from a dual-stack IPv6 socket. config.bind = Some("127.0.0.1:0".parse().expect("parse bind")); @@ -890,7 +890,7 @@ async fn internal_unix_path_reaches_server() { /// address and an abort handle. async fn spawn_quic_relay() -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { let mut config = moq_tokio::listen::Config::default(); - config.bind = Some("127.0.0.1:0".to_string()); + config.bind = Some("127.0.0.1:0".parse().unwrap()); config.tls.generate = vec!["localhost".into()]; let mut auth_config = auth::Config::default(); diff --git a/rs/moq-tokio/src/cli.rs b/rs/moq-tokio/src/cli.rs index 7533574c74..2dc50256e8 100644 --- a/rs/moq-tokio/src/cli.rs +++ b/rs/moq-tokio/src/cli.rs @@ -5,10 +5,12 @@ //! `parse()` is expected to take them first. A binary that parses more than once //! never reaches that code, so it has to answer them itself. Both shapes exist here: //! a TOML merge that layers CLI, env, and file with recorded provenance, and -//! moq-cli's repeated `--` stage grammar. [`Duration`] is the human-readable -//! duration those flags and TOML keys parse. +//! moq-cli's repeated `--` stage grammar. A private duration adapter parses the +//! human-readable values used by those flags and TOML keys. -mod duration; +#[path = "deprecated.rs"] +mod deprecated; +pub(crate) mod duration; use std::collections::HashSet; use std::ffi::OsStr; @@ -21,7 +23,11 @@ use usage::config::{ SourceKind, Value, resolve, }; -pub use duration::Duration; +pub use deprecated::Deprecated; +pub(crate) use duration::Duration; + +/// Re-exported because [`Merge`] and [`answer`] use Usage types in their APIs. +pub use usage; /// What a Usage parse result asks the process to do. #[non_exhaustive] @@ -110,35 +116,43 @@ pub fn answer( /// /// The merge is a TOML round-trip, so a `#[serde(skip)]` field comes back as its /// default. The released CLI spellings live on such fields: collect -/// [`Deprecated`](crate::Deprecated) from `parsed` before calling this, and from +/// [`Deprecated`] from `parsed` before calling this, and from /// the result for the file's own released keys. -pub fn merge( - registry: Registry, - parsed: T, - cli: &CliLayer, - env: &EnvLayer, - file: Option>, -) -> Result<(T, Resolved), String> -where - T: Serialize + DeserializeOwned, -{ - let file_layer = file.map(|source| TomlLayer { - path: source.path, - value: source.value, - }); - let mut layers = Layers::new().then(cli).then(env); - if let Some(ref file) = file_layer { - layers = layers.then(file); - } - let resolved = resolve(registry, layers).map_err(|err| err.to_string())?; +pub struct Merge<'a> { + /// The settings declared by the root CLI. + pub registry: Registry, + /// Values explicitly supplied on the command line. + pub cli: &'a CliLayer, + /// Values explicitly supplied through the environment. + pub env: &'a EnvLayer, + /// An optional TOML document and its provenance. + pub file: Option>, +} - let occupied = occupied_keys(registry, &resolved); - let mut merged = toml::Value::try_from(&parsed).map_err(|err| err.to_string())?; - if let Some(source) = file { - overlay_unoccupied(&mut merged, source.value, "", &occupied); +impl Merge<'_> { + /// Apply CLI, environment, file, and default precedence to `parsed`. + pub fn apply(self, parsed: T) -> Result<(T, Resolved), String> + where + T: Serialize + DeserializeOwned, + { + let file_layer = self.file.map(|source| TomlLayer { + path: source.path, + value: source.value, + }); + let mut layers = Layers::new().then(self.cli).then(self.env); + if let Some(ref file) = file_layer { + layers = layers.then(file); + } + let resolved = resolve(self.registry, layers).map_err(|err| err.to_string())?; + + let occupied = occupied_keys(self.registry, &resolved); + let mut merged = toml::Value::try_from(&parsed).map_err(|err| err.to_string())?; + if let Some(source) = self.file { + overlay_unoccupied(&mut merged, source.value, "", &occupied); + } + let config: T = merged.try_into().map_err(|err: toml::de::Error| err.to_string())?; + Ok((config, resolved)) } - let config: T = merged.try_into().map_err(|err: toml::de::Error| err.to_string())?; - Ok((config, resolved)) } /// A TOML document to merge, named for provenance. @@ -202,6 +216,9 @@ fn overlay_unoccupied(base: &mut toml::Value, overlay: &toml::Value, path: &str, } else { format!("{path}.{key}") }; + if !occupied.contains(child.as_str()) { + base.remove(&format!("__cli_{key}")); + } match base.get_mut(key) { Some(existing) => overlay_unoccupied(existing, value, &child, occupied), None if occupied.contains(child.as_str()) => {} diff --git a/rs/moq-tokio/src/cli/duration.rs b/rs/moq-tokio/src/cli/duration.rs index 016e0a078d..1bc0a0e748 100644 --- a/rs/moq-tokio/src/cli/duration.rs +++ b/rs/moq-tokio/src/cli/duration.rs @@ -5,17 +5,32 @@ use std::ops::Deref; use std::str::FromStr; use std::time::Duration as StdDuration; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use ::serde::{Deserialize, Deserializer, Serialize, Serializer}; /// A duration that parses human-readable command-line values such as `500ms` or `2m`. #[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] -#[repr(transparent)] -pub struct Duration(StdDuration); +pub struct Duration { + value: StdDuration, + explicit: bool, +} impl Duration { /// Returns the wrapped standard-library duration. pub const fn into_std(self) -> StdDuration { - self.0 + self.value + } + + /// A parser default, which yields to a standing typed value during an update. + pub(crate) const fn fallback(value: StdDuration) -> Self { + Self { value, explicit: false } + } + + /// Resolve an optional parser value against the public typed field. + pub(crate) fn resolve(value: Option, configured: StdDuration) -> StdDuration { + match value { + Some(value) if value.explicit || configured.is_zero() => value.value, + _ => configured, + } } } @@ -23,25 +38,25 @@ impl Deref for Duration { type Target = StdDuration; fn deref(&self) -> &Self::Target { - &self.0 + &self.value } } impl From for Duration { fn from(value: StdDuration) -> Self { - Self(value) + Self { value, explicit: true } } } impl From for StdDuration { fn from(value: Duration) -> Self { - value.0 + value.value } } impl PartialEq for Duration { fn eq(&self, other: &StdDuration) -> bool { - self.0 == *other + self.value == *other } } @@ -49,13 +64,13 @@ impl FromStr for Duration { type Err = humantime::DurationError; fn from_str(value: &str) -> Result { - humantime::parse_duration(value).map(Self) + humantime::parse_duration(value).map(Into::into) } } impl fmt::Display for Duration { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - humantime::format_duration(self.0).fmt(f) + humantime::format_duration(self.value).fmt(f) } } @@ -74,6 +89,26 @@ impl<'de> Deserialize<'de> for Duration { D: Deserializer<'de>, { let value = String::deserialize(deserializer)?; - value.parse().map_err(serde::de::Error::custom) + value.parse().map_err(::serde::de::Error::custom) + } +} + +pub(crate) mod serde_duration { + use ::serde::{Deserialize, Deserializer, Serializer}; + use std::time::Duration; + + pub fn serialize(value: &Duration, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(&humantime::format_duration(*value)) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + humantime::parse_duration(&value).map_err(::serde::de::Error::custom) } } diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index eaa0a1979f..f60e110ae3 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -32,18 +32,6 @@ pub struct Config { } impl Config { - /// Set the dial side, returning `self` for chaining. - pub fn with_connect(mut self, connect: crate::connect::Config) -> Self { - self.connect = connect; - self - } - - /// Set the QUIC settings, returning `self` for chaining. - pub fn with_quic(mut self, quic: crate::quic::Config) -> Self { - self.quic = quic; - self - } - /// Build the [`Client`] this config describes. pub fn init(self) -> crate::Result { Client::new(self) @@ -729,7 +717,7 @@ mod tests { #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] async fn check_fixed_redirect(once: bool, pinned: bool) { let mut listen = crate::listen::Config { - bind: Some("127.0.0.1:0".into()), + bind: Some("127.0.0.1:0".parse().unwrap()), ..Default::default() }; listen.tls.generate = vec!["relay.invalid".into()]; @@ -965,7 +953,11 @@ mod tests { #[test] fn building_a_client_refuses_a_released_spelling() { let config = Cli::config_from(["test", "--client-connect", "https://relay.example.com/anon"]); - let Err(err) = crate::client::Config::default().with_connect(config).init() else { + let Err(err) = crate::client::Config { + connect: config, + ..Default::default() + } + .init() else { panic!("building a client must refuse a released spelling"); }; assert!(matches!(err, Error::Deprecated(_)), "{err}"); @@ -985,7 +977,7 @@ mod tests { "#; let config: crate::connect::Config = toml::from_str(toml).unwrap(); - assert_eq!(config.race, crate::cli::Duration::from(crate::connect::DEFAULT_RACE)); + assert_eq!(config.race, crate::connect::DEFAULT_RACE); assert!( config.deprecated().to_string().contains("failover_delay -> race"), "{}", @@ -996,7 +988,7 @@ mod tests { #[test] fn test_cli_failover_delay() { let config = Cli::config_from(["test", "--connect-race", "50ms"]); - assert_eq!(config.race, std::time::Duration::from_millis(50)); + assert_eq!(config.resolve().race, std::time::Duration::from_millis(50)); } #[test] @@ -1024,7 +1016,6 @@ mod tests { #[test] fn resolution_delay_defaults_to_the_rfc_value() { let config = Cli::config_from(["test"]); - assert_eq!(config.resolution_delay, std::time::Duration::from_millis(50)); assert_eq!(config.resolve().resolution_delay, std::time::Duration::from_millis(50)); } @@ -1330,7 +1321,6 @@ mod tests { #[test] fn connect_timeout_defaults_to_thirty_seconds() { let config = Cli::config_from(["test"]); - assert_eq!(config.timeout, crate::connect::DEFAULT_TIMEOUT); assert_eq!(config.resolve().timeout, crate::connect::DEFAULT_TIMEOUT); } @@ -1349,10 +1339,10 @@ mod tests { let timeout = crate::connect::DEFAULT_TIMEOUT; let mut config = crate::connect::Config { - timeout: timeout.into(), + timeout, ..Default::default() }; - config.websocket.delay = std::time::Duration::ZERO.into(); + config.websocket.delay = std::time::Duration::ZERO; let client = config.init(Default::default()).unwrap(); // Nothing is listening on UDP, so the QUIC arm fails and leaves the WebSocket diff --git a/rs/moq-tokio/src/connect.rs b/rs/moq-tokio/src/connect.rs index 48372c1f2c..5d8020d97d 100644 --- a/rs/moq-tokio/src/connect.rs +++ b/rs/moq-tokio/src/connect.rs @@ -229,8 +229,8 @@ pub(crate) struct Legacy { impl Legacy { /// The released spellings in use, each paired with what replaced it. - fn deprecated(&self) -> crate::Deprecated { - let mut found = crate::Deprecated::default(); + fn deprecated(&self) -> crate::cli::Deprecated { + let mut found = crate::cli::Deprecated::default(); if self.url.is_some() { found.flag( "--client-connect", @@ -395,7 +395,7 @@ failover_delay = "1s" ) .expect("parse"); assert!(config.url.is_none()); - assert_eq!(config.race, crate::cli::Duration::from(DEFAULT_RACE)); + assert_eq!(config.race, DEFAULT_RACE); let reported = config.deprecated().to_string(); assert!(reported.contains("connect -> url"), "{reported}"); assert!(reported.contains("failover_delay -> race"), "{reported}"); @@ -537,14 +537,20 @@ pub struct Config { /// /// This staggers the attempts within one [`crate::Client::connect`]; [`Self::timeout`] /// bounds that call as a whole. + #[usage(skip)] + #[serde(with = "crate::cli::duration::serde_duration")] + pub race: std::time::Duration, + #[usage( name = "connect-race", long = "connect-race", env = "MOQ_CONNECT_RACE", + default_value_t = crate::cli::Duration::fallback(DEFAULT_RACE), default = "250ms", setting = "connect.race" )] - pub race: crate::cli::Duration, + #[serde(default, rename = "__cli_race", skip_serializing_if = "Option::is_none")] + pub(crate) race_arg: Option, /// The released `failover_delay` key, kept so [`deprecated`](Self::deprecated) can name [`race`](Self::race). #[serde(default, skip_serializing)] @@ -558,14 +564,20 @@ pub struct Config { /// The full answer is authoritative, including which family to try first, so /// this is how long the IPv4-only one waits for it before going ahead alone. /// Defaults to 50ms; `0s` dials as soon as any address resolves. + #[usage(skip)] + #[serde(with = "crate::cli::duration::serde_duration")] + pub resolution_delay: std::time::Duration, + #[usage( name = "connect-resolution-delay", long = "connect-resolution-delay", env = "MOQ_CONNECT_RESOLUTION_DELAY", + default_value_t = crate::cli::Duration::fallback(DEFAULT_RESOLUTION_DELAY), default = "50ms", setting = "connect.resolution_delay" )] - pub resolution_delay: crate::cli::Duration, + #[serde(default, rename = "__cli_resolution_delay", skip_serializing_if = "Option::is_none")] + pub(crate) resolution_delay_arg: Option, /// Maximum time for one [`crate::Client::connect`], covering the dial and the MoQ /// handshake. Defaults to 30 seconds; set to 0 to wait forever. @@ -576,14 +588,20 @@ pub struct Config { /// never speaks would hang the whole connect. [`crate::Connection`] only re-arms /// its backoff between attempts, so an attempt that never returns stalls the /// retry loop indefinitely. + #[usage(skip)] + #[serde(with = "crate::cli::duration::serde_duration")] + pub timeout: std::time::Duration, + #[usage( name = "connect-timeout", long = "connect-timeout", env = "MOQ_CONNECT_TIMEOUT", + default_value_t = crate::cli::Duration::fallback(DEFAULT_TIMEOUT), default = "30s", setting = "connect.timeout" )] - pub timeout: crate::cli::Duration, + #[serde(default, rename = "__cli_timeout", skip_serializing_if = "Option::is_none")] + pub(crate) timeout_arg: Option, /// Restrict the client to specific MoQ protocol version(s). /// @@ -686,10 +704,13 @@ impl Default for Config { connect: None, bind: None, backend: None, - race: DEFAULT_RACE.into(), + race: DEFAULT_RACE, + race_arg: None, failover_delay: None, - resolution_delay: DEFAULT_RESOLUTION_DELAY.into(), - timeout: DEFAULT_TIMEOUT.into(), + resolution_delay: DEFAULT_RESOLUTION_DELAY, + resolution_delay_arg: None, + timeout: DEFAULT_TIMEOUT, + timeout_arg: None, version: Vec::new(), tls: Default::default(), once: None, @@ -712,7 +733,7 @@ impl Config { /// old spellings are parsed so the process can name their replacement, not so it /// can honor them: [`crate::Client::new`] rejects them too, so a config that /// skipped the check can't reach a dial that quietly ignored half of it. - pub fn deprecated(&self) -> crate::Deprecated { + pub fn deprecated(&self) -> crate::cli::Deprecated { let mut found = self.legacy.deprecated(); if self.connect.is_some() { found.toml("connect", "url", None); @@ -734,10 +755,12 @@ impl Config { /// Build the [`crate::Client`] this config describes. pub fn init(self, quic: crate::quic::Config) -> crate::Result { - crate::client::Config::default() - .with_connect(self) - .with_quic(quic) - .init() + crate::client::Config { + connect: self, + quic, + ..Default::default() + } + .init() } /// Returns the configured versions, defaulting to all if none specified. @@ -757,9 +780,9 @@ impl Config { pub fn resolve(&self) -> Resolved { Resolved { bind: self.bind.unwrap_or_else(default_bind), - race: self.race.into_std(), - resolution_delay: self.resolution_delay.into_std(), - timeout: self.timeout.into_std(), + race: crate::cli::Duration::resolve(self.race_arg, self.race), + resolution_delay: crate::cli::Duration::resolve(self.resolution_delay_arg, self.resolution_delay), + timeout: crate::cli::Duration::resolve(self.timeout_arg, self.timeout), } } } diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index eb3e4a0ab4..59dd5fba61 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -91,14 +91,20 @@ pub struct Backoff { /// Doubles as the bar a session must stay up to count as healthy, so it is /// floored at 50ms: at zero every session would look healthy and the retry /// pacing would collapse. + #[usage(skip)] + #[serde(with = "crate::cli::duration::serde_duration")] + pub initial: Duration, + #[usage( name = "backoff-initial", long, env = "MOQ_BACKOFF_INITIAL", + default_value_t = CliDuration::fallback(DEFAULT_INITIAL), default = "1s", setting = "connect.backoff.initial" )] - pub initial: CliDuration, + #[serde(default, rename = "__cli_initial", skip_serializing_if = "Option::is_none")] + initial_arg: Option, /// Multiplier applied to delay after each failure. Defaults to 2. #[usage( @@ -111,63 +117,56 @@ pub struct Backoff { pub multiplier: u32, /// Maximum delay between reconnect attempts. Defaults to 5s. + #[usage(skip)] + #[serde(with = "crate::cli::duration::serde_duration")] + pub max: Duration, + #[usage( name = "backoff-max", long, env = "MOQ_BACKOFF_MAX", + default_value_t = CliDuration::fallback(DEFAULT_MAX), default = "5s", setting = "connect.backoff.max" )] - pub max: CliDuration, + #[serde(default, rename = "__cli_max", skip_serializing_if = "Option::is_none")] + max_arg: Option, /// Maximum time to spend retrying before giving up. Defaults to 10s. /// /// Resets after a stable connection (one that outlives the initial backoff), so a flapping /// session that reconnects then immediately drops still counts toward the timeout. Set to 0 for /// unlimited retries. + #[usage(skip)] + #[serde(with = "crate::cli::duration::serde_duration")] + pub timeout: Duration, + #[usage( name = "backoff-timeout", long, env = "MOQ_BACKOFF_TIMEOUT", + default_value_t = CliDuration::fallback(DEFAULT_TIMEOUT), default = "10s", setting = "connect.backoff.timeout" )] - pub timeout: CliDuration, + #[serde(default, rename = "__cli_timeout", skip_serializing_if = "Option::is_none")] + timeout_arg: Option, } impl Default for Backoff { fn default() -> Self { Self { - initial: DEFAULT_INITIAL.into(), + initial: DEFAULT_INITIAL, + initial_arg: None, multiplier: DEFAULT_MULTIPLIER, - max: DEFAULT_MAX.into(), - timeout: DEFAULT_TIMEOUT.into(), + max: DEFAULT_MAX, + max_arg: None, + timeout: DEFAULT_TIMEOUT, + timeout_arg: None, } } } -impl Backoff { - /// The configured initial delay, or the default. - pub fn initial(&self) -> Duration { - self.initial.into_std() - } - - /// The configured multiplier, or the default. - pub fn multiplier(&self) -> u32 { - self.multiplier - } - - /// The configured delay ceiling, or the default. - pub fn max(&self) -> Duration { - self.max.into_std() - } - - /// The configured give-up window, or the default. Zero retries forever. - pub fn timeout(&self) -> Duration { - self.timeout.into_std() - } -} - /// A connection lifecycle transition reported by [`Connection::status`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[non_exhaustive] @@ -343,21 +342,28 @@ pub struct Goaway { /// "10s" or "500ms". This is a cap: a GOAWAY naming a shorter deadline wins, /// since the peer force-closes at its own deadline regardless, but a longer one /// does not extend it. Defaults to 10 seconds. + #[usage(skip)] + #[serde(with = "crate::cli::duration::serde_duration")] + pub handover: Duration, + #[usage( name = "goaway-handover", long, env = "MOQ_GOAWAY_HANDOVER", + default_value_t = CliDuration::fallback(DEFAULT_HANDOVER), default = "10s", setting = "connect.goaway.handover" )] - pub handover: CliDuration, + #[serde(default, rename = "__cli_handover", skip_serializing_if = "Option::is_none")] + handover_arg: Option, } impl Default for Goaway { fn default() -> Self { Self { redirect: Redirect::SameHost, - handover: DEFAULT_HANDOVER.into(), + handover: DEFAULT_HANDOVER, + handover_arg: None, } } } @@ -393,11 +399,12 @@ struct Pacing { impl Pacing { fn new(backoff: &Backoff) -> Self { - let initial = backoff.initial().max(MIN_BACKOFF); + let initial = CliDuration::resolve(backoff.initial_arg, backoff.initial).max(MIN_BACKOFF); + let max = CliDuration::resolve(backoff.max_arg, backoff.max); Self { initial, - max: backoff.max().max(initial), - multiplier: backoff.multiplier().max(1), + max: max.max(initial), + multiplier: backoff.multiplier.max(1), } } @@ -412,20 +419,23 @@ impl Pacing { const DEFAULT_HANDOVER: Duration = Duration::from_secs(10); impl Goaway { - /// The configured redirect policy, or the default. - pub fn redirect(&self) -> Redirect { - self.redirect + fn resolve(&self) -> Resolved { + Resolved { + redirect: self.redirect, + handover: CliDuration::resolve(self.handover_arg, self.handover), + } } +} - /// How long the old session keeps serving, given the deadline the peer's GOAWAY - /// named (`None` when it named none). - /// - /// Whichever comes first. The peer's deadline is a promise about when it - /// force-closes, so waiting past it just holds a dead session; ours is the cap - /// on how long we keep one around at all, so a peer naming an hour cannot talk - /// us into honoring it. - pub fn handover(&self, timeout: Option) -> Duration { - std::cmp::min(self.handover.into_std(), timeout.unwrap_or(Duration::MAX)) +#[derive(Clone, Copy)] +struct Resolved { + redirect: Redirect, + handover: Duration, +} + +impl Resolved { + fn handover(self, timeout: Option) -> Duration { + std::cmp::min(self.handover, timeout.unwrap_or(Duration::MAX)) } } @@ -715,8 +725,9 @@ impl Connection { async fn run(shared: &Shared, client: Client, addrs: Addrs) -> crate::Result<()> { let backoff = client.backoff.clone(); - let goaway = client.goaway.clone(); + let goaway = client.goaway.resolve(); let pacing = Pacing::new(&backoff); + let timeout = CliDuration::resolve(backoff.timeout_arg, backoff.timeout); let initial = pacing.initial; let mut delay = initial; let mut retry_start = tokio::time::Instant::now(); @@ -729,7 +740,6 @@ impl Connection { let mut draining: Option = None; loop { - let timeout = backoff.timeout(); if !timeout.is_zero() && retry_start.elapsed() >= timeout { return Err(timeout_error(timeout, last_error.as_ref())); } @@ -755,7 +765,7 @@ impl Connection { // The connected target owns the policy, including in one-shot mode. if let Ended::Goaway(msg) = &ended && addr.addresses().is_some() - && goaway.redirect().target(&msg.uri, &url).is_some() + && goaway.redirect.target(&msg.uri, &url).is_some() { return Err(Error::PinnedRedirect); } @@ -774,7 +784,7 @@ impl Connection { // An accepted redirect is an assignment: keep dialing it from here on, and // only it. The peer named exactly one place to go, which retires // whatever other addresses got us to this session. - let url = if let Some(target) = goaway.redirect().target(&msg.uri, &url) { + let url = if let Some(target) = goaway.redirect.target(&msg.uri, &url) { addrs = Addrs::new(target.clone()); target } else { @@ -862,7 +872,7 @@ impl Connection { Ended::Goaway(_) => None, }; // NOTE: only UNAUTHORIZED is specified, and it is handled above. Any - // other MoQ-layer rejection (Request::close after the transport is + // other MoQ-layer rejection (Request::reject after the transport is // accepted) lands here as an untyped transport close, so it cannot be // told apart from a network blip and is retried until the give-up // timeout. Classifying the rest needs the transport to surface the @@ -1331,10 +1341,10 @@ mod tests { // Stand in for the TOML layer, then re-apply the CLI with none of the flags set. let mut parsed = Wrapper::parse_from(&[]).unwrap(); - parsed.backoff.initial = Duration::from_secs(7).into(); + parsed.backoff.initial = Duration::from_secs(7); parsed.backoff.multiplier = 5; - parsed.backoff.max = Duration::from_secs(11).into(); - parsed.backoff.timeout = Duration::ZERO.into(); + parsed.backoff.max = Duration::from_secs(11); + parsed.backoff.timeout = Duration::ZERO; parsed.update_from(&[]); assert_eq!(parsed.backoff.initial, Duration::from_secs(7)); @@ -1342,18 +1352,21 @@ mod tests { assert_eq!(parsed.backoff.max, Duration::from_secs(11)); assert_eq!(parsed.backoff.timeout, Duration::ZERO, "0 means retry forever"); - // With no flags, the typed defaults are materialized directly. + // With no flags, the parser defaults resolve to the typed defaults. let parsed = Wrapper::parse_from(&[]).unwrap(); - assert_eq!(parsed.backoff.initial, DEFAULT_INITIAL); - assert_eq!(parsed.backoff.initial(), DEFAULT_INITIAL); - assert_eq!(parsed.backoff.multiplier(), DEFAULT_MULTIPLIER); - assert_eq!(parsed.backoff.max(), DEFAULT_MAX); - assert_eq!(parsed.backoff.timeout(), DEFAULT_TIMEOUT); + assert_eq!(parsed.backoff.multiplier, DEFAULT_MULTIPLIER); + let pacing = Pacing::new(&parsed.backoff); + assert_eq!(pacing.initial, DEFAULT_INITIAL); + assert_eq!(pacing.max, DEFAULT_MAX); + assert_eq!( + CliDuration::resolve(parsed.backoff.timeout_arg, parsed.backoff.timeout), + DEFAULT_TIMEOUT + ); // And a flag still lands where the merge can see it. let parsed = Wrapper::parse_from(&[std::ffi::OsStr::new("--backoff-initial"), std::ffi::OsStr::new("3s")]).unwrap(); - assert_eq!(parsed.backoff.initial, Duration::from_secs(3)); + assert_eq!(Pacing::new(&parsed.backoff).initial, Duration::from_secs(3)); } /// GOAWAY typed defaults remain overrideable by a standing TOML layer. @@ -1370,9 +1383,9 @@ mod tests { // No flags passed: the typed defaults are present. let parsed = Wrapper::parse_from(&[]).unwrap(); assert_eq!(parsed.goaway.redirect, Redirect::SameHost); - assert_eq!(parsed.goaway.handover, Duration::from_secs(10)); - assert_eq!(parsed.goaway.redirect(), Redirect::SameHost); - assert_eq!(parsed.goaway.handover(None), Duration::from_secs(10)); + let resolved = parsed.goaway.resolve(); + assert_eq!(resolved.redirect, Redirect::SameHost); + assert_eq!(resolved.handover(None), Duration::from_secs(10)); // Flags passed: they land where the merge can see them. let parsed = Wrapper::parse_from(&[ @@ -1383,7 +1396,7 @@ mod tests { ]) .unwrap(); assert_eq!(parsed.goaway.redirect, Redirect::Ignore); - assert_eq!(parsed.goaway.handover, Duration::from_secs(3)); + assert_eq!(parsed.goaway.resolve().handover(None), Duration::from_secs(3)); } /// Our own window caps the peer's. A GOAWAY deadline shortens the handover but @@ -1392,10 +1405,11 @@ mod tests { #[test] fn handover_takes_the_earlier_deadline() { let config = Goaway { - handover: Duration::from_secs(10).into(), + handover: Duration::from_secs(10), ..Default::default() }; + let config = config.resolve(); assert_eq!(config.handover(None), Duration::from_secs(10), "no deadline: ours"); assert_eq!( config.handover(Some(Duration::from_secs(3))), @@ -1409,7 +1423,7 @@ mod tests { ); // The default is a cap too, not just a fallback for a silent peer. - let default = Goaway::default(); + let default = Goaway::default().resolve(); assert_eq!(default.handover(Some(Duration::from_secs(3600))), DEFAULT_HANDOVER); } @@ -1555,7 +1569,7 @@ mod tests { "a peer-named host is refused without resolving it" ); assert_eq!( - Goaway::default().redirect(), + Goaway::default().resolve().redirect, Redirect::SameHost, "and the shipped config carries that default" ); @@ -1611,7 +1625,7 @@ mod tests { // A zero initial would also make every session look healthy, resetting the // give-up window forever. let pacing = Pacing::new(&Backoff { - initial: Duration::ZERO.into(), + initial: Duration::ZERO, ..Default::default() }); assert_eq!(pacing.initial, MIN_BACKOFF); @@ -1619,7 +1633,7 @@ mod tests { // A cap below the floor would clamp the delay straight back down. let pacing = Pacing::new(&Backoff { - max: Duration::ZERO.into(), + max: Duration::ZERO, ..Default::default() }); assert_eq!(pacing.max, pacing.initial); @@ -1634,8 +1648,8 @@ mod tests { // All at once: still paced. let pacing = Pacing::new(&Backoff { - initial: Duration::ZERO.into(), - max: Duration::ZERO.into(), + initial: Duration::ZERO, + max: Duration::ZERO, multiplier: 0, ..Default::default() }); @@ -1650,9 +1664,9 @@ mod tests { #[test] fn pacing_grows_to_the_cap() { let pacing = Pacing::new(&Backoff { - initial: Duration::from_millis(100).into(), + initial: Duration::from_millis(100), multiplier: 2, - max: Duration::from_millis(400).into(), + max: Duration::from_millis(400), ..Default::default() }); @@ -1665,10 +1679,10 @@ mod tests { #[test] fn test_backoff_default() { let backoff = Backoff::default(); - assert_eq!(backoff.initial(), Duration::from_secs(1)); - assert_eq!(backoff.multiplier(), 2); - assert_eq!(backoff.max(), Duration::from_secs(5)); - assert_eq!(backoff.timeout(), Duration::from_secs(10)); + assert_eq!(backoff.initial, Duration::from_secs(1)); + assert_eq!(backoff.multiplier, 2); + assert_eq!(backoff.max, Duration::from_secs(5)); + assert_eq!(backoff.timeout, Duration::from_secs(10)); } #[test] diff --git a/rs/moq-tokio/src/crypto.rs b/rs/moq-tokio/src/crypto.rs index 1119c71acd..9f997fec98 100644 --- a/rs/moq-tokio/src/crypto.rs +++ b/rs/moq-tokio/src/crypto.rs @@ -1,8 +1,12 @@ +//! Rustls crypto-provider selection shared by applications using moq-tokio. + use rustls::crypto::hash::{self, HashAlgorithm}; use std::sync::Arc; +/// A shared rustls crypto provider. pub type Provider = Arc; +/// Return the installed provider, or the provider selected by crate features. pub fn provider() -> Provider { if let Some(provider) = rustls::crypto::CryptoProvider::get_default().cloned() { return provider; @@ -17,12 +21,17 @@ pub fn provider() -> Provider { } } +/// Install the provider selected by crate features as rustls' process default. +pub fn install_default() -> Result<(), Provider> { + Arc::unwrap_or_clone(provider()).install_default() +} + /// Helper function to compute SHA256 hash using the crypto provider /// /// This function tries to find a SHA256 hash implementation in the provided /// crypto provider's cipher suites. If not found, it falls back to direct /// implementations based on enabled features. -pub fn sha256(provider: &Provider, data: &[u8]) -> hash::Output { +pub(crate) fn sha256(provider: &Provider, data: &[u8]) -> hash::Output { // Try to find a SHA-256 hash provider from the cipher suites let hash_provider = provider.cipher_suites.iter().find_map(|suite| { let hash_provider = suite.tls13()?.common.hash_provider; diff --git a/rs/moq-tokio/src/error.rs b/rs/moq-tokio/src/error.rs index 5518f66ae2..756aee9587 100644 --- a/rs/moq-tokio/src/error.rs +++ b/rs/moq-tokio/src/error.rs @@ -89,7 +89,7 @@ pub enum Error { /// The config was parsed from released spellings that no longer work. The /// payload is the migration to print. #[error("{0}")] - Deprecated(crate::Deprecated), + Deprecated(crate::cli::Deprecated), /// The idle timeout is longer than QUIC's millisecond varint can carry. #[error("idle timeout must be under 2^62 milliseconds")] @@ -137,10 +137,6 @@ pub enum Error { #[error("tls.root (mTLS) is not supported by the selected QUIC backend")] MtlsUnsupported, - /// A QUIC-LB nonce length was set without the server id it encodes alongside. - #[error("--listen-quic-lb-nonce needs --listen-quic-lb-id")] - LbNonceWithoutId, - /// A worker group was asked for more members than the connection ID's one-byte /// steering prefix can name. #[error("QUIC workers cannot exceed {max}; {count} were requested")] diff --git a/rs/moq-tokio/src/failover.rs b/rs/moq-tokio/src/failover.rs index 11c1266562..51d8b1c8e6 100644 --- a/rs/moq-tokio/src/failover.rs +++ b/rs/moq-tokio/src/failover.rs @@ -12,7 +12,7 @@ //! holds back for the full answer is [`crate::connect::Config::resolution_delay`]. //! //! Nothing here needs calling: every client dial goes through it. The one type -//! a consumer sees is [`Failure`], which the backend `Error` types carry when +//! a consumer sees is [`Attempt`], which the backend `Error` types carry when //! the race loses every attempt. use std::fmt; @@ -31,7 +31,7 @@ use crate::resolve::Candidates; /// the address race loses all of them. A dial that had only one address to try /// reports that error directly instead, so this never stands alone. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Failure { +pub struct Attempt { /// The address that was dialed. pub addr: SocketAddr, @@ -39,13 +39,13 @@ pub struct Failure { pub error: E, } -impl fmt::Display for Failure { +impl fmt::Display for Attempt { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}: {}", self.addr, self.error) } } -impl std::error::Error for Failure { +impl std::error::Error for Attempt { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { Some(&self.error) } @@ -60,7 +60,7 @@ pub(crate) trait Aggregate: Sized { /// /// Never called with fewer than two: a lone attempt is no race, so [`race`] /// hands that error back untouched rather than burying it in an aggregate. - fn aggregate(failures: Vec>) -> Self; + fn aggregate(failures: Vec>) -> Self; /// The error for a dial that never had an address to try: the DNS failure /// when there was one, and the backend's empty-answer error when both queries @@ -69,7 +69,7 @@ pub(crate) trait Aggregate: Sized { } /// Render each failed attempt as `addr: error`, joined by `; `. -pub(crate) fn describe(failures: &[Failure]) -> String { +pub(crate) fn describe(failures: &[Attempt]) -> String { failures.iter().map(|f| f.to_string()).collect::>().join("; ") } @@ -103,7 +103,7 @@ where E: Aggregate + fmt::Display, { let mut attempts = FuturesUnordered::new(); - let mut failures: Vec<(usize, Failure)> = Vec::new(); + let mut failures: Vec<(usize, Attempt)> = Vec::new(); let mut exhausted = false; // When the next attempt may start: the first as soon as an address resolves, @@ -148,7 +148,7 @@ where // interesting when the whole race fails. Then it comes back in // the returned error, which the caller logs. tracing::debug!(%addr, index, %err, "connection attempt failed"); - failures.push((index, Failure { addr, error: err })); + failures.push((index, Attempt { addr, error: err })); // A failure starts the next candidate immediately (RFC 8305 // section 5) rather than waiting out the stagger delay. ready = tokio::time::Instant::now(); @@ -182,7 +182,7 @@ async fn pull(candidates: &mut Candidates, ready: tokio::time::Instant) -> Optio /// Fold the failed attempts into one error, leaving a lone attempt's error /// exactly as the backend produced it. -fn collapse(mut failures: Vec>) -> E { +fn collapse(mut failures: Vec>) -> E { match failures.len() { 1 => failures.pop().expect("checked len").error, _ => E::aggregate(failures), @@ -205,7 +205,7 @@ mod tests { #[derive(Debug, PartialEq, Eq)] enum TestError { Dial(&'static str), - All(Vec>), + All(Vec>), } impl fmt::Display for TestError { @@ -218,7 +218,7 @@ mod tests { } impl Aggregate for TestError { - fn aggregate(failures: Vec>) -> Self { + fn aggregate(failures: Vec>) -> Self { Self::All(failures) } @@ -230,8 +230,8 @@ mod tests { } } - fn failed(dest: &str, err: &'static str) -> Failure { - Failure { + fn failed(dest: &str, err: &'static str) -> Attempt { + Attempt { addr: addr(dest), error: TestError::Dial(err), } diff --git a/rs/moq-tokio/src/iroh.rs b/rs/moq-tokio/src/iroh.rs index 030c857a30..fcbbd32014 100644 --- a/rs/moq-tokio/src/iroh.rs +++ b/rs/moq-tokio/src/iroh.rs @@ -41,7 +41,7 @@ pub enum Error { /// The QUIC config was parsed from released spellings that no longer work. The /// payload is the migration to print. #[error("{0}")] - Deprecated(crate::Deprecated), + Deprecated(crate::cli::Deprecated), /// The configured secret was neither a valid hex key nor a readable key file. #[error("invalid iroh secret key: {0}")] diff --git a/rs/moq-tokio/src/lib.rs b/rs/moq-tokio/src/lib.rs index 488b5ee76b..c22dea7175 100644 --- a/rs/moq-tokio/src/lib.rs +++ b/rs/moq-tokio/src/lib.rs @@ -35,8 +35,7 @@ pub mod cli; pub mod client; pub mod connect; pub mod connection; -mod crypto; -mod deprecated; +pub mod crypto; mod error; #[cfg(any( feature = "quinn", @@ -90,11 +89,10 @@ pub mod websocket; pub use client::Client; pub use connect::{Addrs, ConnectError}; pub use connection::{Backoff, Connection, Redirect, Status}; -pub use deprecated::Deprecated; pub use error::{Error, Result}; pub use log::{Log, RedactedUrl}; #[cfg(feature = "_transport")] -pub use server::{Listener, Request, Server, Transport}; +pub use server::{Listener, Server}; // Re-export these crates. pub use moq_net; diff --git a/rs/moq-tokio/src/listen.rs b/rs/moq-tokio/src/listen.rs index 0cd792e262..003f0ac1d2 100644 --- a/rs/moq-tokio/src/listen.rs +++ b/rs/moq-tokio/src/listen.rs @@ -5,6 +5,77 @@ use crate::QuicBackend; +/// A QUIC listen address. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Bind { + /// A resolved socket address. + Addr(std::net::SocketAddr), + /// A host and port to resolve when the listener binds. + Host(String, u16), +} + +impl Bind { + #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] + pub(crate) fn resolve(&self) -> std::io::Result { + match self { + Self::Addr(addr) => Ok(*addr), + Self::Host(host, port) => crate::util::resolve(Some(&format!("{host}:{port}")), ""), + } + } +} + +impl std::str::FromStr for Bind { + type Err = std::net::AddrParseError; + + fn from_str(value: &str) -> Result { + let socket_error = match value.parse() { + Ok(addr) => return Ok(Self::Addr(addr)), + Err(err) => err, + }; + + let Some((host, port)) = value.rsplit_once(':') else { + return Err(socket_error); + }; + if host.is_empty() || host.contains(':') { + return Err(socket_error); + } + let Ok(port) = port.parse() else { + return Err(socket_error); + }; + Ok(Self::Host(host.to_owned(), port)) + } +} + +impl std::fmt::Display for Bind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Addr(addr) => addr.fmt(f), + Self::Host(host, port) => write!(f, "{host}:{port}"), + } + } +} + +impl serde::Serialize for Bind { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> serde::Deserialize<'de> for Bind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(serde::de::Error::custom) + } +} + /// The accept side of an endpoint: what to listen on and how to be trusted. /// /// Derives [`usage::Args`], so flatten it into a binary's own parser with @@ -16,13 +87,12 @@ use crate::QuicBackend; pub struct Config { /// Listen for QUIC (UDP) on the given address. Defaults to `[::]:443`. /// - /// Accepts standard socket address syntax (e.g. `[::]:443`) or a DNS - /// `host:port` pair (e.g. `fly-global-services:443`), resolved at bind time - /// (first address only; Quinn cannot bind multiple). Leave unset while a + /// Text configuration accepts socket addresses and `host:port` names. Hostnames + /// are resolved when the listener binds. Leave unset while a /// `tcp`/`unix` listener is configured to run a stream-only server with no /// QUIC. #[usage(name = "listen", long = "listen", env = "MOQ_LISTEN", setting = "listen.bind")] - pub bind: Option, + pub bind: Option, /// The released `listen` key, kept so [`deprecated`](Self::deprecated) can name [`bind`](Self::bind). #[serde(default, skip_serializing)] @@ -124,8 +194,8 @@ pub struct Config { env = "MOQ_LISTEN_QUIC_LB_ID", setting = "listen.lb_id" )] - #[serde(default, skip_serializing_if = "Option::is_none")] - pub lb_id: Option, + #[serde(default, rename = "__cli_lb_id", skip_serializing_if = "Option::is_none")] + pub(crate) lb_id: Option, /// Number of random nonce bytes in QUIC-LB connection IDs. /// Must be at least 4, and server_id + nonce + 1 must not exceed 20. @@ -133,10 +203,16 @@ pub struct Config { name = "listen-quic-lb-nonce", long = "listen-quic-lb-nonce", env = "MOQ_LISTEN_QUIC_LB_NONCE", - setting = "listen.lb_nonce" + setting = "listen.lb_nonce", + requires = "--listen-quic-lb-id" )] + #[serde(default, rename = "__cli_lb_nonce", skip_serializing_if = "Option::is_none")] + pub(crate) lb_nonce: Option, + + /// QUIC-LB connection-ID encoding. + #[usage(skip)] #[serde(default, skip_serializing_if = "Option::is_none")] - pub lb_nonce: Option, + pub load_balancer: Option, /// The released `--server-*` spellings and their env vars, kept parsing but /// hidden. Never read as settings: [`Config::deprecated`] names what replaced @@ -245,8 +321,8 @@ pub(crate) struct Legacy { impl Legacy { /// The released spellings in use, each paired with what replaced it. - fn deprecated(&self) -> crate::Deprecated { - let mut found = crate::Deprecated::default(); + fn deprecated(&self) -> crate::cli::Deprecated { + let mut found = crate::cli::Deprecated::default(); if self.bind.is_some() { found.flag("--server-bind", Some("MOQ_SERVER_BIND"), "--listen / MOQ_LISTEN"); } @@ -304,7 +380,7 @@ impl Config { /// old spellings are parsed so the process can name their replacement, not so it /// can honor them: [`crate::Server::new`] rejects them too, so a config that /// skipped the check can't reach a listener that quietly ignored half of it. - pub fn deprecated(&self) -> crate::Deprecated { + pub fn deprecated(&self) -> crate::cli::Deprecated { let mut found = self.legacy.deprecated(); if self.listen.is_some() { found.toml("listen", "bind", None); @@ -320,16 +396,20 @@ impl Config { found } - /// Reject a QUIC-LB nonce with no server id to pair it with. - /// - /// Checked here rather than with Usage's `requires`, which can only name one arg - /// id, and the nonce reaches this config from a TOML file as well as the flag. #[cfg(feature = "_transport")] pub(crate) fn validate(&self) -> crate::Result<()> { - match (self.lb_id.is_some(), self.lb_nonce.is_some()) { - (false, true) => Err(crate::Error::LbNonceWithoutId), - _ => Ok(()), - } + Ok(()) + } + + #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] + pub(crate) fn load_balancer(&self) -> Option { + self.lb_id + .clone() + .map(|id| crate::quic::LoadBalancer { + id, + nonce: self.lb_nonce.unwrap_or(8), + }) + .or_else(|| self.load_balancer.clone()) } } @@ -406,7 +486,10 @@ mod tests { #[test] fn a_canonical_spelling_does_not_excuse_a_released_one() { let config = config_from(["test", "--listen", "[::]:443", "--server-bind", "[::]:4443"]); - assert_eq!(config.bind.as_deref(), Some("[::]:443")); + assert_eq!( + config.bind.as_ref().map(ToString::to_string).as_deref(), + Some("[::]:443") + ); assert!(config.deprecated().to_string().contains("--server-bind")); let config = config_from(["test", "--listen", "[::]:443"]); @@ -426,16 +509,53 @@ mod tests { ); } - /// A nonce with no server id is meaningless. Checked here rather than with a - /// Usage `requires`, which can only name one arg id and never sees a TOML file. - #[cfg(feature = "_transport")] #[test] - fn lb_nonce_needs_an_id() { - let config = config_from(["test", "--listen-quic-lb-nonce", "8"]); - assert!(matches!(config.validate(), Err(crate::Error::LbNonceWithoutId))); + fn bind_host_round_trips_as_text() { + #[derive(serde::Serialize, serde::Deserialize)] + struct Wrapper { + bind: Bind, + } + + let expected = Bind::Host("relay.example.com".to_string(), 443); + let encoded = toml::to_string(&Wrapper { bind: expected.clone() }).expect("serialize"); + let decoded: Wrapper = toml::from_str(&encoded).expect("deserialize"); + assert_eq!(decoded.bind, expected); + + assert!("relay.example.com:443:8443".parse::().is_err()); + } + + #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] + #[test] + fn cli_load_balancer_survives_the_merge_round_trip() { + let config = config_from(["test", "--listen-quic-lb-id", "ab", "--listen-quic-lb-nonce", "9"]); + let encoded = toml::Value::try_from(config).expect("serialize"); + let decoded: Config = encoded.try_into().expect("deserialize"); + assert_eq!( + decoded.load_balancer(), + Some(crate::quic::LoadBalancer { + id: "ab".parse().unwrap(), + nonce: 9, + }) + ); + } - let config = config_from(["test", "--listen-quic-lb-id", "ab", "--listen-quic-lb-nonce", "8"]); - assert!(config.validate().is_ok()); + /// Programmatic configuration keeps the QUIC-LB id and nonce paired. + #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] + #[test] + fn load_balancer_is_a_single_typed_value() { + let config: Config = toml::from_str( + r#" +load_balancer = { id = "ab", nonce = 8 } +"#, + ) + .unwrap(); + assert_eq!( + config.load_balancer(), + Some(crate::quic::LoadBalancer { + id: "ab".parse().unwrap(), + nonce: 8, + }) + ); } /// A stream-only server opens no QUIC listener even with a bind configured, @@ -443,7 +563,7 @@ mod tests { #[tokio::test] async fn init_streams_leaves_quic_alone() { let config = Config { - bind: Some("127.0.0.1:0".to_string()), + bind: Some("127.0.0.1:0".parse().unwrap()), ..Default::default() }; @@ -457,7 +577,7 @@ mod tests { #[tokio::test] async fn init_still_defaults_to_quic() { let mut config = Config { - bind: Some("127.0.0.1:0".to_string()), + bind: Some("127.0.0.1:0".parse().unwrap()), ..Default::default() }; config.tls.generate = vec!["localhost".to_string()]; @@ -470,7 +590,10 @@ mod tests { #[test] fn canonical_spellings_parse() { let config = config_from(["test", "--listen", "[::]:443", "--listen-version", "moq-lite-03"]); - assert_eq!(config.bind.as_deref(), Some("[::]:443")); + assert_eq!( + config.bind.as_ref().map(ToString::to_string).as_deref(), + Some("[::]:443") + ); assert_eq!(config.version, vec!["moq-lite-03".parse::().unwrap()]); } } diff --git a/rs/moq-tokio/src/noq.rs b/rs/moq-tokio/src/noq.rs index 0eae1f4daa..fb6378f39b 100644 --- a/rs/moq-tokio/src/noq.rs +++ b/rs/moq-tokio/src/noq.rs @@ -234,11 +234,11 @@ pub enum Error { /// whichever address happened to be unroutable or to blackhole until its /// timeout. A host with a single address reports that error directly instead. #[error("all {} connection attempts failed: {}", .0.len(), crate::failover::describe(.0))] - Failover(Vec>), + Failover(Vec>), } impl crate::failover::Aggregate for Error { - fn aggregate(failures: Vec>) -> Self { + fn aggregate(failures: Vec>) -> Self { Self::Failover(failures) } @@ -569,13 +569,19 @@ impl NoqServer { // There's a bit more boilerplate to make a generic endpoint. let runtime = noq::default_runtime().ok_or(Error::NoRuntime)?; - let listen = - crate::util::resolve(config.bind.as_deref(), crate::server::DEFAULT_BIND).map_err(Error::ResolveBind)?; + let listen = config + .bind + .as_ref() + .map(crate::listen::Bind::resolve) + .transpose() + .map_err(Error::ResolveBind)? + .unwrap_or(crate::server::DEFAULT_BIND); + let load_balancer = config.load_balancer(); // Configure connection ID generator with server ID if provided let mut endpoint_config = noq::EndpointConfig::default(); if let Some(shard) = member.as_ref().map(listen::Member::shard) { - if config.lb_id.is_some() { + if load_balancer.is_some() { return Err(Error::ShardWithQuicLb); } tracing::debug!( @@ -584,8 +590,9 @@ impl NoqServer { "encoding the shard in connection IDs" ); endpoint_config.cid_generator(Arc::new(move || Box::new(ShardIdGenerator::new(shard)))); - } else if let Some(server_id) = config.lb_id { - let nonce_len = config.lb_nonce.unwrap_or(8); + } else if let Some(load_balancer) = load_balancer { + let server_id = load_balancer.id; + let nonce_len = load_balancer.nonce; if nonce_len < 4 { return Err(Error::QuicLbNonceTooSmall); } @@ -820,12 +827,10 @@ mod tests { fn apply_windows_writes_each_field() { let defaults = format!("{:?}", noq::TransportConfig::default()); - let quic = crate::quic::Config { - receive_window: Some(64 << 20), - stream_receive_window: Some(8 << 20), - send_window: Some(32 << 20), - ..Default::default() - }; + let mut quic = crate::quic::Config::default(); + quic.receive_window = Some(64 << 20); + quic.stream_receive_window = Some(8 << 20); + quic.send_window = Some(32 << 20); let mut transport = noq::TransportConfig::default(); apply_windows(&mut transport, &quic.resolve()); @@ -863,7 +868,7 @@ mod tests { #[tokio::test] async fn default_reaches_the_live_connection() { let server_config = listen::Config { - bind: Some("127.0.0.1:0".to_string()), + bind: Some("127.0.0.1:0".parse().unwrap()), tls: crate::tls::Listen { generate: vec!["localhost".into()], ..Default::default() diff --git a/rs/moq-tokio/src/quic.rs b/rs/moq-tokio/src/quic.rs index c617e045b2..41fc94eb00 100644 --- a/rs/moq-tokio/src/quic.rs +++ b/rs/moq-tokio/src/quic.rs @@ -43,6 +43,16 @@ impl std::str::FromStr for ServerId { } } +/// QUIC-LB connection-ID encoding. +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[non_exhaustive] +pub struct LoadBalancer { + /// The routable server identifier encoded into each connection ID. + pub id: ServerId, + /// Number of random nonce bytes appended to each connection ID. + pub nonce: usize, +} + /// The congestion control family for a QUIC connection. /// /// This selects a family rather than a named algorithm because each backend ships a @@ -115,25 +125,37 @@ pub struct Config { pub gso: Option, /// Idle timeout before an inactive connection is dropped. Defaults to 30s. + #[usage(skip)] + #[serde(with = "crate::cli::duration::serde_duration")] + pub idle_timeout: Duration, + #[usage( name = "quic-idle-timeout", long = "quic-idle-timeout", env = "MOQ_QUIC_IDLE_TIMEOUT", + default_value_t = CliDuration::fallback(DEFAULT_IDLE_TIMEOUT), default = "30s", setting = "quic.idle_timeout" )] - pub idle_timeout: CliDuration, + #[serde(default, rename = "__cli_idle_timeout", skip_serializing_if = "Option::is_none")] + idle_timeout_arg: Option, /// Keep-alive ping interval. Defaults to 5s; set `0s` to disable. /// Ignored by the iroh backend, which has no keep-alive knob. + #[usage(skip)] + #[serde(with = "crate::cli::duration::serde_duration")] + pub keep_alive: Duration, + #[usage( name = "quic-keep-alive", long = "quic-keep-alive", env = "MOQ_QUIC_KEEP_ALIVE", + default_value_t = CliDuration::fallback(DEFAULT_KEEP_ALIVE), default = "5s", setting = "quic.keep_alive" )] - pub keep_alive: CliDuration, + #[serde(default, rename = "__cli_keep_alive", skip_serializing_if = "Option::is_none")] + keep_alive_arg: Option, /// Enable path MTU discovery. Defaults to off. #[serde(skip_serializing_if = "Option::is_none")] @@ -228,8 +250,10 @@ impl Default for Config { Self { max_streams: None, gso: None, - idle_timeout: DEFAULT_IDLE_TIMEOUT.into(), - keep_alive: DEFAULT_KEEP_ALIVE.into(), + idle_timeout: DEFAULT_IDLE_TIMEOUT, + idle_timeout_arg: None, + keep_alive: DEFAULT_KEEP_ALIVE, + keep_alive_arg: None, mtu_discovery: None, congestion_control: None, receive_window: None, @@ -381,10 +405,10 @@ impl Legacy { /// The note is the same on every line and is the point of the message: these /// knobs used to bound one direction, so a deployment moving to the shared /// section is also widening what the value applies to. - fn deprecated(&self) -> crate::Deprecated { + fn deprecated(&self) -> crate::cli::Deprecated { const SHARED: &str = "now applies to dialed and accepted connections alike"; - let mut found = crate::Deprecated::default(); + let mut found = crate::cli::Deprecated::default(); for (used, old, env) in [ ( self.client_max_streams.is_some(), @@ -459,7 +483,7 @@ impl Config { /// A binary checks this before anything else and exits when it isn't empty. The /// old spellings are parsed so the process can name their replacement, not so it /// can honor them. - pub fn deprecated(&self) -> crate::Deprecated { + pub fn deprecated(&self) -> crate::cli::Deprecated { self.legacy.deprecated() } @@ -472,7 +496,7 @@ impl Config { Some(_) if cfg!(not(feature = "qlog")) => Err(crate::Error::QlogUnsupported), _ => Ok(()), }?; - validate_idle_timeout(Some(self.idle_timeout.into_std()))?; + validate_idle_timeout(Some(CliDuration::resolve(self.idle_timeout_arg, self.idle_timeout)))?; validate_windows(self) } @@ -484,12 +508,14 @@ impl Config { pub fn resolve(&self) -> Resolved { // A zero keep-alive means "disabled"; anything else (including unset) keeps // the connection warm, defaulting to 5s. - let keep_alive = (!self.keep_alive.is_zero()).then(|| self.keep_alive.into_std()); + let idle_timeout = CliDuration::resolve(self.idle_timeout_arg, self.idle_timeout); + let keep_alive = CliDuration::resolve(self.keep_alive_arg, self.keep_alive); + let keep_alive = (!keep_alive.is_zero()).then_some(keep_alive); Resolved { max_streams: self.max_streams.unwrap_or(DEFAULT_MAX_STREAMS), gso: self.gso, - idle_timeout: self.idle_timeout.into_std(), + idle_timeout, keep_alive, mtu_discovery: self.mtu_discovery.unwrap_or(false), congestion_control: self.congestion_control, @@ -655,13 +681,13 @@ mod tests { #[test] fn zero_keep_alive_disables_it() { let disabled = Config { - keep_alive: Duration::ZERO.into(), + keep_alive: Duration::ZERO, ..Default::default() }; assert_eq!(disabled.resolve().keep_alive, None); let explicit = Config { - keep_alive: Duration::from_secs(2).into(), + keep_alive: Duration::from_secs(2), ..Default::default() }; assert_eq!(explicit.resolve().keep_alive, Some(Duration::from_secs(2))); @@ -803,19 +829,19 @@ mod tests { #[test] fn idle_timeout_beyond_the_varint_is_rejected() { let over = Config { - idle_timeout: (MAX_IDLE_TIMEOUT + Duration::from_millis(1)).into(), + idle_timeout: (MAX_IDLE_TIMEOUT + Duration::from_millis(1)), ..Default::default() }; assert!(matches!(over.validate(), Err(crate::Error::IdleTimeoutRange))); let saturated = Config { - idle_timeout: Duration::from_millis(u64::MAX).into(), + idle_timeout: Duration::from_millis(u64::MAX), ..Default::default() }; assert!(matches!(saturated.validate(), Err(crate::Error::IdleTimeoutRange))); let at_limit = Config { - idle_timeout: MAX_IDLE_TIMEOUT.into(), + idle_timeout: MAX_IDLE_TIMEOUT, ..Default::default() }; assert!(at_limit.validate().is_ok()); diff --git a/rs/moq-tokio/src/quiche.rs b/rs/moq-tokio/src/quiche.rs index 631a60a441..e9edecd84d 100644 --- a/rs/moq-tokio/src/quiche.rs +++ b/rs/moq-tokio/src/quiche.rs @@ -147,11 +147,11 @@ pub enum Error { /// whichever address happened to be unroutable or to blackhole until its /// timeout. A host with a single address reports that error directly instead. #[error("all {} connection attempts failed: {}", .0.len(), crate::failover::describe(.0))] - Failover(Vec>), + Failover(Vec>), } impl crate::failover::Aggregate for Error { - fn aggregate(failures: Vec>) -> Self { + fn aggregate(failures: Vec>) -> Self { Self::Failover(failures) } @@ -623,7 +623,7 @@ impl web_transport_quiche::ez::CertResolver for ServeCerts { impl QuicheServer { pub fn new(config: listen::Config, quic: &crate::quic::Config, member: Option) -> Result { - if config.lb_id.is_some() { + if config.load_balancer().is_some() { tracing::warn!("QUIC-LB is not supported with the quiche backend; ignoring server ID"); } @@ -637,8 +637,13 @@ impl QuicheServer { let quic = quic.resolve(); - let listen = - crate::util::resolve(config.bind.as_deref(), crate::server::DEFAULT_BIND).map_err(Error::ResolveBind)?; + let listen = config + .bind + .as_ref() + .map(crate::listen::Bind::resolve) + .transpose() + .map_err(Error::ResolveBind)? + .unwrap_or(crate::server::DEFAULT_BIND); let socket = crate::bind::udp(crate::bind::Udp::new(listen))?; // Pinning client fingerprints needs a verifier that runs per handshake, which @@ -848,11 +853,9 @@ mod tests { /// Both halves have to land for the window to be what was asked for. #[test] fn apply_settings_pins_both_halves_of_each_window() { - let quic = crate::quic::Config { - receive_window: Some(64 << 20), - stream_receive_window: Some(8 << 20), - ..Default::default() - }; + let mut quic = crate::quic::Config::default(); + quic.receive_window = Some(64 << 20); + quic.stream_receive_window = Some(8 << 20); let mut settings = web_transport_quiche::Settings::default(); apply_settings(&mut settings, &quic.resolve()).unwrap(); @@ -881,10 +884,8 @@ mod tests { /// dropped: an operator must not believe a ceiling is in force that is not. #[test] fn send_window_is_refused() { - let quic = crate::quic::Config { - send_window: Some(32 << 20), - ..Default::default() - }; + let mut quic = crate::quic::Config::default(); + quic.send_window = Some(32 << 20); let mut settings = web_transport_quiche::Settings::default(); assert!(matches!( diff --git a/rs/moq-tokio/src/quinn.rs b/rs/moq-tokio/src/quinn.rs index cf43c83876..5bb566fe2b 100644 --- a/rs/moq-tokio/src/quinn.rs +++ b/rs/moq-tokio/src/quinn.rs @@ -250,11 +250,11 @@ pub enum Error { /// whichever address happened to be unroutable or to blackhole until its /// timeout. A host with a single address reports that error directly instead. #[error("all {} connection attempts failed: {}", .0.len(), crate::failover::describe(.0))] - Failover(Vec>), + Failover(Vec>), } impl crate::failover::Aggregate for Error { - fn aggregate(failures: Vec>) -> Self { + fn aggregate(failures: Vec>) -> Self { Self::Failover(failures) } @@ -585,13 +585,19 @@ impl QuinnServer { // There's a bit more boilerplate to make a generic endpoint. let runtime = quinn::default_runtime().ok_or(Error::NoRuntime)?; - let listen = - crate::util::resolve(config.bind.as_deref(), crate::server::DEFAULT_BIND).map_err(Error::ResolveBind)?; + let listen = config + .bind + .as_ref() + .map(crate::listen::Bind::resolve) + .transpose() + .map_err(Error::ResolveBind)? + .unwrap_or(crate::server::DEFAULT_BIND); + let load_balancer = config.load_balancer(); // Configure connection ID generator with server ID if provided let mut endpoint_config = quinn::EndpointConfig::default(); if let Some(shard) = member.as_ref().map(listen::Member::shard) { - if config.lb_id.is_some() { + if load_balancer.is_some() { return Err(Error::ShardWithQuicLb); } tracing::debug!( @@ -600,8 +606,9 @@ impl QuinnServer { "encoding the shard in connection IDs" ); endpoint_config.cid_generator(move || Box::new(ShardIdGenerator::new(shard))); - } else if let Some(server_id) = config.lb_id { - let nonce_len = config.lb_nonce.unwrap_or(8); + } else if let Some(load_balancer) = load_balancer { + let server_id = load_balancer.id; + let nonce_len = load_balancer.nonce; if nonce_len < 4 { return Err(Error::QuicLbNonceTooSmall); } @@ -871,12 +878,10 @@ mod tests { fn apply_windows_writes_each_field() { let defaults = format!("{:?}", quinn::TransportConfig::default()); - let quic = crate::quic::Config { - receive_window: Some(64 << 20), - stream_receive_window: Some(8 << 20), - send_window: Some(32 << 20), - ..Default::default() - }; + let mut quic = crate::quic::Config::default(); + quic.receive_window = Some(64 << 20); + quic.stream_receive_window = Some(8 << 20); + quic.send_window = Some(32 << 20); let mut transport = quinn::TransportConfig::default(); apply_windows(&mut transport, &quic.resolve()); @@ -914,7 +919,7 @@ mod tests { #[tokio::test] async fn delay_reaches_the_live_connection() { let server_config = listen::Config { - bind: Some("127.0.0.1:0".to_string()), + bind: Some("127.0.0.1:0".parse().unwrap()), tls: crate::tls::Listen { generate: vec!["localhost".into()], ..Default::default() @@ -923,10 +928,8 @@ mod tests { }; // One shared tuning for both roles, the way a binary composes them. - let quic = crate::quic::Config { - congestion_control: Some(CongestionControl::Delay), - ..Default::default() - }; + let mut quic = crate::quic::Config::default(); + quic.congestion_control = Some(CongestionControl::Delay); let server = QuinnServer::new(server_config, &quic, None).expect("server init"); let addr = server.local_addr().expect("local addr"); diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index 72067c0a39..555d4b97dc 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -33,7 +33,12 @@ use futures::stream::StreamExt; impl crate::listen::Config { /// Build the [`Server`] this config describes, binding its listeners. pub fn init(self, quic: crate::quic::Config) -> crate::Result { - Config::default().with_listen(self).with_quic(quic).init() + Config { + listen: self, + quic, + ..Default::default() + } + .init() } /// Build a server with only the `tcp`/`unix` listeners, leaving the QUIC @@ -48,7 +53,11 @@ impl crate::listen::Config { /// Distinct from clearing [`bind`](crate::listen::Config::bind), which still /// opens the default QUIC listener when nothing else is configured. pub fn init_streams(self) -> crate::Result { - Server::build(Config::default().with_listen(self), Parts::Streams) + Config { + listen: self, + ..Default::default() + } + .init_streams() } /// Returns the configured versions, defaulting to all if none specified. @@ -86,7 +95,8 @@ impl crate::listen::Config { /// Default bind address used when [`crate::listen::Config::bind`] is not set. #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] -pub(crate) const DEFAULT_BIND: &str = "[::]:443"; +pub(crate) const DEFAULT_BIND: net::SocketAddr = + net::SocketAddr::V6(net::SocketAddrV6::new(net::Ipv6Addr::UNSPECIFIED, 443, 0, 0)); /// Which listeners a [`Server`] opens, out of the ones its config describes. /// @@ -153,7 +163,7 @@ impl Parts { /// another [`Server::new`] parameter. The mirror of [`crate::client::Config`]. /// /// Most callers want the [`crate::listen::Config::init`] shorthand instead. -#[derive(Clone, Debug, Default)] +#[derive(Default)] #[non_exhaustive] pub struct Config { /// The accept side of the endpoint: what to listen on and how to be trusted. @@ -161,24 +171,50 @@ pub struct Config { /// QUIC socket and transport settings, shared with [`crate::Client`]. pub quic: crate::quic::Config, + + /// A standalone WebSocket listener on a separate TCP port. + #[cfg(feature = "websocket")] + pub websocket: Option, + + /// An Iroh endpoint to accept sessions from. + #[cfg(feature = "iroh")] + pub iroh: Option, + + /// The origin published to every accepted session. + pub publisher: Option, + + /// The origin that receives every accepted session's publications. + pub subscriber: Option, + + /// Per-connection statistics context. + pub stats: moq_net::stats::Session, } impl Config { - /// Set the accept side, returning `self` for chaining. - pub fn with_listen(mut self, listen: crate::listen::Config) -> Self { - self.listen = listen; - self + /// Build the [`Server`] this config describes. + pub fn init(self) -> crate::Result { + Server::new(self) } - /// Set the QUIC settings, returning `self` for chaining. - pub fn with_quic(mut self, quic: crate::quic::Config) -> Self { - self.quic = quic; - self + /// Build a server with only its TCP and Unix listeners. + pub fn init_streams(self) -> crate::Result { + Server::build(self, Parts::Streams) } - /// Build the [`Server`] this config describes. - pub fn init(self) -> crate::Result { - Server::new(self) + /// Copy the settings one QUIC worker owns. + #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] + pub(crate) fn worker(&self) -> Self { + Self { + listen: self.listen.clone(), + quic: self.quic.clone(), + #[cfg(feature = "websocket")] + websocket: None, + #[cfg(feature = "iroh")] + iroh: None, + publisher: self.publisher.clone(), + subscriber: self.subscriber.clone(), + stats: self.stats.clone(), + } } } @@ -237,7 +273,15 @@ impl Server { /// [`Self::new`], for a caller that opens only some of the config's listeners. pub(crate) fn build(config: Config, parts: Parts) -> crate::Result { let Config { - listen: config, quic, .. + listen: config, + quic, + #[cfg(feature = "websocket")] + websocket, + #[cfg(feature = "iroh")] + iroh, + publisher, + subscriber, + stats, } = config; // Refuse here rather than in `init`, so a caller that skipped its own check @@ -351,14 +395,22 @@ impl Server { unix_allow, ); + let mut moq = moq_net::Server::new().with_versions(versions.clone()).with_stats(stats); + if let Some(publisher) = publisher { + moq = moq.with_publisher(publisher); + } + if let Some(subscriber) = subscriber { + moq = moq.with_subscriber(subscriber); + } + Ok(Server { accept: Default::default(), - moq: moq_net::Server::new().with_versions(versions.clone()), + moq, versions, #[cfg(any(feature = "tcp", all(feature = "uds", unix)))] streams, #[cfg(feature = "iroh")] - iroh: None, + iroh, #[cfg(feature = "noq")] noq, #[cfg(feature = "quinn")] @@ -366,47 +418,10 @@ impl Server { #[cfg(feature = "quiche")] quiche, #[cfg(feature = "websocket")] - websocket: None, + websocket, }) } - /// Add a standalone WebSocket listener on a separate TCP port. - /// - /// This is useful for simple applications that want WebSocket on a dedicated port. - /// For applications that need WebSocket on the same HTTP port (e.g. moq-relay), - /// use `qmux::Session::accept()` with your own HTTP framework instead. - #[cfg(feature = "websocket")] - pub fn with_websocket(mut self, websocket: crate::websocket::Listener) -> Self { - self.websocket = Some(websocket); - self - } - - /// Also accept sessions over the given Iroh endpoint. - #[cfg(feature = "iroh")] - pub fn with_iroh(mut self, iroh: iroh::Endpoint) -> Self { - self.iroh = Some(iroh); - self - } - - /// Publish the given origin to every session this server accepts. - pub fn with_publisher(mut self, publish: impl moq_net::Consume) -> Self { - self.moq = self.moq.with_publisher(publish); - self - } - - /// Subscribe to every session's broadcasts, ingesting them into the given origin. - pub fn with_subscriber(mut self, subscribe: moq_net::origin::Producer) -> Self { - self.moq = self.moq.with_subscriber(subscribe); - self - } - - /// Attach a per-connection [`moq_net::stats::Session`] context to all sessions - /// accepted by this server. - pub fn with_stats(mut self, stats: moq_net::stats::Session) -> Self { - self.moq = self.moq.with_stats(stats); - self - } - /// Accept sessions until the listener stops, serving `origin` to each subscriber. /// /// Spawns a task per session and logs (rather than propagates) per-session @@ -416,14 +431,18 @@ impl Server { /// auth or routing, [`listen`](Self::listen) and drive [`Listener::accept`] /// yourself instead. pub async fn serve_publish(self, origin: moq_net::origin::Consumer) -> crate::Result<()> { - self.with_publisher(origin).serve().await + let mut server = self; + server.moq = server.moq.with_publisher(origin); + server.serve().await } /// Accept sessions until the listener stops, ingesting each publisher into `origin`. /// /// The mirror of [`serve_publish`](Self::serve_publish) for the consume direction. pub async fn serve_consume(self, origin: moq_net::origin::Producer) -> crate::Result<()> { - self.with_subscriber(origin).serve().await + let mut server = self; + server.moq = server.moq.with_subscriber(origin); + server.serve().await } /// Accept sessions until the listener stops, serving `publish` to each subscriber @@ -437,7 +456,9 @@ impl Server { publish: moq_net::origin::Consumer, subscribe: moq_net::origin::Producer, ) -> crate::Result<()> { - self.with_publisher(publish).with_subscriber(subscribe).serve().await + let mut server = self; + server.moq = server.moq.with_publisher(publish).with_subscriber(subscribe); + server.serve().await } /// Shared accept loop for the `serve_*` entry points; the origin is already @@ -516,7 +537,7 @@ impl Server { /// The accept-loop health of every listener this server owns that performs a real /// `accept(2)`: the `tcp`/`unix` stream listeners and, if one was set, - /// [`with_websocket`](Self::with_websocket). + /// [`Config::websocket`]. /// /// Empty on a QUIC-only server, which is the honest answer rather than a /// convenient one: a QUIC backend multiplexes every session over one UDP socket, @@ -733,7 +754,7 @@ impl Server { } } - /// The Iroh endpoint from [`with_iroh`](Self::with_iroh), if one was set. + /// The Iroh endpoint from [`Config::iroh`], if one was set. #[cfg(feature = "iroh")] pub fn iroh_endpoint(&self) -> Option<&iroh::Endpoint> { self.iroh.as_ref() @@ -761,8 +782,7 @@ impl Server { Err(Error::NoBackend("no QUIC listener configured")) } - /// The address the WebSocket listener from - /// [`with_websocket`](Self::with_websocket) bound to, if one was set. + /// The address the WebSocket listener from [`Config::websocket`] bound to, if one was set. #[cfg(feature = "websocket")] pub fn websocket_local_addr(&self) -> Option { self.websocket.as_ref().and_then(|ws| ws.local_addr().ok()) @@ -773,19 +793,18 @@ impl Server { #[cfg(any(feature = "tcp", all(feature = "uds", unix)))] self.streams.shutdown().await; + self.close(); + #[cfg(feature = "noq")] - if let Some(noq) = self.noq.as_mut() { - noq.close(); + if self.noq.is_some() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; } #[cfg(feature = "quinn")] - if let Some(quinn) = self.quinn.as_mut() { - quinn.close(); + if self.quinn.is_some() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; } #[cfg(feature = "quiche")] - if let Some(quiche) = self.quiche.as_mut() { - quiche.close(); + if self.quiche.is_some() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; } #[cfg(feature = "iroh")] @@ -797,6 +816,22 @@ impl Server { let _ = self.websocket.take(); } } + + /// Start the synchronous half of listener shutdown. + fn close(&mut self) { + #[cfg(feature = "noq")] + if let Some(noq) = self.noq.as_mut() { + noq.close(); + } + #[cfg(feature = "quinn")] + if let Some(quinn) = self.quinn.as_mut() { + quinn.close(); + } + #[cfg(feature = "quiche")] + if let Some(quiche) = self.quiche.as_mut() { + quiche.close(); + } + } } /// A [`Server`] that is listening: the only thing sessions can be accepted from. @@ -813,7 +848,7 @@ impl Listener { /// /// This returns a [Request] instead of a session so the connection can be /// rejected early on an invalid path or missing auth. Call [Request::ok] or - /// [Request::close] to complete the handshake. + /// [Request::reject] to complete the handshake. /// /// `None` means every configured listener has stopped and no handshake is still /// in flight, so nothing can arrive again. Everything is already bound, so a bind @@ -840,8 +875,7 @@ impl Listener { self.server.local_addr() } - /// The address the WebSocket listener from - /// [`Server::with_websocket`] bound to, if one was set. + /// The address the WebSocket listener from [`Config::websocket`] bound to, if one was set. #[cfg(feature = "websocket")] pub fn websocket_local_addr(&self) -> Option { self.server.websocket_local_addr() @@ -861,13 +895,19 @@ impl Listener { self.server.accept_health() } - /// The Iroh endpoint from [`Server::with_iroh`], if one was set. + /// The Iroh endpoint from [`Config::iroh`], if one was set. #[cfg(feature = "iroh")] pub fn iroh_endpoint(&self) -> Option<&iroh::Endpoint> { self.server.iroh_endpoint() } } +impl Drop for Listener { + fn drop(&mut self) { + self.server.close(); + } +} + /// Complete one accepted [`Request`] and wait for the session to close. async fn serve_session(request: Request) -> crate::Result<()> { let session = request.ok().await?; @@ -1232,7 +1272,7 @@ impl std::fmt::Display for Transport { /// all populated consistently regardless of transport. [Self::with_publisher] and /// [Self::with_subscriber] configure what is published and subscribed to on the session; /// otherwise the Server's configuration is used by default. Call [Self::ok] to start the -/// session, or [Self::close] to reject it (which closes the just-established session). +/// session, or [Self::reject] to reject it (which closes the just-established session). pub struct Request { transport: Transport, /// The request URL, for transports that carry one (QUIC/WebTransport/WebSocket). `None` for the @@ -1249,6 +1289,18 @@ pub struct Request { kind: RequestKind, } +/// Why an incoming session was rejected. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Reject { + /// The request supplied no acceptable credentials. + Unauthorized, + /// The credentials do not grant the requested operation. + Forbidden, + /// An application-defined HTTP-style status code. + App(u16), +} + /// Delegate a read-only call to the inner [`moq_net::server::Handshake`], whatever the transport. macro_rules! request_ref { ($self:expr, $r:ident => $body:expr) => { @@ -1307,10 +1359,10 @@ impl Request { /// Reject the session. The transport is already accepted, so this closes the /// just-established MoQ session rather than answering the transport handshake: /// the `code` (an HTTP-style status the caller passes) maps to a MoQ close reason. - pub async fn close(self, code: u16) -> crate::Result<()> { - let err = match code { - 401 | 403 => moq_net::Error::Unauthorized, - other => moq_net::Error::App(other), + pub async fn reject(self, reject: Reject) -> crate::Result<()> { + let err = match reject { + Reject::Unauthorized | Reject::Forbidden => moq_net::Error::Unauthorized, + Reject::App(code) => moq_net::Error::App(code), }; request_into!(self.kind, request => request.close(err)); Ok(()) @@ -1547,10 +1599,12 @@ mod tests { fn accept_health_covers_stream_listeners_before_they_bind() { let mut config = crate::listen::Config::default(); config.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); - let server = Config::default() - .with_listen(config) - .init() - .expect("stream-only server"); + let server = Config { + listen: config, + ..Default::default() + } + .init() + .expect("stream-only server"); let names: Vec<_> = server.accept_health().iter().map(|h| h.listener()).collect(); assert_eq!(names, vec!["tcp"], "the tcp listener must report before it binds"); @@ -1580,10 +1634,12 @@ mod tests { let mut config = crate::listen::Config::default(); config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); config.unix.bind = Some(occupied); - let server = Config::default() - .with_listen(config) - .init() - .expect("stream-only server"); + let server = Config { + listen: config, + ..Default::default() + } + .init() + .expect("stream-only server"); assert!(server.listen().await.is_err(), "the unix bind must fail"); std::net::TcpListener::bind(("127.0.0.1", port)).expect("the tcp port must be free again"); @@ -1599,13 +1655,15 @@ mod tests { let mut config = crate::listen::Config::default(); config.tcp.bind = Some(addr); - let listener = Config::default() - .with_listen(config) - .init() - .expect("stream-only server") - .listen() - .await - .expect("listen"); + let listener = Config { + listen: config, + ..Default::default() + } + .init() + .expect("stream-only server") + .listen() + .await + .expect("listen"); listener.close().await; std::net::TcpListener::bind(addr).expect("close must release the tcp port"); @@ -1719,10 +1777,12 @@ mod tests { let mut config = crate::listen::Config::default(); config.tcp.bind = Some(addr); - let server = Config::default() - .with_listen(config) - .init() - .expect("stream-only server"); + let server = Config { + listen: config, + ..Default::default() + } + .init() + .expect("stream-only server"); let listener = server.listen().await.expect("listen"); assert!(tokio::net::TcpListener::bind(addr).await.is_err(), "listener is bound"); @@ -1737,12 +1797,16 @@ mod tests { #[test] fn quic_bind_without_a_quic_backend_is_rejected() { let config = crate::listen::Config { - bind: Some("127.0.0.1:0".to_string()), + bind: Some("127.0.0.1:0".parse().unwrap()), ..Default::default() }; assert!(matches!( - Config::default().with_listen(config).init(), + Config { + listen: config, + ..Default::default() + } + .init(), Err(Error::NoBackend(_)) )); } @@ -1773,7 +1837,7 @@ mod tests { #[tokio::test] async fn certificates_expose_generated_fingerprints() { let mut config = crate::listen::Config { - bind: Some("[::]:0".to_string()), + bind: Some("[::]:0".parse().unwrap()), ..Default::default() }; config.tls.generate = vec!["localhost".into()]; @@ -1826,9 +1890,15 @@ mod tests { #[test] fn bind_string_or_listen_alias() { let bind: crate::listen::Config = toml::from_str(r#"bind = "[::]:443""#).unwrap(); - assert_eq!(bind.bind.as_deref(), Some("[::]:443")); + assert_eq!(bind.bind.as_ref().map(ToString::to_string).as_deref(), Some("[::]:443")); assert!(bind.deprecated().is_empty()); + let bind: crate::listen::Config = toml::from_str(r#"bind = "fly-global-services:443""#).unwrap(); + assert_eq!( + bind.bind, + Some(crate::listen::Bind::Host("fly-global-services".to_string(), 443)) + ); + // The released key still parses so the process can name `bind`, but it // configures nothing. let alias: crate::listen::Config = toml::from_str(r#"listen = "0.0.0.0:4443""#).unwrap(); @@ -1855,7 +1925,10 @@ uid = [1001, 1002] "#, ) .unwrap(); - assert_eq!(config.bind.as_deref(), Some("[::]:443")); + assert_eq!( + config.bind.as_ref().map(ToString::to_string).as_deref(), + Some("[::]:443") + ); assert_eq!(config.unix.bind.as_deref(), Some(std::path::Path::new("/run/moq.sock"))); assert_eq!(config.unix.allow.uid, vec![1001, 1002]); assert!(config.has_stream_listener()); diff --git a/rs/moq-tokio/src/tcp.rs b/rs/moq-tokio/src/tcp.rs index eabb02a618..b94d5266f7 100644 --- a/rs/moq-tokio/src/tcp.rs +++ b/rs/moq-tokio/src/tcp.rs @@ -63,8 +63,8 @@ pub(crate) struct Legacy { impl Config { /// The released spelling, if in use, paired with what replaced it. Reached /// through [`crate::listen::Config::deprecated`]. - pub(crate) fn deprecated(&self) -> crate::Deprecated { - let mut found = crate::Deprecated::default(); + pub(crate) fn deprecated(&self) -> crate::cli::Deprecated { + let mut found = crate::cli::Deprecated::default(); if self.legacy.bind.is_some() { found.flag( "--server-tcp-bind", @@ -112,11 +112,11 @@ pub enum Error { /// unroutable or to blackhole until its timeout. A host with a single address /// reports that error directly instead. #[error("all {} connection attempts failed: {}", .0.len(), crate::failover::describe(.0))] - Failover(Vec>), + Failover(Vec>), } impl crate::failover::Aggregate for Error { - fn aggregate(failures: Vec>) -> Self { + fn aggregate(failures: Vec>) -> Self { Self::Failover(failures) } diff --git a/rs/moq-tokio/src/tls.rs b/rs/moq-tokio/src/tls.rs index edfdf672b5..bace2a16b7 100644 --- a/rs/moq-tokio/src/tls.rs +++ b/rs/moq-tokio/src/tls.rs @@ -160,7 +160,7 @@ pub enum Error { /// The section was parsed from released spellings that no longer work. The /// payload is the migration to print. #[error("{0}")] - Deprecated(crate::Deprecated), + Deprecated(crate::cli::Deprecated), } #[cfg(feature = "_certs")] @@ -349,7 +349,7 @@ impl rustls::client::ResolvesClientCert for IdentityResolver { /// /// A peer whose fingerprint is absent fails the TLS handshake, so a session that /// reaches the application is always one of these. Which one is -/// [`crate::Request::peer_identity`] plus [`PeerIdentity::fingerprint`]. +/// [`crate::server::Request::peer_identity`] plus [`PeerIdentity::fingerprint`]. #[derive(Clone, Debug, Default)] pub struct Peers { allowed: Arc>>, @@ -829,9 +829,9 @@ impl Connect { /// while it still can; [`build`](Self::build) refuses either way, since a /// released `--client-tls-root` silently falling back to the system store is a /// downgrade of exactly the setting that was meant to restrict trust. - pub fn deprecated(&self) -> crate::Deprecated { + pub fn deprecated(&self) -> crate::cli::Deprecated { let old = &self.deprecated; - let mut found = crate::Deprecated::default(); + let mut found = crate::cli::Deprecated::default(); if self.disable_verify.is_some() { found.toml("disable_verify", "insecure", None); } @@ -1277,7 +1277,7 @@ pub struct Listen { /// The peer-mesh counterpart to `root`: membership is a set of fingerprints /// that changes as peers are discovered, rather than an authority that issues /// certificates. A client whose certificate isn't in the set fails the - /// handshake, and one that is arrives with a [`crate::Request::peer_identity`] + /// handshake, and one that is arrives with a [`crate::server::Request::peer_identity`] /// naming which peer it is. /// /// Combining this with `root` is an error: pinning bypasses the chain, so one @@ -1290,7 +1290,7 @@ pub struct Listen { /// PEM file(s) of root CAs for validating optional client certificates (mTLS). /// /// When set, clients *may* present a certificate during the TLS handshake. - /// Valid presentations are reported via [`crate::Request::peer_identity`] + /// Valid presentations are reported via [`crate::server::Request::peer_identity`] /// and can be used by the application to grant elevated access. Clients that /// do not present a certificate are unaffected. /// @@ -1372,9 +1372,9 @@ impl Listen { /// [`crate::listen::Config`]. The methods that build a server config refuse /// anyway, since a dropped `--server-tls-root` would take the mTLS client CAs /// with it and leave the listener accepting unauthenticated peers. - pub fn deprecated(&self) -> crate::Deprecated { + pub fn deprecated(&self) -> crate::cli::Deprecated { let old = &self.deprecated; - let mut found = crate::Deprecated::default(); + let mut found = crate::cli::Deprecated::default(); for (used, flag, env, new) in [ ( @@ -1542,7 +1542,7 @@ fn server_config(config: &Listen, alpn: Vec>) -> Result crate::Deprecated { + pub(crate) fn deprecated(&self) -> crate::cli::Deprecated { let legacy = &self.legacy; - let mut found = crate::Deprecated::default(); + let mut found = crate::cli::Deprecated::default(); for (used, old, env, new) in [ ( diff --git a/rs/moq-tokio/src/websocket.rs b/rs/moq-tokio/src/websocket.rs index 121a340d91..df20f6bc77 100644 --- a/rs/moq-tokio/src/websocket.rs +++ b/rs/moq-tokio/src/websocket.rs @@ -19,7 +19,7 @@ use crate::cli::Duration as CliDuration; pub enum Error { /// Every candidate failed its TCP, TLS, or WebSocket handshake. #[error("all {} connection attempts failed: {}", .0.len(), crate::failover::describe(.0))] - Failover(Vec>), + Failover(Vec>), /// The TCP socket failed to bind or connect. Not accept: a failed `accept(2)` is /// the listener's own to classify and retry (see [`crate::accept`]). @@ -73,7 +73,7 @@ pub enum Error { } impl crate::failover::Aggregate for Error { - fn aggregate(failures: Vec>) -> Self { + fn aggregate(failures: Vec>) -> Self { Self::Failover(failures) } fn resolve(error: Option) -> Self { @@ -129,14 +129,20 @@ pub struct Config { /// Head start given to the QUIC dial before the WebSocket fallback joins the /// race. Defaults to 200ms, and drops to zero for a server WebSocket already won. + #[usage(skip)] + #[serde(with = "crate::cli::duration::serde_duration")] + pub delay: time::Duration, + #[usage( name = "connect-websocket-delay", long = "connect-websocket-delay", env = "MOQ_CONNECT_WEBSOCKET_DELAY", + default_value_t = CliDuration::fallback(DEFAULT_DELAY), default = "200ms", setting = "connect.websocket.delay" )] - pub delay: CliDuration, + #[serde(default, rename = "__cli_delay", skip_serializing_if = "Option::is_none")] + delay_arg: Option, /// The released `MOQ_CLIENT_WEBSOCKET_*` env vars, named by [`Config::deprecated`]. #[usage(flatten)] @@ -148,7 +154,8 @@ impl Default for Config { fn default() -> Self { Self { enabled: None, - delay: DEFAULT_DELAY.into(), + delay: DEFAULT_DELAY, + delay_arg: None, legacy: Default::default(), } } @@ -188,8 +195,8 @@ const DEFAULT_DELAY: time::Duration = time::Duration::from_millis(200); impl Config { /// The released spellings in use, each paired with what replaced it. Reached /// through [`crate::connect::Config::deprecated`]. - pub(crate) fn deprecated(&self) -> crate::Deprecated { - let mut found = crate::Deprecated::default(); + pub(crate) fn deprecated(&self) -> crate::cli::Deprecated { + let mut found = crate::cli::Deprecated::default(); if self.legacy.enabled.is_some() { found.flag( "--websocket-enabled", @@ -211,7 +218,7 @@ impl Config { pub fn resolve(&self) -> Resolved { Resolved { enabled: self.enabled.unwrap_or(true), - delay: self.delay.into_std(), + delay: CliDuration::resolve(self.delay_arg, self.delay), } } } @@ -459,7 +466,7 @@ impl Error { /// Listens for incoming WebSocket connections on a TCP port. /// -/// Use with [`crate::Server::with_websocket`] to accept WebSocket connections +/// Assign to [`crate::server::Config::websocket`] to accept WebSocket connections /// alongside QUIC connections on a separate port. pub struct Listener { listener: tokio::net::TcpListener, @@ -470,16 +477,8 @@ pub struct Listener { impl Listener { /// Bind a listener to the given address, accepting every moq ALPN we know about. pub async fn bind(addr: net::SocketAddr) -> Result { - Self::bind_with_alpns(addr, moq_net::ALPNS).await - } - - /// Bind a listener that only accepts the given moq ALPNs, in preference order. - pub async fn bind_with_alpns(addr: net::SocketAddr, alpns: &[&str]) -> Result { let listener = tokio::net::TcpListener::bind(addr).await?; - let protocols = supported_subprotocols(alpns); - for protocol in &protocols { - http::HeaderValue::from_str(protocol).map_err(|err| Error::ProtocolHeader(crate::error::message(err)))?; - } + let protocols = supported_subprotocols(moq_net::ALPNS); Ok(Self { listener, protocols, @@ -487,6 +486,25 @@ impl Listener { }) } + /// Accept only the given moq ALPNs, in preference order. + pub fn with_protocols(mut self, protocols: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let alpns: Vec = protocols + .into_iter() + .map(|protocol| protocol.as_ref().to_owned()) + .collect(); + let refs: Vec<&str> = alpns.iter().map(String::as_str).collect(); + let protocols = supported_subprotocols(&refs); + for protocol in &protocols { + http::HeaderValue::from_str(protocol).map_err(|err| Error::ProtocolHeader(crate::error::message(err)))?; + } + self.protocols = protocols; + Ok(self) + } + /// The local address the listener is bound to. pub fn local_addr(&self) -> Result { Ok(self.listener.local_addr()?) @@ -838,9 +856,8 @@ mod legacy_tests { assert!(!resolved.enabled); assert_eq!(resolved.delay, time::Duration::from_secs(2)); - // Neither given: the typed defaults. + // Neither given: the parser resolves to the typed defaults. let config = parse(&[]); - assert_eq!(config.delay, DEFAULT_DELAY); assert_eq!(config.resolve().delay, DEFAULT_DELAY); } } diff --git a/rs/moq-tokio/src/worker/group.rs b/rs/moq-tokio/src/worker/group.rs index 13c559f088..aeb7f79ea3 100644 --- a/rs/moq-tokio/src/worker/group.rs +++ b/rs/moq-tokio/src/worker/group.rs @@ -6,7 +6,7 @@ use std::sync::{ }; use super::Config; -use crate::{Error, Result, Server, abort::AbortOnDrop, listen::Member, server::SocketRetainer}; +use crate::{Error, Result, Server, abort::AbortOnDrop, listen::Member as ShardMember, server::SocketRetainer}; /// A bound group of QUIC workers sharing one port. /// @@ -19,15 +19,17 @@ use crate::{Error, Result, Server, abort::AbortOnDrop, listen::Member, server::S /// # async fn example(listen: moq_tokio::listen::Config) -> anyhow::Result<()> { /// use moq_tokio::worker; /// -/// let workers = worker::Workers::bind(listen, Default::default(), worker::Config::new(8))?; +/// let mut server = moq_tokio::server::Config::default(); +/// server.listen = listen; +/// let workers = worker::Workers::bind(server, worker::Config::new(8))?; /// println!("listening on {}", workers.local_addr()); /// /// // The group owns the threads, so keep it alive as long as you want the /// // port served. /// let mut group = workers.split(); /// let mut tasks = Vec::new(); -/// for (server, spawner) in group.members() { -/// tasks.push(spawner.serve(server, |server| async move { +/// for member in group.members() { +/// tasks.push(member.serve(|server| async move { /// let _ = server.listen().await; /// })); /// } @@ -65,11 +67,11 @@ impl Workers { /// bound more than once. Serve those from a /// [`init_streams`](crate::listen::Config::init_streams) server on the /// caller's own runtime. - pub fn bind(listen: crate::listen::Config, quic: crate::quic::Config, config: Config) -> Result { + pub fn bind(mut server: crate::server::Config, config: Config) -> Result { // Each worker loads the certificate files itself, so generating would give // every member a certificate of its own and clients a different one per // connection. - if !listen.tls.generate.is_empty() { + if !server.listen.tls.generate.is_empty() { return Err(Error::WorkerTlsGenerate); } @@ -78,20 +80,9 @@ impl Workers { // One resolution for the whole group. Each worker resolves its own config // otherwise, so a DNS answer that rotates between queries would hand // members different addresses and fail the bind on whichever member drew a - // fresh one. An unset `bind` stays unset: the backends fall back to the - // default literal, and `Some` here would flip a stream-only config into - // opening a QUIC listener. - let mut listen = listen; - let requested = match listen.bind.as_deref() { - Some(bind) => { - let addr = crate::util::resolve(Some(bind), bind).map_err(|err| Error::WorkerResolve(Arc::new(err)))?; - listen.bind = Some(addr.to_string()); - addr - } - None => crate::server::DEFAULT_BIND - .parse() - .expect("the default bind is a literal"), - }; + // fresh one. A worker group always opens QUIC, including when the source + // server config also names a stream listener. + let requested = materialize_bind(&mut server.listen)?; // The group owns everything a reuseport group has to get right: it takes // the port before the first member binds and holds it until the group is @@ -116,7 +107,7 @@ impl Workers { // there are no workers. let core = cores.get(index as usize % cores.len().max(1)).copied(); - let worker = Worker::spawn(listen.clone(), quic.clone(), member, core, shared.clone())?; + let worker = Worker::spawn(server.worker(), member, core, shared.clone())?; certificates.get_or_insert_with(|| { worker @@ -174,7 +165,7 @@ impl Workers { /// and retains every socket until serving has stopped, so dropping a /// returned server or letting one member's future return cannot resize the /// reuseport array out from under its siblings. Serve each member with - /// [`Spawner::serve`]: the first member to complete, fail, or cancel ends + /// [`Member::serve`]: the first member to complete, fail, or cancel ends /// serving for the group. pub fn split(self) -> Group { let shared = match self.workers.first() { @@ -223,6 +214,34 @@ impl Workers { } } +/// Resolve the one QUIC address shared by every worker and write it back into +/// the per-worker config so a separately configured stream listener cannot +/// make [`Server::build`] treat the worker as stream-only. +fn materialize_bind(listen: &mut crate::listen::Config) -> Result { + let requested = match listen.bind.as_ref() { + Some(bind) => bind.resolve().map_err(|err| Error::WorkerResolve(Arc::new(err)))?, + None => crate::server::DEFAULT_BIND, + }; + listen.bind = Some(crate::listen::Bind::Addr(requested)); + Ok(requested) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(feature = "tcp")] + fn stream_bind_does_not_suppress_worker_quic() { + let mut listen = crate::listen::Config::default(); + listen.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); + + let requested = materialize_bind(&mut listen).unwrap(); + assert_eq!(requested, crate::server::DEFAULT_BIND); + assert_eq!(listen.bind, Some(crate::listen::Bind::Addr(requested))); + } +} + /// An owning group of bound QUIC workers, serving one port together. /// /// Returned by [`Workers::split`], which consumes the bound workers so each @@ -231,7 +250,7 @@ impl Workers { /// or letting one serving future return cannot take one socket out of the /// reuseport group while its siblings keep serving. /// -/// Serve each member with [`Spawner::serve`]. The first member whose serving +/// Serve each member with [`Member::serve`]. The first member whose serving /// future completes, panics, or is cancelled ends serving for the group: its /// siblings are stopped and the group is ready to join. Join with /// [`Group::shutdown`] (off the caller's runtime) or by dropping the group. @@ -281,39 +300,38 @@ impl Group { self.workers.is_empty() } - /// Each worker's bound server, paired with a handle onto the thread that - /// has to drive it. + /// Each worker's bound server paired with the thread that has to drive it. /// - /// Serve each pair with [`Spawner::serve`], not [`Spawner::run`]: `serve` + /// Serve each member with [`Member::serve`], not [`Member::run`]: `serve` /// builds the future on the worker's thread from the server and ends the /// group when the first member finishes, while `run` is for auxiliary /// tasks that must not end serving. Empty after the first call, since a /// worker serves one server. /// - /// Dropping a returned server is safe but leaves its slot unserved: the + /// Dropping a returned member is safe but leaves its slot unserved: the /// group retains the socket, so the survivors keep their steering, but /// nothing accepts the dropped member's share until the group stops. - pub fn members(&mut self) -> Vec<(Server, Spawner<'_>)> { + pub fn members(&mut self) -> Vec> { self.workers .iter_mut() .filter_map(|worker| { let server = worker.server.take()?; - Some(( + Some(Member { server, - Spawner { + spawner: Spawner { index: worker.index, handle: &worker.handle, spawn: &worker.spawn, shared: &worker.shared, }, - )) + }) }) .collect() } /// Resolve once any serving member has ended the group. /// - /// The output itself travels through the [`Spawner::serve`] handle that ran + /// The output itself travels through the [`Member::serve`] handle that ran /// it; this is the owner's signal that serving is over and /// [`Group::shutdown`] will join cleanly. Pends forever while every member /// still serves. @@ -465,6 +483,39 @@ pub struct Spawner<'a> { shared: &'a Arc, } +/// One bound server paired with the only worker thread that may drive it. +pub struct Member<'a> { + server: Server, + spawner: Spawner<'a>, +} + +impl Member<'_> { + /// This member's position in the reuseport group. + pub fn index(&self) -> u16 { + self.spawner.index() + } + + /// Build and drive an auxiliary future on this member's worker thread. + pub fn run(&self, make: M) -> tokio::task::JoinHandle + where + M: FnOnce() -> F + Send + 'static, + F: Future + 'static, + F::Output: Send + 'static, + { + self.spawner.run(make) + } + + /// Build and drive this member's serving future on its worker thread. + pub fn serve(self, make: M) -> tokio::task::JoinHandle + where + M: FnOnce(Server) -> F + Send + 'static, + F: Future + 'static, + F::Output: Send + 'static, + { + self.spawner.serve(self.server, make) + } +} + impl Spawner<'_> { /// Serve this worker's server, ending the group when the future ends. /// @@ -478,7 +529,7 @@ impl Spawner<'_> { /// serve is the end of serving, not a resized reuseport group. The returned /// handle reports what the future returned; dropping it without aborting /// leaves the task running. - pub fn serve(&self, server: Server, make: M) -> tokio::task::JoinHandle + fn serve(&self, server: Server, make: M) -> tokio::task::JoinHandle where M: FnOnce(Server) -> F + Send + 'static, F: Future + 'static, @@ -522,7 +573,7 @@ impl Spawner<'_> { /// builder rather than a hook: it runs once, to make the future, and nothing /// calls back into it afterwards. /// - /// Auxiliary tasks only: unlike [`serve`](Self::serve) this does not own a + /// Auxiliary tasks only: unlike [`Member::serve`] this does not own a /// server and ending it does not end the group. The returned handle reports /// what the future returned, so a caller can end the process on a worker /// that fails, and stands in for the worker-local task itself: a panic in @@ -585,9 +636,8 @@ impl Worker { /// Bind this worker's socket on a thread of its own, returning once it is /// listening. fn spawn( - listen: crate::listen::Config, - quic: crate::quic::Config, - member: Member, + server: crate::server::Config, + member: ShardMember, core: Option, shared: Arc, ) -> Result { @@ -600,7 +650,7 @@ impl Worker { .name(format!("moq-quic-{index}")) .spawn({ let shared = shared.clone(); - move || run(member, core, listen, quic, ready_tx, stop_rx, spawn_rx, shared) + move || run(member, core, server, ready_tx, stop_rx, spawn_rx, shared) }) .map_err(|err| Error::WorkerStart { index, @@ -683,10 +733,9 @@ type Spawn = Box; /// [`Spawner::serve`] spawns onto this same runtime. #[allow(clippy::too_many_arguments)] fn run( - member: Member, + member: ShardMember, core: Option, - listen: crate::listen::Config, - quic: crate::quic::Config, + server: crate::server::Config, ready: std::sync::mpsc::Sender>, stop: tokio::sync::oneshot::Receiver<()>, mut spawn: tokio::sync::mpsc::UnboundedReceiver, @@ -710,11 +759,8 @@ fn run( let built = { let _guard = runtime.enter(); - Server::build( - crate::server::Config::default().with_listen(listen).with_quic(quic), - crate::server::Parts::Member(member), - ) - .and_then(|server| server.local_addr().map(|addr| (server, addr))) + Server::build(server, crate::server::Parts::Member(member)) + .and_then(|server| server.local_addr().map(|addr| (server, addr))) }; let (server, addr) = match built { diff --git a/rs/moq-tokio/src/worker/mod.rs b/rs/moq-tokio/src/worker/mod.rs index 65c51d120d..8a8569d395 100644 --- a/rs/moq-tokio/src/worker/mod.rs +++ b/rs/moq-tokio/src/worker/mod.rs @@ -24,7 +24,7 @@ mod group; #[cfg(any(feature = "noq", feature = "quinn", feature = "quiche"))] -pub use group::{Group, Spawner, Workers}; +pub use group::{Group, Member, Spawner, Workers}; /// How many QUIC workers to run, and whether to pin them. #[derive(Clone, Copy, Debug)] diff --git a/rs/moq-tokio/tests/alpn.rs b/rs/moq-tokio/tests/alpn.rs index 4429aea646..2dc34c0a52 100644 --- a/rs/moq-tokio/tests/alpn.rs +++ b/rs/moq-tokio/tests/alpn.rs @@ -15,7 +15,7 @@ async fn connect_with_version(version: &str) { // ── server ────────────────────────────────────────────────────── let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; server_config.version = vec![version]; @@ -38,15 +38,14 @@ async fn connect_with_version(version: &str) { // Run server accept and client connect concurrently. let server_origin = origin.clone(); - let server_handle = tokio::spawn(async move { + let server_connect = async { let request = server.accept().await.expect("no incoming connection"); request.with_publisher(&server_origin).ok().await - }); + }; let client = client.with_publisher(&origin); - let client_result = client.with_reconnect(false).connect(url).established().await; - - let server_result = server_handle.await.expect("server task panicked"); + let client_connect = client.with_reconnect(false).connect(url).established(); + let (server_result, client_result) = tokio::join!(server_connect, client_connect); // Both sides should succeed. if let Err(err) = &client_result { @@ -65,7 +64,7 @@ async fn connect_with_webtransport(version: Option<&str>) { // ── server ────────────────────────────────────────────────────── let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; if let Some(v) = version { server_config.version = vec![v]; @@ -90,15 +89,14 @@ async fn connect_with_webtransport(version: Option<&str>) { let url: url::Url = format!("https://localhost:{}", addr.port()).parse().unwrap(); let server_origin = origin.clone(); - let server_handle = tokio::spawn(async move { + let server_connect = async { let request = server.accept().await.expect("no incoming connection"); request.with_publisher(&server_origin).ok().await - }); + }; let client = client.with_publisher(&origin); - let client_result = client.with_reconnect(false).connect(url).established().await; - - let server_result = server_handle.await.expect("server task panicked"); + let client_connect = client.with_reconnect(false).connect(url).established(); + let (server_result, client_result) = tokio::join!(server_connect, client_connect); let label = version.map_or("default".to_string(), |v| v.to_string()); if let Err(err) = &client_result { diff --git a/rs/moq-tokio/tests/backend.rs b/rs/moq-tokio/tests/backend.rs index c1173e84a2..b3456b0008 100644 --- a/rs/moq-tokio/tests/backend.rs +++ b/rs/moq-tokio/tests/backend.rs @@ -24,7 +24,7 @@ struct ConnectTest<'a> { path: &'a str, /// The request path the server must observe, when the test cares. expect_path: Option<&'a str>, - /// The authority the server must observe via [`moq_tokio::Request::authority`], when the + /// The authority the server must observe via [`moq_tokio::server::Request::authority`], when the /// test cares. `None` skips the check; `Some(None)` asserts no authority (a bare-IP dial that /// sends no SNI); `Some(Some(host))` asserts that host. expect_authority: Option>, @@ -61,7 +61,7 @@ async fn backend_test(scheme: &str, backend: moq_tokio::QuicBackend) { /// /// Raw QUIC (`moqt`/`moql`) has no request URI, so the whole request target has to /// ride the SETUP; WebTransport carries it in the CONNECT URL instead. Either way the -/// server reports the same route and query through [`moq_tokio::Request`]. +/// server reports the same route and query through [`moq_tokio::server::Request`]. #[cfg(any(feature = "quinn", feature = "quiche", feature = "noq"))] async fn path_test(scheme: &str, backend: moq_tokio::QuicBackend) { connect_test(ConnectTest { @@ -132,7 +132,7 @@ async fn connect_test(config: ConnectTest<'_>) { group.finish().expect("failed to finish group"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some(bind.to_string()); + server_config.bind = Some(bind.parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; server_config.backend = Some(backend.clone()); let server = server_config.init(quic.clone()).expect("failed to init server"); @@ -265,7 +265,7 @@ async fn sni_test(backend: moq_tokio::QuicBackend) { let (second_cert, second_key) = write_self_signed(dir.path(), "second", "alt.localhost"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("127.0.0.1:0".to_string()); + server_config.bind = Some("127.0.0.1:0".parse().unwrap()); server_config.tls.cert = vec![first_cert, second_cert]; server_config.tls.key = vec![first_key, second_key]; server_config.backend = Some(backend.clone()); @@ -323,7 +323,7 @@ async fn cert_sources_test(backend: moq_tokio::QuicBackend) { let (cert, key) = write_self_signed(dir.path(), "server", "localhost"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("127.0.0.1:0".to_string()); + server_config.bind = Some("127.0.0.1:0".parse().unwrap()); server_config.tls.cert = vec![cert]; server_config.tls.key = vec![key]; server_config.tls.generate = vec!["generated.localhost".into()]; @@ -349,7 +349,7 @@ async fn reload_test(backend: moq_tokio::QuicBackend) { let (cert, key) = write_self_signed(dir.path(), "server", "localhost"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("127.0.0.1:0".to_string()); + server_config.bind = Some("127.0.0.1:0".parse().unwrap()); server_config.tls.cert = vec![cert.clone()]; server_config.tls.key = vec![key.clone()]; server_config.backend = Some(backend); @@ -478,7 +478,7 @@ async fn mtls_test(scheme: &str, backend: moq_tokio::QuicBackend, reject: bool) let pub_origin = moq_tokio::origin::spawn(Hop::random()); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("127.0.0.1:0".to_string()); + server_config.bind = Some("127.0.0.1:0".parse().unwrap()); server_config.tls.cert = vec![paths.server_cert.clone()]; server_config.tls.key = vec![paths.server_key.clone()]; server_config.tls.root = vec![paths.ca.clone()]; @@ -486,7 +486,7 @@ async fn mtls_test(scheme: &str, backend: moq_tokio::QuicBackend, reject: bool) // One shared tuning, handed to both roles the way a binary would. let mut quic = moq_tokio::quic::Config::default(); quic.gso = Some(false); - quic.keep_alive = Duration::from_secs(1).into(); + quic.keep_alive = Duration::from_secs(1); let server = server_config.init(quic.clone()).expect("failed to init server"); let mut server = server.listen().await.expect("failed to listen"); @@ -512,7 +512,7 @@ async fn mtls_test(scheme: &str, backend: moq_tokio::QuicBackend, reject: bool) let has_cert = request.peer_identity().is_some(); let _ = identity_tx.send(has_cert); if reject { - request.close(403).await?; + request.reject(moq_tokio::server::Reject::Forbidden).await?; return Ok::<_, anyhow::Error>(has_cert); } let session = request.with_publisher(pub_origin.consume()).ok().await?; @@ -739,13 +739,13 @@ async fn iroh_connect() { // Server still needs a QUIC bind for init, but we'll connect via iroh let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; - let server = server_config - .init(Default::default()) - .expect("failed to init server") - .with_iroh(server_endpoint); + let mut config = moq_tokio::server::Config::default(); + config.listen = server_config; + config.iroh = Some(server_endpoint); + let server = config.init().expect("failed to init server"); let mut server = server.listen().await.expect("failed to listen"); // ── subscriber (client) ───────────────────────────────────────── @@ -782,7 +782,7 @@ async fn iroh_connect() { assert_eq!(request.role(), Some(moq_tokio::moq_net::Role::Subscriber)); // iroh offers the moq ALPNs ahead of H3, so this lands on raw QUIC: no request // URL, leaving the SETUP as the only place for the request target. - assert_eq!(request.transport(), moq_tokio::Transport::Iroh); + assert_eq!(request.transport(), moq_tokio::server::Transport::Iroh); assert_eq!(request.url(), None); assert_eq!(request.path(), "/room"); assert_eq!(request.query(), Some("jwt=abc")); diff --git a/rs/moq-tokio/tests/broadcast.rs b/rs/moq-tokio/tests/broadcast.rs index 6fc6420a84..1f4480972a 100644 --- a/rs/moq-tokio/tests/broadcast.rs +++ b/rs/moq-tokio/tests/broadcast.rs @@ -38,7 +38,7 @@ async fn broadcast_test(scheme: &str, client_version: Option<&str>, server_versi group.finish().expect("failed to finish group"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; if let Some(v) = server_version { server_config.version = vec![v]; @@ -165,7 +165,7 @@ async fn lite05_timestamp_roundtrip(scheme: &str) { group.finish().expect("failed to finish group"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; server_config.version = vec!["moq-lite-05".parse().unwrap()]; let server = server_config.init(Default::default()).expect("failed to init server"); @@ -288,7 +288,7 @@ async fn lite05_fetch_roundtrip(scheme: &str) { group.finish().expect("failed to finish group"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; server_config.version = vec!["moq-lite-05".parse().unwrap()]; let server = server_config.init(Default::default()).expect("failed to init server"); @@ -418,7 +418,7 @@ async fn lite05_fetch_during_subscribe(scheme: &str) { group1.finish().expect("finish group 1"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; server_config.version = vec!["moq-lite-05".parse().unwrap()]; let server = server_config.init(Default::default()).expect("failed to init server"); @@ -529,7 +529,7 @@ async fn broadcast_moq_lite_05_default_timescale() { group.finish().expect("finish group"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; server_config.version = vec!["moq-lite-05".parse().unwrap()]; let server = server_config.init(Default::default()).expect("init server"); @@ -629,7 +629,7 @@ async fn broadcast_moq_transport_20_current_group_join() { } let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; server_config.version = vec!["moq-transport-20".parse().unwrap()]; let server = server_config.init(Default::default()).expect("init server"); @@ -731,7 +731,7 @@ async fn broadcast_moq_lite_06_announce_lifecycle() { first.announce(Default::default()).expect("create broadcast"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; server_config.version = vec!["moq-lite-06-wip".parse().unwrap()]; let server = server_config.init(Default::default()).expect("init server"); @@ -893,13 +893,13 @@ async fn broadcast_route_migration() { } let server_a = { let mut config = moq_tokio::listen::Config::default(); - config.bind = Some("[::]:0".to_string()); + config.bind = Some("[::]:0".parse().unwrap()); config.tls.generate = vec!["localhost".into()]; config.init(Default::default()).expect("init server a") }; let server_b = { let mut config = moq_tokio::listen::Config::default(); - config.bind = Some("[::]:0".to_string()); + config.bind = Some("[::]:0".parse().unwrap()); config.tls.generate = vec!["localhost".into()]; config.init(Default::default()).expect("init server b") }; @@ -1025,7 +1025,7 @@ async fn route_reannounce_test(version: Option<&str>) { group.finish().expect("finish group"); } let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; if let Some(v) = version { server_config.version = vec![v]; @@ -1335,7 +1335,7 @@ async fn max_age_test(version: &str) -> Duration { let track = broadcast.create_track("video", info).expect("create track"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; server_config.version = vec![version]; let server = server_config.init(Default::default()).expect("init server"); @@ -1642,7 +1642,7 @@ async fn broadcast_websocket() { // Server with both QUIC (required) and WebSocket listeners. let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; let ws_listener = moq_tokio::websocket::Listener::bind("[::]:0".parse().unwrap()) @@ -1650,10 +1650,10 @@ async fn broadcast_websocket() { .expect("failed to bind WebSocket listener"); let ws_addr = ws_listener.local_addr().expect("failed to get ws addr"); - let server = server_config - .init(Default::default()) - .expect("failed to init server") - .with_websocket(ws_listener); + let mut config = moq_tokio::server::Config::default(); + config.listen = server_config; + config.websocket = Some(ws_listener); + let server = config.init().expect("failed to init server"); let mut server = server.listen().await.expect("failed to listen"); // ── subscriber (client) ───────────────────────────────────────── @@ -1664,7 +1664,7 @@ async fn broadcast_websocket() { let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); // Disable WebSocket delay so client connects immediately via ws:// - client_config.websocket.delay = Duration::ZERO.into(); + client_config.websocket.delay = Duration::ZERO; let client = client_config.init(Default::default()).expect("failed to init client"); let url: url::Url = format!("ws://localhost:{}", ws_addr.port()).parse().unwrap(); @@ -1672,7 +1672,7 @@ async fn broadcast_websocket() { // ── run server and client concurrently ────────────────────────── let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - assert_eq!(request.transport(), moq_tokio::Transport::WebSocket); + assert_eq!(request.transport(), moq_tokio::server::Transport::WebSocket); assert_eq!(request.path(), ""); // The dialed host reaches the server as the authority, like the QUIC transports. assert_eq!(request.authority(), Some("localhost")); @@ -1761,7 +1761,7 @@ async fn broadcast_websocket_fallback() { // QUIC binds on its own port; WebSocket on a different port. let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; let ws_listener = moq_tokio::websocket::Listener::bind("[::]:0".parse().unwrap()) @@ -1769,10 +1769,10 @@ async fn broadcast_websocket_fallback() { .expect("failed to bind WebSocket listener"); let ws_addr = ws_listener.local_addr().expect("failed to get ws addr"); - let server = server_config - .init(Default::default()) - .expect("failed to init server") - .with_websocket(ws_listener); + let mut config = moq_tokio::server::Config::default(); + config.listen = server_config; + config.websocket = Some(ws_listener); + let server = config.init().expect("failed to init server"); let mut server = server.listen().await.expect("failed to listen"); // ── subscriber (client) ───────────────────────────────────────── @@ -1783,7 +1783,7 @@ async fn broadcast_websocket_fallback() { let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); // No delay. Race QUIC and WebSocket simultaneously. - client_config.websocket.delay = Duration::ZERO.into(); + client_config.websocket.delay = Duration::ZERO; let client = client_config.init(Default::default()).expect("failed to init client"); @@ -1796,7 +1796,7 @@ async fn broadcast_websocket_fallback() { // ── run server and client concurrently ────────────────────────── let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - assert_eq!(request.transport(), moq_tokio::Transport::WebSocket); + assert_eq!(request.transport(), moq_tokio::server::Transport::WebSocket); assert_eq!(request.path(), "/admin"); assert_eq!(request.query(), Some("jwt=test")); assert_eq!(request.url().and_then(url::Url::query), Some("jwt=test")); @@ -1887,7 +1887,7 @@ async fn broadcast_websocket_uses_newest_version() { group.finish().expect("failed to finish group"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; let ws_listener = moq_tokio::websocket::Listener::bind("[::]:0".parse().unwrap()) @@ -1895,16 +1895,16 @@ async fn broadcast_websocket_uses_newest_version() { .expect("failed to bind WebSocket listener"); let ws_addr = ws_listener.local_addr().expect("failed to get ws addr"); - let server = server_config - .init(Default::default()) - .expect("failed to init server") - .with_websocket(ws_listener); + let mut config = moq_tokio::server::Config::default(); + config.listen = server_config; + config.websocket = Some(ws_listener); + let server = config.init().expect("failed to init server"); let mut server = server.listen().await.expect("failed to listen"); let sub_origin = moq_tokio::origin::spawn(Hop::random()); let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); - client_config.websocket.delay = Duration::ZERO.into(); + client_config.websocket.delay = Duration::ZERO; let client = client_config.init(Default::default()).expect("failed to init client"); let url: url::Url = format!("ws://localhost:{}", ws_addr.port()).parse().unwrap(); @@ -1913,7 +1913,7 @@ async fn broadcast_websocket_uses_newest_version() { let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - assert_eq!(request.transport(), moq_tokio::Transport::WebSocket); + assert_eq!(request.transport(), moq_tokio::server::Transport::WebSocket); let session = request.with_publisher(&pub_origin).ok().await?; assert_eq!(session.version(), expected_version, "server negotiated stale version"); let _broadcast = broadcast; @@ -1967,20 +1967,20 @@ async fn broadcast_race_quic_wins() { let port = ws_listener.local_addr().expect("failed to get ws addr").port(); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some(format!("[::]:{port}")); + server_config.bind = Some(format!("[::]:{port}").parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; - let server = server_config - .init(Default::default()) - .expect("failed to init server") - .with_websocket(ws_listener); + let mut config = moq_tokio::server::Config::default(); + config.listen = server_config; + config.websocket = Some(ws_listener); + let server = config.init().expect("failed to init server"); let mut server = server.listen().await.expect("failed to listen"); let sub_origin = moq_tokio::origin::spawn(Hop::random()); let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); // Zero head start: QUIC has to win on its own merit, not by penalising WS. - client_config.websocket.delay = Duration::ZERO.into(); + client_config.websocket.delay = Duration::ZERO; let client = client_config.init(Default::default()).expect("failed to init client"); let url: url::Url = format!("https://localhost:{port}").parse().unwrap(); @@ -1991,7 +1991,7 @@ async fn broadcast_race_quic_wins() { let request = server.accept().await.expect("no incoming connection"); assert_eq!( request.transport(), - moq_tokio::Transport::Quic, + moq_tokio::server::Transport::Quic, "QUIC lost the race to WebSocket with both reachable", ); let session = request.with_publisher(&pub_origin).ok().await?; @@ -2045,7 +2045,7 @@ async fn quic_driver_task_inherits_connection_span() { group.finish().expect("failed to finish group"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; let server = server_config.init(Default::default()).expect("failed to init server"); @@ -2173,7 +2173,7 @@ async fn resubscribe_keeps_flowing_moq_lite_03() { group0.finish().expect("finish group 0"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; server_config.version = vec!["moq-lite-03".parse().unwrap()]; let server = server_config.init(Default::default()).expect("init server"); @@ -2308,7 +2308,7 @@ async fn idle_subscription_releases_the_viewer_count() { group.finish().expect("finish group"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; let server = server_config.init(Default::default()).expect("init server"); let mut server = server.listen().await.expect("failed to listen"); @@ -2414,7 +2414,7 @@ async fn websocket_unauthorized_handshake_is_explicit() { }); let mut client_config = moq_tokio::connect::Config::default(); - client_config.websocket.delay = Duration::ZERO.into(); + client_config.websocket.delay = Duration::ZERO; let client = client_config.init(Default::default()).expect("failed to init client"); let url: url::Url = format!("ws://{addr}").parse().unwrap(); @@ -2451,7 +2451,7 @@ async fn reconnect_stops_on_websocket_unauthorized() { }); let mut client_config = moq_tokio::connect::Config::default(); - client_config.websocket.delay = Duration::ZERO.into(); + client_config.websocket.delay = Duration::ZERO; let client = client_config.init(Default::default()).expect("failed to init client"); let url: url::Url = format!("ws://{addr}").parse().unwrap(); @@ -2503,7 +2503,7 @@ async fn websocket_forbidden_does_not_end_a_quic_connect() { let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); // No head start, so the 403 lands before the QUIC handshake completes. - client_config.websocket.delay = Duration::ZERO.into(); + client_config.websocket.delay = Duration::ZERO; let client = client_config.init(Default::default()).expect("failed to init client"); // http:// dials QUIC as https:// and the fallback as plain ws://, which the listener // above can answer without TLS. @@ -2596,7 +2596,7 @@ async fn one_shot_connect_surfaces_the_session_close() { client_config.tls.insecure = Some(true); client_config.once = Some(true); // A tiny backoff so a buggy redial happens well within the sleep below. - client_config.backoff.initial = Duration::from_millis(10).into(); + client_config.backoff.initial = Duration::from_millis(10); let client = client_config.init(Default::default()).expect("failed to init client"); let connection = tokio::time::timeout(TIMEOUT, client.connect(url).established()) @@ -2658,8 +2658,8 @@ async fn a_dead_session_unannounces_while_the_reconnect_retries() { let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); // Retry forever with a fast cadence: the worst case for a stale announce. - client_config.backoff.initial = Duration::from_millis(10).into(); - client_config.backoff.timeout = Duration::ZERO.into(); + client_config.backoff.initial = Duration::from_millis(10); + client_config.backoff.timeout = Duration::ZERO; let client = client_config.init(Default::default()).expect("failed to init client"); let connection = tokio::time::timeout(TIMEOUT, client.with_subscriber(sub_origin).connect(url).established()) @@ -2805,7 +2805,7 @@ async fn wildcard_scope_test(version: &str, server_scope: &str) { .expect("scope publish origin"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; server_config.version = vec![version.parse().unwrap()]; let server = server_config.init(Default::default()).expect("init server"); @@ -3026,7 +3026,7 @@ async fn publish_only_client_to_subscribe_only_server() { /// A test server bound to a free port with a generated localhost certificate. async fn test_server() -> (moq_tokio::Listener, std::net::SocketAddr) { let mut config = moq_tokio::listen::Config::default(); - config.bind = Some("[::]:0".to_string()); + config.bind = Some("[::]:0".parse().unwrap()); config.tls.generate = vec!["localhost".into()]; let server = config.init(Default::default()).expect("failed to init server"); let server = server.listen().await.expect("failed to listen"); @@ -3079,7 +3079,7 @@ async fn goaway_test(scheme: &str, version: &str, expect_wire_timeout: bool) { group.finish().expect("failed to finish group"); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; server_config.version = vec![version]; @@ -3222,7 +3222,7 @@ async fn goaway_timeout_force_close_moq_transport_19_quic() { let pub_origin = moq_tokio::origin::spawn(Hop::random()); let mut server_config = moq_tokio::listen::Config::default(); - server_config.bind = Some("[::]:0".to_string()); + server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; server_config.version = vec![version]; let server = server_config.init(Default::default()).expect("failed to init server"); @@ -3318,8 +3318,8 @@ async fn zero_initial_backoff_still_gives_up_on_a_flapping_peer() { let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); - client_config.backoff.initial = Duration::ZERO.into(); - client_config.backoff.timeout = Duration::from_millis(500).into(); + client_config.backoff.initial = Duration::ZERO; + client_config.backoff.timeout = Duration::from_millis(500); let client = client_config.init(Default::default()).expect("failed to init client"); let connection = client.connect(url); @@ -3344,7 +3344,7 @@ async fn session_close_surfaces_a_rejection_code() { let server_handle = tokio::spawn(async move { while let Some(request) = server.accept().await { - request.close(403).await?; + request.reject(moq_tokio::server::Reject::Forbidden).await?; } Ok::<_, anyhow::Error>(()) }); @@ -3357,7 +3357,7 @@ async fn session_close_surfaces_a_rejection_code() { .await .expect("close timed out") .expect_err("a rejected session must surface as an error"); - // `Request::close` maps both 401 and 403 onto the wire's single UNAUTHORIZED. + // `Request::reject` maps both 401 and 403 onto the wire's single UNAUTHORIZED. assert_connect_error(&err, moq_tokio::ConnectError::Unauthorized); server_handle.abort(); @@ -3374,7 +3374,7 @@ async fn reconnect_stops_on_a_session_level_rejection() { let server_handle = tokio::spawn(async move { while let Some(request) = server.accept().await { - request.close(401).await?; + request.reject(moq_tokio::server::Reject::Unauthorized).await?; } Ok::<_, anyhow::Error>(()) }); @@ -3402,7 +3402,7 @@ async fn one_shot_surfaces_a_session_level_rejection() { let server_handle = tokio::spawn(async move { while let Some(request) = server.accept().await { - request.close(403).await?; + request.reject(moq_tokio::server::Reject::Forbidden).await?; } Ok::<_, anyhow::Error>(()) }); diff --git a/rs/moq-tokio/tests/reconnect.rs b/rs/moq-tokio/tests/reconnect.rs index a77936bf40..8358f75cb4 100644 --- a/rs/moq-tokio/tests/reconnect.rs +++ b/rs/moq-tokio/tests/reconnect.rs @@ -23,9 +23,9 @@ fn client(backoff: moq_tokio::Backoff) -> moq_tokio::Client { #[tokio::test] async fn a_transient_failure_retries_until_the_budget_runs_out() { let mut backoff = moq_tokio::Backoff::default(); - backoff.initial = Duration::from_millis(20).into(); - backoff.max = Duration::from_millis(40).into(); - backoff.timeout = Duration::from_millis(200).into(); + backoff.initial = Duration::from_millis(20); + backoff.max = Duration::from_millis(40); + backoff.timeout = Duration::from_millis(200); // Nothing listens on port 1, so every attempt is refused: transient as far as this layer knows. let url: url::Url = "tcp://127.0.0.1:1".parse().expect("failed to parse url"); @@ -148,9 +148,9 @@ async fn spawn_server() -> ( /// server inside the test's patience. fn quick_client(redirect: moq_tokio::Redirect) -> moq_tokio::Client { let mut config = moq_tokio::connect::Config::default(); - config.backoff.initial = Duration::from_millis(20).into(); - config.backoff.max = Duration::from_millis(40).into(); - config.backoff.timeout = Duration::ZERO.into(); + config.backoff.initial = Duration::from_millis(20); + config.backoff.max = Duration::from_millis(40); + config.backoff.timeout = Duration::ZERO; config.goaway.redirect = redirect; config.init(Default::default()).expect("failed to init client") } diff --git a/rs/moq-tokio/tests/worker.rs b/rs/moq-tokio/tests/worker.rs index c9f5eaa2df..127ff6148b 100644 --- a/rs/moq-tokio/tests/worker.rs +++ b/rs/moq-tokio/tests/worker.rs @@ -37,12 +37,23 @@ fn certificate(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf fn listen_config(cert: &std::path::Path, key: &std::path::Path, port: u16) -> moq_tokio::listen::Config { let mut config = moq_tokio::listen::Config::default(); - config.bind = Some(format!("127.0.0.1:{port}")); + config.bind = Some(format!("127.0.0.1:{port}").parse().unwrap()); config.tls.cert = vec![cert.to_path_buf()]; config.tls.key = vec![key.to_path_buf()]; config } +fn bind_workers( + listen: moq_tokio::listen::Config, + quic: moq_tokio::quic::Config, + worker: worker::Config, +) -> moq_tokio::Result { + let mut server = moq_tokio::server::Config::default(); + server.listen = listen; + server.quic = quic; + Workers::bind(server, worker) +} + /// Pinning is off throughout: a CI container may restrict which cores it may run /// on, and none of these tests are about placement. fn config(count: u16) -> worker::Config { @@ -62,14 +73,14 @@ async fn dropping_the_workers_releases_the_port() { let port = free_udp_port(); let workers = - Workers::bind(listen_config(&cert, &key, port), Default::default(), config(WORKERS)).expect("bind workers"); + bind_workers(listen_config(&cert, &key, port), Default::default(), config(WORKERS)).expect("bind workers"); assert_eq!(workers.len(), usize::from(WORKERS)); // Serving first is the case that used to strand the threads. The accept // loops never return: ending them is the group's job, via shutdown below. let mut group = workers.split(); - for (server, spawner) in group.members() { - spawner.serve(server, |server| async move { + for member in group.members() { + member.serve(|server| async move { let _listener = server.listen().await.expect("bind stream listeners"); std::future::pending::<()>().await; }); @@ -91,13 +102,13 @@ async fn spawner_runs_a_send_less_future() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let workers = Workers::bind(listen_config(&cert, &key, 0), Default::default(), config(1)).expect("bind worker"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(1)).expect("bind worker"); let mut group = workers.split(); let task = { let mut split = group.members(); - let (_server, spawner) = split.pop().expect("one worker"); - spawner.run(|| async move { + let member = split.pop().expect("one worker"); + member.run(|| async move { let value = std::rc::Rc::new(std::cell::Cell::new(1)); tokio::task::yield_now().await; value.set(value.get() + 1); @@ -124,14 +135,14 @@ async fn spawner_contains_a_factory_panic() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let workers = Workers::bind(listen_config(&cert, &key, 0), Default::default(), config(1)).expect("bind worker"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(1)).expect("bind worker"); let mut group = workers.split(); let (panicked, survived) = { let mut split = group.members(); - let (_server, spawner) = split.pop().expect("one worker"); - let panicked = spawner.run(|| -> std::future::Pending<()> { panic!("factory") }); - let survived = spawner.run(|| async { "still here" }); + let member = split.pop().expect("one worker"); + let panicked = member.run(|| -> std::future::Pending<()> { panic!("factory") }); + let survived = member.run(|| async { "still here" }); (panicked, survived) }; @@ -148,7 +159,7 @@ async fn spawner_abort_reaches_the_worker() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let workers = Workers::bind(listen_config(&cert, &key, 0), Default::default(), config(1)).expect("bind worker"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(1)).expect("bind worker"); // Dropped when the future is, so it reports the cancellation from the worker. let (dropped, was_dropped) = tokio::sync::oneshot::channel::<()>(); @@ -157,8 +168,8 @@ async fn spawner_abort_reaches_the_worker() { let mut group = workers.split(); let task = { let mut split = group.members(); - let (_server, spawner) = split.pop().expect("one worker"); - spawner.run(move || async move { + let member = split.pop().expect("one worker"); + member.run(move || async move { let _dropped = dropped; let _ = started.send(()); std::future::pending::<()>().await; @@ -182,7 +193,7 @@ async fn spawner_abort_races_the_handoff() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let workers = Workers::bind(listen_config(&cert, &key, 0), Default::default(), config(1)).expect("bind worker"); + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(1)).expect("bind worker"); // Dropped with the future, whether or not it was ever polled. let (dropped, was_dropped) = tokio::sync::oneshot::channel::<()>(); @@ -190,8 +201,8 @@ async fn spawner_abort_races_the_handoff() { let mut group = workers.split(); let task = { let mut split = group.members(); - let (_server, spawner) = split.pop().expect("one worker"); - spawner.run(move || async move { + let member = split.pop().expect("one worker"); + member.run(move || async move { let _dropped = dropped; std::future::pending::<()>().await; }) @@ -218,7 +229,7 @@ async fn dropping_unserved_workers_releases_the_port() { let port = free_udp_port(); let workers = - Workers::bind(listen_config(&cert, &key, port), Default::default(), config(WORKERS)).expect("bind workers"); + bind_workers(listen_config(&cert, &key, port), Default::default(), config(WORKERS)).expect("bind workers"); drop(workers); let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); @@ -235,7 +246,7 @@ async fn an_ephemeral_port_is_shared_by_the_group() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let workers = Workers::bind(listen_config(&cert, &key, 0), Default::default(), config(WORKERS)) + let workers = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(WORKERS)) .expect("a group may take an ephemeral port"); assert_eq!(workers.len(), usize::from(WORKERS)); @@ -264,9 +275,9 @@ async fn an_occupied_port_is_refused() { let port = free_udp_port(); let workers = - Workers::bind(listen_config(&cert, &key, port), Default::default(), config(WORKERS)).expect("bind workers"); + bind_workers(listen_config(&cert, &key, port), Default::default(), config(WORKERS)).expect("bind workers"); - let err = Workers::bind(listen_config(&cert, &key, port), Default::default(), config(WORKERS)) + let err = bind_workers(listen_config(&cert, &key, port), Default::default(), config(WORKERS)) .expect_err("a second group must not join the first"); assert!( matches!(err, moq_tokio::Error::WorkerOverlap { .. }), @@ -276,7 +287,7 @@ async fn an_occupied_port_is_refused() { // The lock and the probe must be gone with the group: a second group takes // the same address cleanly once the first shuts down. workers.shutdown().await; - let again = Workers::bind(listen_config(&cert, &key, port), Default::default(), config(WORKERS)) + let again = bind_workers(listen_config(&cert, &key, port), Default::default(), config(WORKERS)) .expect("the released address must be bindable again"); drop(again); } @@ -294,12 +305,12 @@ async fn the_other_wildcard_spelling_is_refused() { let port = free_udp_port(); let mut v4 = listen_config(&cert, &key, port); - v4.bind = Some(format!("0.0.0.0:{port}")); - let workers = Workers::bind(v4, Default::default(), config(WORKERS)).expect("bind v4 wildcard workers"); + v4.bind = Some(format!("0.0.0.0:{port}").parse().unwrap()); + let workers = bind_workers(v4, Default::default(), config(WORKERS)).expect("bind v4 wildcard workers"); let mut v6 = listen_config(&cert, &key, port); - v6.bind = Some(format!("[::]:{port}")); - let err = Workers::bind(v6, Default::default(), config(WORKERS)) + v6.bind = Some(format!("[::]:{port}").parse().unwrap()); + let err = bind_workers(v6, Default::default(), config(WORKERS)) .expect_err("the overlapping wildcard spelling must be refused"); assert!( matches!(err, moq_tokio::Error::WorkerOverlap { .. }), @@ -324,13 +335,13 @@ async fn a_shared_port_is_refused_across_addresses() { let port = free_udp_port(); let workers = - Workers::bind(listen_config(&cert, &key, port), Default::default(), config(WORKERS)).expect("bind workers"); + bind_workers(listen_config(&cert, &key, port), Default::default(), config(WORKERS)).expect("bind workers"); // A distinct loopback address: no bind conflict with 127.0.0.1, only the // shared port. let mut other = listen_config(&cert, &key, port); - other.bind = Some(format!("127.0.0.2:{port}")); - let err = Workers::bind(other, Default::default(), config(WORKERS)) + other.bind = Some(format!("127.0.0.2:{port}").parse().unwrap()); + let err = bind_workers(other, Default::default(), config(WORKERS)) .expect_err("a second group sharing the port must be refused"); assert!( matches!(err, moq_tokio::Error::WorkerOverlap { .. }), @@ -356,7 +367,7 @@ async fn a_foreign_reuseport_group_is_refused() { .expect("bind foreign reuseport socket"); let port = foreign.local_addr().expect("local addr").port(); - Workers::bind(listen_config(&cert, &key, port), Default::default(), config(WORKERS)) + bind_workers(listen_config(&cert, &key, port), Default::default(), config(WORKERS)) .expect_err("a group must not join a foreign reuseport member"); } @@ -371,7 +382,7 @@ async fn an_unaddressable_group_is_refused() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let err = Workers::bind(listen_config(&cert, &key, 0), Default::default(), config(257)) + let err = bind_workers(listen_config(&cert, &key, 0), Default::default(), config(257)) .expect_err("a group larger than the prefix can name"); assert!( matches!(err, moq_tokio::Error::WorkerCount { count: 257, max: 256 }), @@ -386,11 +397,11 @@ async fn generated_certificates_are_refused() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let mut listen = moq_tokio::listen::Config::default(); - listen.bind = Some(format!("127.0.0.1:{}", free_udp_port())); + listen.bind = Some(format!("127.0.0.1:{}", free_udp_port()).parse().unwrap()); listen.tls.generate = vec!["localhost".to_string()]; - let err = Workers::bind(listen, Default::default(), config(WORKERS)) - .expect_err("generated certificates cannot be shared"); + let err = + bind_workers(listen, Default::default(), config(WORKERS)).expect_err("generated certificates cannot be shared"); assert!( matches!(err, moq_tokio::Error::WorkerTlsGenerate), "unexpected error: {err}" @@ -399,10 +410,11 @@ async fn generated_certificates_are_refused() { /// How many UDP sockets this process holds on `port`, via procfs. /// -/// Each worker member is one reuseport socket. The group retains every socket -/// until serving has stopped, so dropping a server handle must not change this -/// count while the group is alive: without the retainer the kernel would close -/// the socket and renumber every member after it. +/// The group retains every reuseport socket until serving has stopped, so +/// dropping a server handle must not change this count while the group is +/// alive: without the retainer the kernel would close the socket and renumber +/// every member after it. A backend may own more than one descriptor per +/// member, so the invariant is the stable count rather than its exact value. #[cfg(target_os = "linux")] fn udp_sockets_on(port: u16) -> usize { let want = format!(":{port:04X}"); @@ -436,31 +448,32 @@ async fn dropping_a_server_keeps_its_socket() { let (cert, key) = certificate(dir.path()); let port = free_udp_port(); - let workers = Workers::bind(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); let mut group = workers.split(); - assert_eq!(udp_sockets_on(port), 2, "every member holds a socket"); + let sockets = udp_sockets_on(port); + assert!(sockets >= 2, "every member holds at least one socket"); assert_eq!(group.local_addr().port(), port); let mut members = group.members(); assert_eq!(members.len(), 2); // An unused handle: never served, just dropped. - let (dropped, _) = members.pop().expect("two members"); + let dropped = members.pop().expect("two members"); drop(dropped); assert_eq!( udp_sockets_on(port), - 2, + sockets, "dropping a server must not lose its socket while the group lives" ); // The survivor still owns its address, and the port is still held. // `members` is in index order and `pop` takes the last, so this drops // member 1 and keeps member 0. - let (_server, spawner) = members.pop().expect("one member"); - assert_eq!(spawner.index(), 0); - drop(_server); - assert_eq!(udp_sockets_on(port), 2, "the retainer outlives both handles"); + let member = members.pop().expect("one member"); + assert_eq!(member.index(), 0); + drop(member); + assert_eq!(udp_sockets_on(port), sockets, "the retainer outlives both handles"); group.shutdown().await; assert_eq!(udp_sockets_on(port), 0, "stopping the group releases every socket"); @@ -478,7 +491,7 @@ async fn completing_a_member_stops_its_siblings() { let (cert, key) = certificate(dir.path()); let port = free_udp_port(); - let workers = Workers::bind(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); let mut group = workers.split(); let mut members = group.members(); assert_eq!(members.len(), 2); @@ -486,14 +499,14 @@ async fn completing_a_member_stops_its_siblings() { // The sibling signals once its serving future is running, so the completion // below cannot win the race before it has started. let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>(); - let (server_sibling, spawner_sibling) = members.pop().expect("sibling"); - let sibling = spawner_sibling.serve(server_sibling, |server| async move { + let member_sibling = members.pop().expect("sibling"); + let sibling = member_sibling.serve(|server| async move { let _listener = server.listen().await.expect("bind stream listeners"); let _ = started_tx.send(()); std::future::pending::<()>().await; }); - let (server_done, spawner_done) = members.pop().expect("completing member"); - let done = spawner_done.serve(server_done, |server| async move { + let member_done = members.pop().expect("completing member"); + let done = member_done.serve(|server| async move { let _listener = server.listen().await.expect("bind stream listeners"); started_rx.await.expect("sibling started"); "done" @@ -529,19 +542,19 @@ async fn cancelling_a_member_stops_its_siblings() { let (cert, key) = certificate(dir.path()); let port = free_udp_port(); - let workers = Workers::bind(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); let mut group = workers.split(); let mut members = group.members(); let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>(); - let (server_a, spawner_a) = members.pop().expect("member a"); - let task_a = spawner_a.serve(server_a, |server| async move { + let member_a = members.pop().expect("member a"); + let task_a = member_a.serve(|server| async move { let _listener = server.listen().await.expect("bind stream listeners"); let _ = started_tx.send(()); std::future::pending::<()>().await; }); - let (server_b, spawner_b) = members.pop().expect("member b"); - let task_b = spawner_b.serve(server_b, |server| async move { + let member_b = members.pop().expect("member b"); + let task_b = member_b.serve(|server| async move { let _listener = server.listen().await.expect("bind stream listeners"); started_rx.await.expect("sibling started"); std::future::pending::<()>().await; @@ -575,19 +588,19 @@ async fn a_panicking_member_stops_its_siblings() { let (cert, key) = certificate(dir.path()); let port = free_udp_port(); - let workers = Workers::bind(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); let mut group = workers.split(); let mut members = group.members(); let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>(); - let (server_sibling, spawner_sibling) = members.pop().expect("sibling"); - let sibling = spawner_sibling.serve(server_sibling, |server| async move { + let member_sibling = members.pop().expect("sibling"); + let sibling = member_sibling.serve(|server| async move { let _listener = server.listen().await.expect("bind stream listeners"); let _ = started_tx.send(()); std::future::pending::<()>().await; }); - let (server_panics, spawner_panics) = members.pop().expect("panicking member"); - let panics = spawner_panics.serve(server_panics, |server| async move { + let member_panics = members.pop().expect("panicking member"); + let panics = member_panics.serve(|server| async move { let _listener = server.listen().await.expect("bind stream listeners"); started_rx.await.expect("sibling started"); panic!("serving panicked"); @@ -620,11 +633,11 @@ async fn shutdown_with_work_in_flight_joins() { let (cert, key) = certificate(dir.path()); let port = free_udp_port(); - let workers = Workers::bind(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); let mut group = workers.split(); let mut tasks = Vec::new(); - for (server, spawner) in group.members() { - tasks.push(spawner.serve(server, |server| async move { + for member in group.members() { + tasks.push(member.serve(|server| async move { let _listener = server.listen().await.expect("bind stream listeners"); std::future::pending::<()>().await; })); @@ -651,13 +664,13 @@ async fn dropping_the_group_with_work_in_flight_stops() { let (cert, key) = certificate(dir.path()); let port = free_udp_port(); - let workers = Workers::bind(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); + let workers = bind_workers(listen_config(&cert, &key, port), Default::default(), config(2)).expect("bind workers"); let mut group = workers.split(); let mut tasks = Vec::new(); { let mut members = group.members(); - for (server, spawner) in members.drain(..) { - tasks.push(spawner.serve(server, |server| async move { + for member in members.drain(..) { + tasks.push(member.serve(|server| async move { let _listener = server.listen().await.expect("bind stream listeners"); std::future::pending::<()>().await; })); diff --git a/swift/Sources/Moq/Server.swift b/swift/Sources/Moq/Server.swift index 074bcd10ef..7fe1b148c9 100644 --- a/swift/Sources/Moq/Server.swift +++ b/swift/Sources/Moq/Server.swift @@ -116,7 +116,7 @@ public final class Request: Sendable { Session(try await ffi.accept()) } - /// Reject the session with the given HTTP status code. + /// Reject the session with an application error code; 401 and 403 map to unauthorized. public func reject(code: UInt16) async throws { try await ffi.reject(code: code) } diff --git a/test/wasm/src/main.ts b/test/wasm/src/main.ts index 77b1ea9a3a..70585a9d52 100644 --- a/test/wasm/src/main.ts +++ b/test/wasm/src/main.ts @@ -133,57 +133,53 @@ async function connect(wasm: Wasm, relay: RelayFixture): Promise { * It keeps publishing rather than writing a fixed number of groups and closing: * a track that ends before the subscriber's stream is wired up is only reachable * through the relay's cache, so the test would be measuring retention, and would - * race. A live edge that keeps moving has neither problem. Every track name - * other than {@link TRACK} is rejected, which is what the refusal case reads. + * race. A live edge that keeps moving has neither problem. The `missing` track + * is rejected when subscribed, which is what the refusal case reads. */ async function withPublisher(relay: RelayFixture, path: string, run: () => Promise): Promise { // The session serves an origin rather than individual broadcasts, so the path is // published into the origin and the session announces the table. const origin = new Moq.Origin.Producer(); const broadcast = origin.createBroadcast(Moq.Path.from(path)); + const track = broadcast.createTrack(TRACK); + const missing = broadcast.createTrack("missing"); broadcast.announce(); - const connection = await Moq.Connection.connect(new URL(relay.url), { publish: origin.consume() }); + const connection = await Moq.Connection.connect({ url: new URL(relay.url), publish: origin.consume() }); let stopped = false; - const writers: Promise[] = []; - - // lite-05 looks a track's info up before subscribing, so the same name can be - // requested more than once. Each request gets its own producer writing the same - // groups; the subscriber only ever reads the one it asked for. - const serving = (async () => { - for (;;) { - const request = await broadcast.requested(); - if (!request) break; - if (request.name !== TRACK) { - request.reject(new Error(`no such track: ${request.name}`)); + const writer = (async () => { + while (!stopped && track.closed.peek() === undefined) { + if (!track.used.peek()) { + await Promise.race([track.used.changed(), track.closed]); continue; } - const track = request.accept(); - writers.push( - (async () => { - while (!stopped && track.closed.peek() === undefined) { - const group = track.appendGroup(); - for (const frame of FIXTURE) { - group.writeFrame({ payload: frame, timestamp: Moq.Time.Timestamp.now() }); - } - group.close(); - await sleep(GROUP_INTERVAL_MS); - } - track.close(); - })(), - ); + const group = track.appendGroup(); + for (const frame of FIXTURE) { + group.writeFrame({ payload: frame, timestamp: Moq.Time.Timestamp.now() }); + } + group.close(); + await Promise.race([sleep(GROUP_INTERVAL_MS), track.used.changed(), track.closed]); + } + track.close(); + })(); + const rejecting = (async () => { + while (!missing.used.peek() && missing.closed.peek() === undefined) { + await Promise.race([missing.used.changed(), missing.closed]); } + if (missing.used.peek()) missing.close(new Error("no such track: missing")); })(); try { return await run(); } finally { stopped = true; + track.close(); + missing.close(); broadcast.close(); connection.close(); origin.close(); - await serving; - await Promise.all(writers); + await writer; + await rejecting; } }