diff --git a/Cargo.lock b/Cargo.lock index d6888bbdf77..2d293f71675 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -638,28 +638,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "aws-lc-fips-sys" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03367707e92796b190a4207d4d39b0a4271d574503d2969c2b0cfbf5c87658ee" -dependencies = [ - "bindgen 0.72.1", - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", - "regex", -] - [[package]] name = "aws-lc-rs" version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ - "aws-lc-fips-sys", "aws-lc-sys", "untrusted 0.7.1", "zeroize", @@ -861,8 +845,6 @@ dependencies = [ "cexpr", "clang-sys", "itertools 0.13.0", - "log", - "prettyplease", "proc-macro2", "quote", "regex", @@ -5151,7 +5133,6 @@ dependencies = [ "rustc-hash 2.1.3", "rustls", "rustls-pki-types", - "rustls-platform-verifier", "slab", "thiserror 2.0.21", "tinyvec", @@ -5186,7 +5167,6 @@ dependencies = [ "http-body", "http-cache-reqwest", "humantime", - "humantime-serde", "hyper-util", "libc", "moq-auth 0.2.1", @@ -5442,7 +5422,6 @@ dependencies = [ "stun-types", "subtle", "tempfile", - "test-env", "thiserror 2.0.21", "tikv-jemalloc-ctl", "tikv-jemallocator", diff --git a/bench/ffi-gen/src/client.rs b/bench/ffi-gen/src/client.rs index 70d4231bed9..cb2af54f776 100644 --- a/bench/ffi-gen/src/client.rs +++ b/bench/ffi-gen/src/client.rs @@ -24,15 +24,14 @@ impl MoqClient { /// A client that dials once, with no reconnect. #[cfg_attr(feature = "uniffi", uniffi::constructor)] pub fn new() -> Result { - let mut connect = moq_tokio::connect::Config::default(); - connect.once = Some(true); + let connect = moq_tokio::connect::Config::default(); let _guard = runtime().enter(); let client = connect .init(moq_tokio::quic::Config::default()) .map_err(|err| MoqError::Net { reason: err.to_string(), })?; - Ok(Self { client }) + Ok(Self { client: client.with_reconnect(false) }) } /// Connect to `url`, publishing and consuming through `origin`. Takes `&self`, which diff --git a/deny.toml b/deny.toml index 0e2896ca8c2..084f55b2ffd 100644 --- a/deny.toml +++ b/deny.toml @@ -67,11 +67,7 @@ allow = [ "Unlicense", "Zlib", ] -exceptions = [ - # AWS-LC's FIPS build still carries OpenSSL-licensed code. Reachable only - # through moq-quic's opt-in `aws-lc-rs-fips` feature, kept from quinn. - { crate = "aws-lc-fips-sys", allow = ["OpenSSL"] }, -] +exceptions = [] [bans] multiple-versions = "warn" diff --git a/doc/bin/gstreamer.md b/doc/bin/gstreamer.md index 2489e17118e..f56cba540d5 100644 --- a/doc/bin/gstreamer.md +++ b/doc/bin/gstreamer.md @@ -153,6 +153,6 @@ preferred first; every stable version by default), `tls-disable-verify`, Debug with `GST_DEBUG=*:4` for GStreamer and `RUST_LOG=debug` for the plugin. To decrypt a capture, set `quic-keylog=true` on either element along with `SSLKEYLOGFILE`; the variable alone writes nothing, and the flag without it fails -the start. +the start. Existing Unix log files must be private (mode 0600); files accessible to group or others are refused. `moqsrc` creates source pads only for enabled renditions. Disabling a rendition stops its media and holds its pad idle without EOS; enabling it again resumes on the same pad. diff --git a/doc/bin/obs.md b/doc/bin/obs.md index 5bce66618d5..335788987f8 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -91,7 +91,7 @@ supports them), and the WebSocket fallback race. A rejected value stops the stream with the reason in the log rather than silently using a default. To decrypt a packet capture, turn on **TLS key log** here or in a MoQ Source's properties and start OBS with `SSLKEYLOGFILE` naming the file; the variable -alone writes nothing, and the toggle without it fails the connection. +alone writes nothing, and the toggle without it fails the connection. Existing Unix log files must be private (mode 0600); files accessible to group or others are refused. The plugin is C++ over the [generated C++ bindings](/lib/cpp/) and ships with every moq-cpp release. Stopping finishes the tracks and drains for up to one diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 367819b4503..7bde4e3c424 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -60,15 +60,16 @@ predecessor's QUIC connections, so clients reconnect at once instead of waiting out the idle timeout. Keep it secret, and give it to one relay only: any holder resets every other holder's connections, so live relays behind one address would reset each other's clients unless the load balancer routes by -connection ID (`lb_id`, QUIC-LB). Relays on different addresses gain nothing from sharing +connection ID (`[listen.lb] id`, QUIC-LB). Relays on different addresses gain nothing from sharing it, since a client accepts a reset only from its relay's address. Create one with `openssl rand -hex 32 > reset.key`. A setting no configured listener reads stops startup rather than being ignored. -A stream-only relay refuses `preferred_v4`, `preferred_v6`, `lb_id` (or `load_balancer`), and +A stream-only relay refuses `preferred_v4`, `preferred_v6`, `lb.id`, and `reset.secret`, which only QUIC reads, and a `[listen.tls]` `cert`, `key`, or `generate` unless `tcp.tls` serves it. -`lb_nonce` needs `lb_id`. `lb_id` and `lb_nonce` cannot be combined with `load_balancer`. +`[listen.lb] nonce` needs an `id` in the same table; unset nonce uses 8. +The `--listen-quic-lb-id` and `--listen-quic-lb-nonce` flags update that table. `unix.allow` needs `unix.bind`. ## \[quic] @@ -80,21 +81,24 @@ Transport tuning, applied to accepted and dialed connections alike. congestion_control = "delay" # "delay" (BBR, the default), "loss" (CUBIC), or "none". max_streams = 10000 # Concurrent streams per connection, bidi and uni; 0 is refused. Default. idle_timeout = "10s" # Drop a connection after this long with nothing on it. Default. -keep_alive = "3s" # Ping interval; "0s" disables it. iroh still pings at least every 5s. Default. +keep_alive = "3s" # Ping interval; "0s" disables it. iroh refuses zero and keeps a separate 5s path heartbeat. Default. gso = true # UDP segmentation offload. iroh cannot turn it off. mtu_discovery = false # Path MTU discovery. Default. receive_window = 134217728 # Whole-connection window, in bytes. Default (128 MiB). stream_receive_window = 4194304 # Per-stream window, in bytes. Default (4 MiB). send_window = 16777216 # Unacknowledged outgoing data, in bytes. Default (16 MiB). qlog = "/var/log/moq/qlog" # Existing directory. Needs the `qlog` build feature. -keylog = false # Write TLS secrets to the file SSLKEYLOGFILE names. Default. +# keylog = "/private/keys.log" # Explicit secret log path; unset is off. ``` `congestion_control = "none"` runs no congestion control and no pacing. It is meant for tests and controlled links, where nothing else shares the path. -`SSLKEYLOGFILE` alone writes nothing: set `keylog = true` too, and only while -debugging, since the file decrypts every captured session. +Set `keylog` only while debugging, since the file decrypts every captured session. +Tokio and io\_uring open the configured path at initialization. New Unix files are +mode 0600; an existing file readable by group or others is refused without changing +its permissions. `SSLKEYLOGFILE` does not configure native relay logging. +Iroh currently refuses an explicit keylog path when enabled. `idle_timeout` is how long a peer that vanished without a close keeps its sessions, and so its [cluster routes](/bin/relay/cluster#failure-detection). diff --git a/quest/m1/api-audit/README.md b/quest/m1/api-audit/README.md index 603b0346737..b98c026e022 100644 --- a/quest/m1/api-audit/README.md +++ b/quest/m1/api-audit/README.md @@ -74,7 +74,6 @@ Rejected in planning: - [Media surface trim](/quest/m1/api-audit/media-trim.md) - media crate items with no consumer go private, and Rust/JS media names agree - [One frame rate name](/quest/m1/api-audit/frame-rate.md) - core `moq_video::Rate` becomes `FrameRate`, as the bindings name it - [Pinned consumers adopt the CLI flag names](/quest/m1/api-audit/cli-flags-consumers.md) - pinned downstream deployments move past the flag rename and use its spellings -- [Transport config](/quest/m1/api-audit/transport-config.md) - moq-tokio and moq-quic configs have one meaning for unset, enums for modes, no order-dependent setters, and no leftover features - [Session-only items stay private](/quest/m1/api-audit/model-private.md) - model items only the built-in sessions use are private - [Only sessions accept a request](/quest/m1/api-audit/claim-request-type.md) - accepting an application claim's request fails to compile, and the peer-route hatches are crate-private - [A request's source enforces its validity](/quest/m1/api-audit/answer.md) - a stale source refuses new track requests by itself, with no public additions diff --git a/quest/m1/api-audit/transport-config.md b/quest/m1/api-audit/transport-config.md deleted file mode 100644 index 2a56210674a..00000000000 --- a/quest/m1/api-audit/transport-config.md +++ /dev/null @@ -1,41 +0,0 @@ -# [M] Transport config - -## Goal - -moq-tokio and moq-quic configuration has one meaning for an unset value, -types modes as enums, has no fields that only one other field reads, and -ships no leftover features. - -## Plan - -Decided 2026-10-22 (API audit): - -- Optional durations: `None` means default in `connect`, `listen`, and - `quic`, yet hand-written `Default` impls fill `Some(DEFAULT_*)`; - `websocket::Options.timeout = None` means forever while `listen.timeout` - uses `Some(ZERO)` for forever. Derive `Default` as all-`None`, keep - defaults in `resolve()`/`Resolved`, and make zero always mean off. -- The `__cli_*` shadow `Duration` fields (about 10 in moq-tokio, plus relay - `config.rs`, `acme.rs`, `stats.rs`, `cache.rs`) let a CLI value silently - override an embedder's assignment; use one humantime newtype field per - duration. -- `quic.keylog: Option` plus `SSLKEYLOGFILE` (failing `KeylogUnset`) - becomes `keylog: Option` like `qlog`. -- `server::Config.tls` and `listen.tls` are exclusive by doc comment only, - and `tls::Listen.resolver` errors with either; `server.tls` overrides - `listen.tls`, and the resolver becomes `tls::Serve::resolver(r)`. -- `Request::ok()` returns `(Session, JoinHandle)` and most callers discard - the handle; return `Session` and surface a driver panic through - `closed()`. -- Delete `quic::Connection` (only wraps `set_limits`), - `worker::Config::with_pin` (unused), and one of `connect.once` or - `Client::with_reconnect(bool)`; the QUIC load balancer gets one config - path (`[listen.lb]`). -- moq-quic's unused features, `rustls` alias, and crate docs moved to - [quic-api trim](/quest/m1/quic-api/trim.md) (2026-10-23). -- Public only for tests: moq-auth `lease::Producer::{due, failed}` and `Due` - go `pub(crate)` (drop moq-auth's `tokio` feature), `connect::Config.quic` and - `listen::Config.quic` (read only by `deprecated()`) go `pub(crate)`, and - moq-uring `manual` and kio `sim` are renamed `_manual`/`_sim` or gated. - -Public API: moq-tokio, moq-auth breaking. Wire: none. diff --git a/quest/m1/js-reload-config.md b/quest/m1/js-reload-config.md index 3acf78685a6..dc1aba2edba 100644 --- a/quest/m1/js-reload-config.md +++ b/quest/m1/js-reload-config.md @@ -8,8 +8,8 @@ and accepts no option it ignores. ## Plan -[Transport config](/quest/m1/api-audit/transport-config.md) splits Rust's -healthy bar out of `backoff.initial` into `backoff.healthy`. `ReloadDelay` +Rust's moq-tokio split its healthy bar out of `backoff.initial` into +`backoff.healthy` ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)). `ReloadDelay` (`js/net/src/connection/reload.ts`) still uses `initial` as the healthy bar. Add `healthy` with Rust's default and a mocked-time test. @@ -20,7 +20,3 @@ the reload loop never passes them to `connect()`. Decided 2026-10-23 unless that quest lands keeping it public, in which case forward it. Public API: breaking JS option change; the Impact bullet says how to migrate. Wire: none. - -## Required - -- [Transport config](/quest/m1/api-audit/transport-config.md) - the Rust `backoff.healthy` this mirrors diff --git a/quest/m1/quic-api/trim.md b/quest/m1/quic-api/trim.md index 0881112b5e4..541a39f6361 100644 --- a/quest/m1/quic-api/trim.md +++ b/quest/m1/quic-api/trim.md @@ -5,7 +5,7 @@ Every public item of moq-quic, `moq_tokio::quic::driver`, and moq-uring's `quic` module has a consumer in this repository, moq.pro, moq.dev, or an m1 quest, or it is private or deleted. Unordered reads, the duplicate limit -setters, unused features, and quinn-era docs are gone. +setters, and quinn-era docs are gone. ## Plan @@ -53,12 +53,6 @@ driver's `read_chunk(max, ordered)` lose the flag, and `Chunk.offset`, deleted. The driver's `read_to_end` is their only caller and reads ordered. Two of the upstream fixes ported so far were unordered-mode bugs. -Moved here from [transport config](/quest/m1/api-audit/transport-config.md): -drop moq-quic's unused features (`rustls-ring`, `tracing-log`, `bloom`, -`platform-verifier`, fips, log) and the "backwards compat" `rustls` alias; -gate `pub use rustls` on `any(ring, aws-lc-rs)` so it exists under -`rustls-aws-lc-rs`. - Docs that still describe quinn: the crate docs (`lib.rs`, "the futures-based quinn API" and C bindings) point at `moq_tokio::quic::driver`; "Quinn" in `config/mod.rs`, `crypto.rs`, `cid_generator.rs`, `config/transport.rs`, and diff --git a/rs/kio/Cargo.toml b/rs/kio/Cargo.toml index e77abfe02c4..a686c5bca39 100644 --- a/rs/kio/Cargo.toml +++ b/rs/kio/Cargo.toml @@ -14,7 +14,7 @@ categories = ["asynchronous", "concurrency"] [features] # Per-task Ready poll accounting for deterministic simulators, never production. -sim = [] +_sim = [] [dependencies] smallvec = { version = "1.15", features = ["union"] } diff --git a/rs/kio/src/consumer.rs b/rs/kio/src/consumer.rs index bb39138bcfb..9b2347332f6 100644 --- a/rs/kio/src/consumer.rs +++ b/rs/kio/src/consumer.rs @@ -72,7 +72,7 @@ impl Consumer { /// channel is still open, registers the [`Waiter`] for notification. /// Returns `Err(`[`Ref`]`)` if the channel has been closed while the /// condition returned by `f` is still pending. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll(&self, waiter: &Waiter, f: F) -> Poll>> where F: FnOnce(&Ref<'_, T>) -> Poll, @@ -96,7 +96,7 @@ impl Consumer { } /// Poll for channel closure, registering the waiter if still open. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll_closed(&self, waiter: &Waiter) -> Poll<()> { let mut state = self.state.lock(); if state.closed { diff --git a/rs/kio/src/coop.rs b/rs/kio/src/coop.rs index 63be5800c85..4f3eafc8135 100644 --- a/rs/kio/src/coop.rs +++ b/rs/kio/src/coop.rs @@ -9,17 +9,17 @@ use std::task::Poll; use crate::Waiter; -#[cfg(feature = "sim")] +#[cfg(feature = "_sim")] thread_local! { static READY: std::cell::Cell> = const { std::cell::Cell::new(None) }; } /// Restores the enclosing count, even when a spawned task panics and its executor /// catches the panic. -#[cfg(feature = "sim")] +#[cfg(feature = "_sim")] struct Reset(Option); -#[cfg(feature = "sim")] +#[cfg(feature = "_sim")] impl Drop for Reset { fn drop(&mut self) { READY.set(self.0); @@ -27,22 +27,22 @@ impl Drop for Reset { } /// Count ready kio polls until the returned guard drops, panicking if the task spins. -#[cfg(feature = "sim")] +#[cfg(feature = "_sim")] #[must_use = "keep the guard until the task poll returns"] pub fn scope() -> impl Drop { Reset(READY.replace(Some(0))) } /// Give a [`Tasks`](crate::Tasks) child's poll its own count, but only inside a counting scope. -#[cfg(feature = "sim")] +#[cfg(feature = "_sim")] pub(crate) fn child() -> impl Drop { Reset(READY.replace(READY.get().map(|_| 0))) } #[inline] -#[cfg_attr(feature = "sim", track_caller)] +#[cfg_attr(feature = "_sim", track_caller)] pub(crate) fn ready(value: T) -> Poll { - #[cfg(feature = "sim")] + #[cfg(feature = "_sim")] { let count = READY.get().map(|count| count + 1); READY.set(count); @@ -75,7 +75,7 @@ impl Budget { /// The yield refills the budget for the next poll. Call it only where `Pending` goes /// straight up to the task: a caller that reads `Pending` as "nothing now" would /// mistake the yield for an answer. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll_yield(&mut self, waiter: &Waiter) -> Poll<()> { match self.left.checked_sub(1) { Some(left) => { diff --git a/rs/kio/src/producer.rs b/rs/kio/src/producer.rs index 155192a2caf..5328d59a80a 100644 --- a/rs/kio/src/producer.rs +++ b/rs/kio/src/producer.rs @@ -124,7 +124,7 @@ impl Producer { /// while pending. /// /// Returns `Poll::Ready(Err(`[`Ref`]`))` if the channel is closed. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll(&self, waiter: &Waiter, f: F) -> Poll, Ref<'_, T>>> where F: FnOnce(&Ref<'_, T>) -> Poll<()>, @@ -151,7 +151,7 @@ impl Producer { /// [`Mut`], so it never flags the state modified and never wakes consumers. /// Use it to wait on a read condition from the producer side without creating /// a [`Consumer`]. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll_ref(&self, waiter: &Waiter, f: F) -> Poll>> where F: FnOnce(&Ref<'_, T>) -> Poll, @@ -193,7 +193,7 @@ impl Producer { /// Poll for channel closure (an explicit [`Mut::close`], e.g. an abort), /// registering the waiter if still open. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll_closed(&self, waiter: &Waiter) -> Poll<()> { let mut state = self.state.lock(); if state.closed { @@ -213,7 +213,7 @@ impl Producer { /// Poll-based variant of [`Self::unused`]: `Ready(Ok(()))` when no consumers /// remain, [`Closed`] if the channel closed first, else `Pending`. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll_unused(&self, waiter: &Waiter) -> Poll> { let mut state = self.state.lock(); if state.closed { @@ -252,7 +252,7 @@ impl Producer { /// Poll-based variant of [`Self::used`]: `Ready(Ok(()))` once a consumer /// exists, [`Closed`] if the channel closed first, else `Pending`. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll_used(&self, waiter: &Waiter) -> Poll> { let mut state = self.state.lock(); if state.closed { diff --git a/rs/kio/src/queue.rs b/rs/kio/src/queue.rs index a4ebba7fe0e..1e2202d6229 100644 --- a/rs/kio/src/queue.rs +++ b/rs/kio/src/queue.rs @@ -130,7 +130,7 @@ impl Queue { /// (or anything that does). /// /// Registers `waiter` while full; a pop or close re-polls it. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll_push_with T>(&self, waiter: &Waiter, make: F) -> Poll> { let mut waiters = { let mut state = self.state.lock(); @@ -181,7 +181,7 @@ impl Queue { /// /// Queued items drain before closure is reported, so [`Closed`] means empty /// *and* closed. Registers `waiter` while empty; a push or close re-polls it. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll_pop(&self, waiter: &Waiter) -> Poll> { let (item, mut waiters) = { let mut state = self.state.lock(); diff --git a/rs/kio/src/shared.rs b/rs/kio/src/shared.rs index 7e77079565d..41a7bd3d85a 100644 --- a/rs/kio/src/shared.rs +++ b/rs/kio/src/shared.rs @@ -61,7 +61,7 @@ impl Shared { /// [`Ref`], so it can't flag the state modified and spuriously wake this poll's own /// waiter. Registers `waiter` while pending; any mutation through a [`lock`](Self::lock) /// guard re-polls it. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll(&self, waiter: &Waiter, f: F) -> Poll> where F: FnOnce(&Ref<'_, T>) -> Poll<()>, diff --git a/rs/kio/src/task.rs b/rs/kio/src/task.rs index a96212ca040..0c3e58d4e41 100644 --- a/rs/kio/src/task.rs +++ b/rs/kio/src/task.rs @@ -342,7 +342,7 @@ impl> Tasks { /// such an arm with [`is_empty`](Self::is_empty). /// `waiter` is registered for the next task wake or push; the tasks /// themselves park on their own wakers. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll(&mut self, waiter: &Waiter) -> Poll<()> { { let mut parent = self.shared.parent.lock().unwrap(); @@ -405,7 +405,7 @@ impl> Tasks { let child_waiter = occupant.park.hold(&cx); // Each child is its own task, so a large sweep of finite children is not // one spinning poll. - #[cfg(feature = "sim")] + #[cfg(feature = "_sim")] let _scope = crate::coop::child(); if occupant.task.poll(child_waiter).is_ready() { self.children[index] = None; diff --git a/rs/kio/src/waiter.rs b/rs/kio/src/waiter.rs index 0dc9c3a8b45..1d26af18930 100644 --- a/rs/kio/src/waiter.rs +++ b/rs/kio/src/waiter.rs @@ -156,7 +156,7 @@ impl Waiter { /// Poll a foreign [`Future`] against this waiter, so it re-wakes the enclosing /// `poll_*` step when it is ready. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll_future(&self, future: Pin<&mut F>) -> Poll { match future.poll(&mut self.context()) { Poll::Ready(value) => crate::coop::ready(value), diff --git a/rs/kio/src/weak.rs b/rs/kio/src/weak.rs index 64f2fdc1d34..cff7c3ce90b 100644 --- a/rs/kio/src/weak.rs +++ b/rs/kio/src/weak.rs @@ -154,7 +154,7 @@ impl ProducerWeak { } /// Poll for channel closure, registering the waiter if still open. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll_closed(&self, waiter: &Waiter) -> Poll<()> { let mut state = self.state.lock(); if state.closed { @@ -174,7 +174,7 @@ impl ProducerWeak { /// Poll-based variant of [`Self::unused`]: `Ready(Ok(()))` when no consumers /// remain, [`Closed`] if the channel closed first, else `Pending`. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll_unused(&self, waiter: &Waiter) -> Poll> { // Closure is checked first, matching `Producer::poll_unused`: a closed channel // with no consumers resolves `Closed` from either handle. @@ -215,7 +215,7 @@ impl ProducerWeak { /// Poll-based variant of [`Self::used`]: `Ready(Ok(()))` once a consumer /// exists, [`Closed`] if the channel closed first, else `Pending`. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll_used(&self, waiter: &Waiter) -> Poll> { // Closure is checked first, matching `Producer::poll_used`. let mut state = self.state.lock(); @@ -280,7 +280,7 @@ impl ConsumerWeak { /// Available here so a watcher can register for changes without joining the /// consumer count, which is what [`Producer::unused`](crate::Producer::unused) /// keys off. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll(&self, waiter: &Waiter, f: F) -> Poll>> where F: FnOnce(&Ref<'_, T>) -> Poll, @@ -313,7 +313,7 @@ impl ConsumerWeak { } /// Poll for channel closure, registering the waiter if still open. - #[cfg_attr(feature = "sim", track_caller)] + #[cfg_attr(feature = "_sim", track_caller)] pub fn poll_closed(&self, waiter: &Waiter) -> Poll<()> { let mut state = self.state.lock(); if state.closed { diff --git a/rs/moq-auth/Cargo.toml b/rs/moq-auth/Cargo.toml index 79251954f24..f632fc26bc8 100644 --- a/rs/moq-auth/Cargo.toml +++ b/rs/moq-auth/Cargo.toml @@ -12,11 +12,9 @@ rust-version.workspace = true [features] default = ["client"] # The HTTP client that drives a lease against an auth server. -client = ["dep:reqwest", "dep:rustls", "tokio", "dep:tracing", "dep:rand"] +client = ["dep:reqwest", "dep:rustls", "dep:tokio", "moq-clock/runtime", "dep:tracing", "dep:rand"] # The reference auth server behind `moq auth serve`: the policy a relay held, on axum. -serve = ["dep:axum", "tokio", "dep:tracing", "tokio/net"] -# The lease clock: `lease::Producer::{due, failed}`. -tokio = ["dep:tokio", "dep:rand", "moq-clock/runtime"] +serve = ["dep:axum", "dep:tokio", "dep:tracing", "tokio/net"] [dependencies] aws-lc-rs = { workspace = true } diff --git a/rs/moq-auth/src/key.rs b/rs/moq-auth/src/key.rs index a119502629c..927146823c8 100644 --- a/rs/moq-auth/src/key.rs +++ b/rs/moq-auth/src/key.rs @@ -345,7 +345,7 @@ impl Key { } /// Async version of [`from_file`](Self::from_file), using `tokio::fs`. - #[cfg(feature = "tokio")] + #[cfg(any(feature = "client", feature = "serve"))] pub async fn from_file_async>(path: P) -> crate::Result { tokio::fs::read_to_string(path).await?.parse() } diff --git a/rs/moq-auth/src/lease.rs b/rs/moq-auth/src/lease.rs index 4f4489034b9..d29a9253580 100644 --- a/rs/moq-auth/src/lease.rs +++ b/rs/moq-auth/src/lease.rs @@ -7,10 +7,10 @@ //! by any in-process logic when the embedder decides itself. No trait, no callbacks. use std::sync::Arc; -#[cfg(feature = "tokio")] +#[cfg(feature = "client")] use std::sync::Mutex; use std::task::Poll; -#[cfg(feature = "tokio")] +#[cfg(feature = "client")] use std::time::Duration; use serde::{Deserialize, Serialize}; @@ -106,15 +106,16 @@ struct State { } /// What the grant's clock requires next. +#[cfg(feature = "client")] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Due { +pub(crate) enum Due { /// Ask the decider for a fresh grant. Revalidate, /// The grant expired before it could be renewed. Expired, } -#[cfg(feature = "tokio")] +#[cfg(feature = "client")] #[derive(Debug)] struct Clock { next: Option, @@ -124,7 +125,7 @@ struct Clock { revision: u64, } -#[cfg(feature = "tokio")] +#[cfg(feature = "client")] impl Clock { fn new(grant: &Grant) -> Self { Self { @@ -146,16 +147,16 @@ impl Clock { #[derive(Debug)] pub struct Producer { state: kio::Shared, - #[cfg(feature = "tokio")] + #[cfg(feature = "client")] clock: Mutex, - #[cfg(feature = "tokio")] + #[cfg(feature = "client")] clock_changed: tokio::sync::Notify, } impl Producer { /// Start a lease on `grant`, returning both handles. pub fn new(grant: Grant) -> (Self, Consumer) { - #[cfg(feature = "tokio")] + #[cfg(feature = "client")] let clock = Mutex::new(Clock::new(&grant)); let state = kio::Shared::new(State { grant, @@ -166,9 +167,9 @@ impl Producer { ( Self { state: state.clone(), - #[cfg(feature = "tokio")] + #[cfg(feature = "client")] clock, - #[cfg(feature = "tokio")] + #[cfg(feature = "client")] clock_changed: tokio::sync::Notify::new(), }, Consumer::new(state), @@ -181,7 +182,7 @@ impl Producer { if state.closed.is_some() { return; } - #[cfg(feature = "tokio")] + #[cfg(feature = "client")] { let mut clock = self.clock.lock().expect("lease clock"); let revision = clock.revision.wrapping_add(1); @@ -191,14 +192,14 @@ impl Producer { state.grant = grant; state.epoch += 1; drop(state); - #[cfg(feature = "tokio")] + #[cfg(feature = "client")] self.clock_changed.notify_waiters(); } /// Wait until the grant needs a re-check or expires. A session may request an /// earlier re-check through [`Consumer::revalidate`]. - #[cfg(feature = "tokio")] - pub async fn due(&self) -> Due { + #[cfg(feature = "client")] + pub(crate) async fn due(&self) -> Due { loop { let changed = self.clock_changed.notified(); tokio::pin!(changed); @@ -243,8 +244,8 @@ impl Producer { } /// Keep the current grant after a failed re-check and schedule a jittered retry. - #[cfg(feature = "tokio")] - pub fn failed(&self) -> Duration { + #[cfg(feature = "client")] + pub(crate) fn failed(&self) -> Duration { use rand::RngExt; const BACKOFF_MAX: Duration = Duration::from_secs(60); let mut clock = self.clock.lock().expect("lease clock"); @@ -587,7 +588,7 @@ mod tests { assert_eq!(poll(producer.revalidate_requested()), Poll::Ready(())); } - #[cfg(feature = "tokio")] + #[cfg(feature = "client")] #[tokio::test] async fn due_follows_cadence_nudges_and_backoff() { tokio::time::pause(); @@ -612,7 +613,7 @@ mod tests { assert!(tokio::time::timeout(Duration::ZERO, producer.due()).await.is_err()); } - #[cfg(feature = "tokio")] + #[cfg(feature = "client")] #[tokio::test] async fn due_reschedules_an_outstanding_wait_after_update() { tokio::time::pause(); @@ -632,7 +633,7 @@ mod tests { assert_eq!(waiter.await.unwrap(), Due::Revalidate); } - #[cfg(feature = "tokio")] + #[cfg(feature = "client")] #[tokio::test] async fn backoff_grows_and_stays_bounded() { tokio::time::pause(); @@ -649,7 +650,7 @@ mod tests { assert!(later <= Duration::from_secs(30).mul_f64(1.25)); } - #[cfg(feature = "tokio")] + #[cfg(feature = "client")] #[tokio::test] async fn an_unschedulable_cadence_never_rechecks() { tokio::time::pause(); @@ -660,7 +661,7 @@ mod tests { assert!(tokio::time::timeout(Duration::ZERO, producer.due()).await.is_err()); } - #[cfg(feature = "tokio")] + #[cfg(feature = "client")] #[tokio::test] async fn due_expires_even_without_a_recheck() { tokio::time::pause(); diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 5aaf0f8fe79..cf56950b9b2 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -1196,16 +1196,15 @@ pub struct moq_client_config { /// The length in bytes of `tls_key`. pub tls_key_len: usize, - /// Reconnect pacing. Each must leave a non-zero delay or retrying would - /// spin, which is rejected at dial. + /// The first reconnect delay in microseconds; zero retries at once. pub backoff_initial_us: u64, /// Whether `backoff_initial_us` is present. pub has_backoff_initial: bool, - /// The factor by which the reconnect delay increases. + /// The factor by which the reconnect delay increases; 0 or 1 keeps it constant. pub backoff_multiplier: u32, /// Whether `backoff_multiplier` is present. pub has_backoff_multiplier: bool, - /// The maximum reconnect delay in microseconds. + /// The maximum reconnect delay in microseconds; zero leaves it uncapped. pub backoff_max_us: u64, /// Whether `backoff_max_us` is present. pub has_backoff_max: bool, @@ -1291,16 +1290,14 @@ pub extern "C" fn moq_client_defaults() -> moq_client_config { dst.websocket_delay_us = micros(websocket.delay); dst.has_websocket_delay = true; - dst.backoff_initial_us = micros(config.connect.backoff.initial.unwrap()); + dst.backoff_initial_us = micros(config.connect.backoff.initial()); dst.has_backoff_initial = true; dst.backoff_multiplier = config.connect.backoff.multiplier; dst.has_backoff_multiplier = true; - dst.backoff_max_us = micros(config.connect.backoff.max.unwrap()); + dst.backoff_max_us = micros(config.connect.backoff.max()); dst.has_backoff_max = true; - if let Some(timeout) = config.connect.backoff.timeout { - dst.backoff_timeout_us = micros(timeout); - dst.has_backoff_timeout = true; - } + dst.backoff_timeout_us = micros(config.connect.backoff.timeout()); + dst.has_backoff_timeout = true; let quic = config.quic.resolve(); dst.quic_max_streams = quic.max_streams; @@ -1544,7 +1541,9 @@ unsafe fn parse_server(config: &moq_server_config) -> Result) -> Result) -> Result) -> Result) -> Result, /// Reads live connection stats, reporting `None` while reconnecting. monitor: moq_tokio::connection::Monitor, }, @@ -74,7 +75,6 @@ enum Link { struct Accepted { session: moq_net::Session, - driver: ffi::Background, } /// Everything needed to prepare a session without holding the global state lock. @@ -142,7 +142,7 @@ impl Session { let reconnect = client.connect(url); let monitor = reconnect.monitor(); let link = Link::Dialed { - connection: reconnect.clone(), + connection: Box::new(reconnect.clone()), monitor: monitor.clone(), }; @@ -243,8 +243,9 @@ impl Session { accepted.session.clone().close().await?; } else { accepted.session.abort(moq_net::Error::Cancel); + let _ = accepted.session.closed().await; } - accepted.driver.finished().await + Ok(()) }) .await { @@ -260,19 +261,11 @@ impl Session { /// Establish an accepted session, publish it to the entry, and wait for it to close. async fn serve(id: Id, callback: ffi::OnState, request: moq_tokio::server::Request) -> Result<(), Error> { - let (session, driver) = request.ok().await.map_err(map_connect_error)?; - let driver = ffi::Background::new(async move { - match driver.await { - Ok(result) => result.map_err(map_connect_error), - Err(error) if error.is_panic() => Err(Error::Panic), - Err(_) => Err(moq_net::Error::Cancel.into()), - } - }); + let session = request.ok().await.map_err(map_connect_error)?; if let Some(entry) = State::lock().session.task.get_mut(id) { entry.link = Link::Accepted(Some(Accepted { session: session.clone(), - driver: driver.clone(), })); } @@ -283,7 +276,6 @@ impl Session { failure: None, version: Some(session.version()), }); - driver.finished().await?; Err(session.closed().await.into()) } diff --git a/rs/moq-c/src/test.rs b/rs/moq-c/src/test.rs index bd55cdacc5b..c1598b53c8f 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -5155,7 +5155,7 @@ fn a_zeroed_config_is_the_defaults() { assert!(parsed.connect.tls.root.is_empty()); assert!(parsed.quic.gso.is_none()); assert!(parsed.quic.mtu_discovery.is_none()); - assert!(!parsed.quic.resolve().keylog); + assert!(parsed.quic.resolve().keylog.is_none()); } /// `moq_client_defaults` has to agree with what a zeroed struct actually dials, @@ -5180,19 +5180,16 @@ fn defaults_report_what_a_zeroed_config_dials() { assert!(config.has_backoff_initial); assert_eq!( config.backoff_initial_us, - expected.connect.backoff.initial.unwrap().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!(config.has_backoff_max); - assert_eq!( - config.backoff_max_us, - expected.connect.backoff.max.unwrap().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!( - expected.connect.backoff.timeout, - Some(std::time::Duration::from_micros(config.backoff_timeout_us)) + expected.connect.backoff.timeout(), + std::time::Duration::from_micros(config.backoff_timeout_us) ); let websocket = expected.connect.websocket.resolve(); @@ -5220,7 +5217,9 @@ 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.backoff.max(), expected.connect.backoff.max()); + assert_eq!(reparsed.connect.backoff.timeout(), expected.connect.backoff.timeout()); assert_eq!( reparsed.connect.websocket.resolve().enabled, expected.connect.websocket.resolve().enabled @@ -5238,13 +5237,22 @@ 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, Some(std::time::Duration::ZERO)); - assert_eq!(explicit.quic.keep_alive, Some(std::time::Duration::ZERO)); + assert_eq!( + explicit.connect.backoff.timeout, + Some((std::time::Duration::ZERO).into()) + ); + assert_eq!(explicit.quic.keep_alive, Some((std::time::Duration::ZERO).into())); // Without the flags the same zeroes mean nothing at all. let defaults = parsed(&client_config()); - assert_ne!(defaults.connect.backoff.timeout, Some(std::time::Duration::ZERO)); - assert_eq!(defaults.quic.keep_alive, Some(std::time::Duration::from_secs(3))); + assert_ne!( + defaults.connect.backoff.timeout, + Some((std::time::Duration::ZERO).into()) + ); + assert_eq!( + defaults.quic.resolve().keep_alive, + Some(std::time::Duration::from_secs(3)) + ); } #[test] @@ -5372,19 +5380,25 @@ fn config_quic_and_backoff_knobs_apply() { let parsed = parsed(&config); assert_eq!( parsed.connect.backoff.initial, - Some(std::time::Duration::from_micros(500_000)) + Some(std::time::Duration::from_micros(500_000).into()) ); assert_eq!(parsed.connect.backoff.multiplier, 3); assert_eq!( parsed.connect.backoff.max, - Some(std::time::Duration::from_micros(10_000_000)) + Some(std::time::Duration::from_micros(10_000_000).into()) ); assert_eq!(parsed.quic.max_streams, Some(4096)); - assert_eq!(parsed.quic.idle_timeout, Some(std::time::Duration::from_millis(15_000))); + assert_eq!( + parsed.quic.idle_timeout, + Some(std::time::Duration::from_millis(15_000).into()) + ); assert_eq!(parsed.quic.gso, Some(false)); assert_eq!(parsed.quic.mtu_discovery, Some(true)); assert_eq!(parsed.quic.qlog.as_deref(), Some(std::path::Path::new(dir))); - assert_eq!(parsed.quic.keylog, Some(true)); + assert_eq!( + parsed.quic.keylog, + Some(std::env::var_os("SSLKEYLOGFILE").unwrap_or_default().into()) + ); } /// Whether a qlog directory works at all is a compile-time feature, so the capability diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index 46763680dbf..a9bce29b152 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -226,14 +226,13 @@ impl Serving { request = request.with_subscriber(subscribe); } let serving = self.shutdown.serve(); - let (session, driver) = self + let session = self .shutdown .until_deadline(request.ok()) .await .ok_or_else(|| anyhow::anyhow!("the SETUP reply was still pending at the drain deadline"))??; - let result = moq_relay::supervise(session, lease, serving, None).await; - driver.await??; - result + + moq_relay::supervise(session, lease, serving, None).await } } @@ -1178,7 +1177,7 @@ mod tests { let consumer = origin.consume(); let mut client = moq_tokio::connect::Config::default(); // Only the listener's deadline may end the undecided admission. - client.timeout = Some(Duration::ZERO); + client.timeout = Some(Duration::ZERO.into()); let client = client .init(Default::default()) .unwrap() diff --git a/rs/moq-cli/tests/integration/epochs.rs b/rs/moq-cli/tests/integration/epochs.rs index 5d1bd45504a..e55907363ba 100644 --- a/rs/moq-cli/tests/integration/epochs.rs +++ b/rs/moq-cli/tests/integration/epochs.rs @@ -34,7 +34,7 @@ impl Drop for Relay { async fn relay() -> Relay { let _ = moq_tokio::crypto::install_default(); let mut config = moq_relay::Config::default(); - config.drain_timeout = Duration::ZERO; + config.drain_timeout = Some(Duration::ZERO.into()); config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); config.listen.tls.generate = vec!["localhost".into()]; config.listen.version = std::iter::once(LITE_07.parse().unwrap()) @@ -52,10 +52,9 @@ async fn relay() -> Relay { fn client(version: &str) -> moq_tokio::Client { let mut config = moq_tokio::connect::Config::default(); config.tls.insecure = Some(true); - config.once = Some(true); config.bind = Some("127.0.0.1:0".parse().unwrap()); config.version = vec![version.parse().unwrap()]; - config.init(Default::default()).unwrap() + config.init(Default::default()).unwrap().with_reconnect(false) } fn write(track: &track::Producer, tag: &str) { diff --git a/rs/moq-cli/tests/integration/export.rs b/rs/moq-cli/tests/integration/export.rs index 48406d71a19..2da6e2d0426 100644 --- a/rs/moq-cli/tests/integration/export.rs +++ b/rs/moq-cli/tests/integration/export.rs @@ -90,7 +90,7 @@ struct Relay { async fn relay() -> Relay { let _ = moq_tokio::crypto::install_default(); let mut config = moq_relay::Config::default(); - config.drain_timeout = Duration::ZERO; + config.drain_timeout = Some(Duration::ZERO.into()); config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); config.listen.tls.generate = vec!["localhost".into()]; config.listen.version = std::iter::once(LITE_07.parse().unwrap()) diff --git a/rs/moq-clock/Cargo.toml b/rs/moq-clock/Cargo.toml index aea73c9d624..38372232687 100644 --- a/rs/moq-clock/Cargo.toml +++ b/rs/moq-clock/Cargo.toml @@ -26,7 +26,7 @@ default = ["runtime"] serde = ["dep:serde", "dep:serde_with"] # The deterministic single-threaded executor and `#[moq_clock::test]`. Meant for # dev-dependencies, so production builds never compile it. -sim = ["dep:futures", "dep:moq-clock-macros", "kio/sim"] +sim = ["dep:futures", "dep:moq-clock-macros", "kio/_sim"] runtime = ["dep:tokio", "dep:web-async"] # `Manual::install`: a clock that only a test moves, as one tokio runtime's ambient # clock. Native only. Meant for test builds, so production builds never compile it. diff --git a/rs/moq-ffi/src/auth.rs b/rs/moq-ffi/src/auth.rs index 3120239b778..a39917dabb1 100644 --- a/rs/moq-ffi/src/auth.rs +++ b/rs/moq-ffi/src/auth.rs @@ -204,7 +204,7 @@ mod tests { // Nothing redials: the client stays disconnected once the session drops. drop(listener); let mut requests = request.requests(); - let (session, driver) = request.ok().await.unwrap(); + let session = request.ok().await.unwrap(); let mut issued = Vec::new(); for _ in 0..2 { let request = requests.next().await.expect("token"); @@ -218,7 +218,7 @@ mod tests { })); } let _ = drop_rx.await; - drop((session, driver, issued, requests)); + drop((session, issued, requests)); }); let client = MoqClient::new(MoqClientConfig { diff --git a/rs/moq-ffi/src/server.rs b/rs/moq-ffi/src/server.rs index 827595b56b2..8ce5b173042 100644 --- a/rs/moq-ffi/src/server.rs +++ b/rs/moq-ffi/src/server.rs @@ -495,7 +495,7 @@ impl MoqRequest { publish.as_ref().or(state.publish.as_ref()), consume.as_ref().or(state.consume.as_ref()), ); - let (session, driver) = request + let session = request .with_publisher(&publish.inner) .with_subscriber(subscribe.inner.clone()) .ok() @@ -503,7 +503,7 @@ impl MoqRequest { .map_err(|err| MoqError::Connect { reason: format!("{err}"), })?; - Ok(Arc::new(MoqSession::accepted(session, publish, subscribe, driver))) + Ok(Arc::new(MoqSession::accepted(session, publish, subscribe))) }) }) .await diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index 9e8c4f514c1..88f99bbeee8 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -165,13 +165,20 @@ impl From for moq_tokio::quic::Config { fn from(config: MoqQuicConfig) -> Self { let mut quic = Self::default(); quic.max_streams = config.max_streams; - quic.idle_timeout = config.idle_timeout_us.map(std::time::Duration::from_micros); - quic.keep_alive = config.keep_alive_us.map(std::time::Duration::from_micros); + quic.idle_timeout = config + .idle_timeout_us + .map(|us| std::time::Duration::from_micros(us).into()); + quic.keep_alive = config + .keep_alive_us + .map(|us| std::time::Duration::from_micros(us).into()); quic.gso = config.gso; quic.mtu_discovery = config.mtu_discovery; quic.congestion_control = config.congestion_control.map(Into::into); quic.qlog = config.qlog.map(Into::into); - quic.keylog = config.keylog; + quic.keylog = config + .keylog + .filter(|enabled| *enabled) + .map(|_| std::env::var_os("SSLKEYLOGFILE").unwrap_or_default().into()); quic } } @@ -193,17 +200,18 @@ pub struct MoqWebSocketConfig { /// /// The delay starts at `initial_us`, multiplies by `multiplier` after each failed attempt, /// and caps at `max_us`. After `timeout_us` of consecutive failures the connection gives up -/// for good; the window resets whenever a session stays up past `initial_us`. Each null -/// field uses moq-tokio's default: 1s, x2, 5s, and a 10s window. +/// for good; the window resets whenever a session stays up past 1s. Each null field uses +/// moq-tokio's default: 1s, x2, 5s, and a 10s window. Zero means off: no wait, no growth, +/// no cap, or no give-up. #[derive(Clone, Debug, Default, PartialEq, Eq, uniffi::Record)] pub struct MoqBackoff { - /// Delay before the first reconnect attempt, in microseconds. + /// Delay before the first reconnect attempt, in microseconds. 0 retries at once. #[uniffi(default = None)] pub initial_us: Option, /// Multiplier applied to the delay after each failure. #[uniffi(default = None)] pub multiplier: Option, - /// Maximum delay between reconnect attempts, in microseconds. + /// Maximum delay between reconnect attempts, in microseconds. 0 leaves it uncapped. #[uniffi(default = None)] pub max_us: Option, /// Time spent retrying before giving up, in microseconds. 0 retries forever. @@ -249,10 +257,10 @@ pub fn moq_client_defaults() -> MoqClientConfig { delay_us: Some(micros(websocket.delay)), }, backoff: MoqBackoff { - initial_us: backoff.initial.map(micros), + initial_us: Some(micros(backoff.initial())), multiplier: Some(backoff.multiplier), - max_us: backoff.max.map(micros), - timeout_us: backoff.timeout.map(micros), + max_us: Some(micros(backoff.max())), + timeout_us: Some(micros(backoff.timeout())), }, ..Default::default() } @@ -312,9 +320,11 @@ impl Client { connect.bind = Some(bind); } connect.version = parse_versions(&config.versions)?; - connect.timeout = config.timeout_us.map(std::time::Duration::from_micros); - connect.race = config.race_us.map(std::time::Duration::from_micros); - connect.resolution_delay = config.resolution_delay_us.map(std::time::Duration::from_micros); + connect.timeout = config.timeout_us.map(|us| std::time::Duration::from_micros(us).into()); + connect.race = config.race_us.map(|us| std::time::Duration::from_micros(us).into()); + connect.resolution_delay = config + .resolution_delay_us + .map(|us| std::time::Duration::from_micros(us).into()); let tls = config.tls; connect.tls.insecure = Some(tls.insecure); @@ -327,32 +337,33 @@ impl Client { connect.websocket.enabled = config.websocket.enabled; if let Some(delay) = config.websocket.delay_us { - connect.websocket.delay = Some(std::time::Duration::from_micros(delay)); + connect.websocket.delay = Some((std::time::Duration::from_micros(delay)).into()); } - connect.once = Some(config.once); - let backoff = config.backoff; if let Some(initial) = backoff.initial_us { - connect.backoff.initial = Some(std::time::Duration::from_micros(initial)); + connect.backoff.initial = Some((std::time::Duration::from_micros(initial)).into()); } if let Some(multiplier) = backoff.multiplier { connect.backoff.multiplier = multiplier; } if let Some(max) = backoff.max_us { - connect.backoff.max = Some(std::time::Duration::from_micros(max)); + connect.backoff.max = Some((std::time::Duration::from_micros(max)).into()); } if let Some(timeout) = backoff.timeout_us { - connect.backoff.timeout = Some(std::time::Duration::from_micros(timeout)); + connect.backoff.timeout = Some((std::time::Duration::from_micros(timeout)).into()); } let quic = config.quic.into(); // Building the endpoint here, not at connect, is what surfaces an unreadable // certificate or a half-configured mTLS identity from `new`. - let client = connect.init(quic).map_err(|err| MoqError::Config { - reason: format!("{err}"), - })?; + let client = connect + .init(quic) + .map_err(|err| MoqError::Config { + reason: format!("{err}"), + })? + .with_reconnect(!config.once); Ok(Self { client, @@ -860,7 +871,6 @@ enum Inner { #[derive(Clone)] struct Accepted { session: moq_net::Session, - driver: crate::ffi::Background, } impl std::ops::Deref for Accepted { @@ -891,17 +901,11 @@ impl Accepted { } async fn closed(&self) -> Result { - self.driver.check()?; - #[cfg(not(target_arch = "wasm32"))] - self.driver.finished().await?; Err(self.session.closed().await.into()) } async fn close(self) -> Result<(), MoqError> { - self.driver.check()?; let result = self.session.close().await; - #[cfg(not(target_arch = "wasm32"))] - self.driver.finished().await?; result.map_err(Into::into) } } @@ -962,16 +966,8 @@ impl MoqSession { session: moq_net::Session, publish: crate::origin::Origin, subscribe: crate::origin::Origin, - driver: tokio::task::JoinHandle>, ) -> Self { - let driver = crate::ffi::Background::new(async move { - match driver.await { - Ok(result) => result, - Err(error) if error.is_cancelled() => Err(moq_net::Error::Cancel.into()), - Err(_) => Err(moq_tokio::Error::Panic), - } - }); - Self::build(Inner::Session(Accepted { session, driver }), publish, subscribe) + Self::build(Inner::Session(Accepted { session }), publish, subscribe) } fn mint_allocator(inner: &Inner) -> moq_net::bandwidth::Allocator { diff --git a/rs/moq-ffi/src/session/dial.rs b/rs/moq-ffi/src/session/dial.rs index 4b734eb8d90..541684b31a4 100644 --- a/rs/moq-ffi/src/session/dial.rs +++ b/rs/moq-ffi/src/session/dial.rs @@ -244,7 +244,6 @@ async fn run( }; published.session = Some(Accepted { session: session.clone(), - driver: crate::ffi::Background::default(), }); drop(published); diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 0c3b41e0e7f..37771e60f7d 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -5730,7 +5730,10 @@ async fn client_defaults_are_what_an_unset_config_dials_with() { assert_eq!(quic.idle_timeout, unset.idle_timeout); assert_eq!(quic.keep_alive, unset.keep_alive); assert_eq!(defaults.websocket.enabled, Some(true)); - assert!(defaults.backoff.initial_us.is_some() && defaults.backoff.max_us.is_some()); + let backoff = moq_tokio::connection::Backoff::default(); + assert_eq!(defaults.backoff.initial_us, micros(backoff.initial())); + assert_eq!(defaults.backoff.max_us, micros(backoff.max())); + assert_eq!(defaults.backoff.timeout_us, micros(backoff.timeout())); // The backend picks these, so there is no single value to report. assert_eq!(defaults.tls.system_roots, None); @@ -7386,15 +7389,6 @@ async fn data_track_names_cannot_collide() { /// In-memory sessions whose protocol and origin drivers use the paused test clock. async fn memory_shutdown_pair() -> (Arc, Arc) { - async fn drive(driver: impl moq_net::time::Driver) -> moq_tokio::Result<()> { - match moq_net::time::run(driver).await { - moq_net::Error::Closed - | moq_net::Error::Cancel - | moq_net::Error::Session(moq_net::SessionError::Cancel) => Ok(()), - error => Err(error.into()), - } - } - fn origin() -> crate::origin::Origin { let mut config = moq_net::origin::Config::default(); config.pool = moq_net::cache::Pool::unbounded(); @@ -7419,8 +7413,8 @@ async fn memory_shutdown_pair() -> (Arc, Arc) { .connect(moq_clock::Instant::now(), client) .await .unwrap(); - let driver = tokio::spawn(drive(driver)); - Arc::new(MoqSession::accepted(session, a.clone(), a, driver)) + tokio::spawn(moq_net::time::run(driver)); + Arc::new(MoqSession::accepted(session, a.clone(), a)) }; let accept = async { let (session, driver) = moq_net::Server::new() @@ -7429,8 +7423,8 @@ async fn memory_shutdown_pair() -> (Arc, Arc) { .accept(moq_clock::Instant::now(), server) .await .unwrap(); - let driver = tokio::spawn(drive(driver)); - Arc::new(MoqSession::accepted(session, b.clone(), b, driver)) + tokio::spawn(moq_net::time::run(driver)); + Arc::new(MoqSession::accepted(session, b.clone(), b)) }; tokio::join!(connect, accept) } @@ -7795,7 +7789,7 @@ async fn auth_tokens_join_and_leave_the_union() { let serving = tokio::spawn(async move { let mut request = listener.accept().await.expect("accept"); let mut requests = request.requests(); - let (session, driver) = request.ok().await.unwrap(); + let session = request.ok().await.unwrap(); let mut issued = Vec::new(); while let Some(request) = requests.next().await { match request.token().as_ref() { @@ -7809,7 +7803,7 @@ async fn auth_tokens_join_and_leave_the_union() { _ => request.reject(moq_net::SessionError::Unauthorized, "unknown token"), } } - drop((session, driver, issued)); + drop((session, issued)); }); let client = MoqClient::new(MoqClientConfig { diff --git a/rs/moq-gst/src/connection.rs b/rs/moq-gst/src/connection.rs index f612fbdccb8..270eb21d079 100644 --- a/rs/moq-gst/src/connection.rs +++ b/rs/moq-gst/src/connection.rs @@ -95,11 +95,13 @@ impl Settings { .map(|version| version.parse().map_err(anyhow::Error::msg)) .collect::>()?; connect.tls.insecure = Some(self.tls_disable_verify); - connect.backoff.timeout = Some(Duration::ZERO); + connect.backoff.timeout = Some(Duration::ZERO.into()); let mut quic = moq_tokio::quic::Config::default(); - quic.idle_timeout = self.quic_idle_timeout; - quic.keep_alive = self.quic_keep_alive; - quic.keylog = Some(self.quic_keylog); + quic.idle_timeout = self.quic_idle_timeout.map(Into::into); + quic.keep_alive = self.quic_keep_alive.map(Into::into); + quic.keylog = self + .quic_keylog + .then(|| std::env::var_os("SSLKEYLOGFILE").unwrap_or_default().into()); Ok((connect, quic)) } } @@ -152,11 +154,11 @@ mod tests { ["moq-transport-14".parse().unwrap(), "moq-lite-06".parse().unwrap()] ); assert_eq!(connect.tls.insecure, Some(true)); - assert_eq!(connect.backoff.timeout, Some(Duration::ZERO)); + assert_eq!(connect.backoff.timeout, Some(Duration::ZERO.into())); let quic = quic.resolve(); assert_eq!(quic.idle_timeout, Duration::from_secs(15)); assert_eq!(quic.keep_alive, None); - assert!(quic.keylog); + assert!(quic.keylog.is_some()); settings.set("connect-version", &"moq-lite-99".to_value()); assert!(settings.resolve().unwrap_err().to_string().contains("moq-lite-99")); } diff --git a/rs/moq-gst/src/sink/imp.rs b/rs/moq-gst/src/sink/imp.rs index aea53b3e806..e295cfb5fc3 100644 --- a/rs/moq-gst/src/sink/imp.rs +++ b/rs/moq-gst/src/sink/imp.rs @@ -2198,7 +2198,7 @@ mod tests { assert_eq!(quic.idle_timeout, Duration::from_secs(15)); assert_eq!(quic.keep_alive, Some(Duration::from_secs(3))); assert!(!sink.property::("quic-keylog")); - assert!(!quic.keylog); + assert!(quic.keylog.is_none()); sink.set_property("quic-keep-alive", 0u64); sink.set_property("quic-idle-timeout", 0u64); @@ -2207,7 +2207,7 @@ mod tests { let quic = super::super::session::quic_config(&resolved).resolve(); assert_eq!(quic.idle_timeout, Duration::ZERO); assert_eq!(quic.keep_alive, None); - assert!(quic.keylog); + assert!(quic.keylog.is_some()); } /// The sink retries without a budget, so an outage of any length is ridden out @@ -2220,7 +2220,11 @@ mod tests { sink.set_property("broadcast", "test"); let resolved = ResolvedSettings::try_from(sink.imp().settings.lock().unwrap().clone()).unwrap(); let config = super::super::session::connect_config(&resolved); - assert_eq!(config.backoff.timeout, Some(Duration::ZERO), "zero is unlimited"); + assert_eq!( + config.backoff.timeout, + Some((Duration::ZERO).into()), + "zero is unlimited" + ); } #[test] diff --git a/rs/moq-gst/src/source/imp.rs b/rs/moq-gst/src/source/imp.rs index 8e8a03a6da3..629b8629749 100644 --- a/rs/moq-gst/src/source/imp.rs +++ b/rs/moq-gst/src/source/imp.rs @@ -2995,7 +2995,11 @@ mod session_tests { #[test] fn the_source_retries_without_a_budget() { let config = super::connect_config(&unreachable()); - assert_eq!(config.backoff.timeout, Some(Duration::ZERO), "zero is unlimited"); + assert_eq!( + config.backoff.timeout, + Some((Duration::ZERO).into()), + "zero is unlimited" + ); } /// `quic-keylog` reaches the client's QUIC config, which refuses it without `SSLKEYLOGFILE`. @@ -3004,8 +3008,12 @@ mod session_tests { if !test_env::Env::without(&["SSLKEYLOGFILE"]).isolate() { return; } - let mut keylog = unreachable(); - keylog.quic.keylog = Some(true); + let mut connection = crate::connection::Settings::default(); + connection.set("quic-keylog", &true.to_value()); + let keylog = ResolvedSettings { + quic: connection.resolve().unwrap().1, + ..unreachable() + }; let err = super::connect(&keylog) .err() .expect("a keylog without SSLKEYLOGFILE must be refused"); @@ -4509,7 +4517,7 @@ mod session_tests { let admit = *admitting.lock().unwrap(); match admit { Admit::Serve => { - if let Ok((session, _driver)) = request.with_publisher(&serving).with_subscriber(serving.clone()).ok().await { + if let Ok(session) = request.with_publisher(&serving).with_subscriber(serving.clone()).ok().await { sessions.push(session); } } diff --git a/rs/moq-hls/src/server/routes.rs b/rs/moq-hls/src/server/routes.rs index 16697e3dd9e..a0cec98be8e 100644 --- a/rs/moq-hls/src/server/routes.rs +++ b/rs/moq-hls/src/server/routes.rs @@ -726,12 +726,11 @@ mod tests { let pub_for_accept = pub_origin.clone(); let accept = tokio::spawn(async move { let request = server.accept().await.expect("accept"); - let (session, driver) = request + let session = request .with_publisher(&pub_for_accept) .ok() .await .expect("publisher session"); - let _ = driver.await; let _ = session.closed().await; }); diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 806b1358ef3..253d7c0aeef 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -622,6 +622,7 @@ mod tests { #[derive(Default)] struct FakeSessionState { + panic_on_poll: std::sync::atomic::AtomicBool, protocol: Option<&'static str>, control_stream: Mutex>, close_events: kio::Shared>, @@ -645,6 +646,7 @@ mod tests { data: VecDeque::from(server_control_bytes), }); let state = FakeSessionState { + panic_on_poll: Default::default(), protocol, control_stream: Mutex::new(Some((send, recv))), close_events: kio::Shared::default(), @@ -763,6 +765,13 @@ mod tests { } fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll { + assert!( + !self + .state + .panic_on_poll + .swap(false, std::sync::atomic::Ordering::Relaxed), + "injected driver poll panic" + ); let _ = std::task::ready!(self.state.close_events.poll(self.park.hold(cx), any_close)); Poll::Ready(FakeError) } @@ -1106,14 +1115,46 @@ mod tests { // rather than parking forever on a machine nobody polls. #[test] fn dropped_driver_resolves_closed() { + for polled in [false, true] { + let fake = FakeSession::new(Some(ALPN_LITE_04), Vec::new()); + let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); + let runtime = crate::time::Deadlines::new(crate::time::Instant::now()); + let (session, mut driver) = futures::executor::block_on(client.connect(runtime.now(), fake)).unwrap(); + if polled { + assert!(driver.poll(runtime.now(), &kio::Waiter::noop()).is_ok()); + } + drop(driver); + assert!(matches!(futures::executor::block_on(session.closed()), Error::Cancel)); + } + } + + #[test] + fn driver_panic_is_published_before_the_executor_drops_it() { let fake = FakeSession::new(Some(ALPN_LITE_04), Vec::new()); let client = Client::new().with_versions(Version::Lite(lite::Version::Lite04).into()); - let runtime = crate::time::Deadlines::new(crate::time::Instant::now()); - let (session, driver) = futures::executor::block_on(client.connect(runtime.now(), fake.clone())).unwrap(); - - drop(driver); - assert!(matches!(futures::executor::block_on(session.closed()), Error::Cancel)); + let (session, mut driver) = futures::executor::block_on(client.connect(runtime.now(), fake.clone())).unwrap(); + fake.state + .panic_on_poll + .store(true, std::sync::atomic::Ordering::Relaxed); + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = driver.poll(runtime.now(), &kio::Waiter::noop()); + })); + assert!(panic.is_err(), "the owner must still receive the panic"); + assert!(matches!( + futures::FutureExt::now_or_never(session.closed()), + Some(Error::Transport(message)) if message == "session driver panicked" + )); + // Even a caller that catches the panic cannot resume the damaged session. + assert!(matches!( + driver.poll(runtime.now(), &kio::Waiter::noop()), + Err(Error::Transport(_)) + )); + drop(driver); // outside unwinding, as an executor's catch_unwind drops a failed future + assert!(matches!( + futures::executor::block_on(session.closed()), + Error::Transport(_) + )); } // `stats()` reads the machine's latest sample and primes the sampler, so a diff --git a/rs/moq-net/src/driver.rs b/rs/moq-net/src/driver.rs index c70a43d1f25..890c547f02f 100644 --- a/rs/moq-net/src/driver.rs +++ b/rs/moq-net/src/driver.rs @@ -17,12 +17,30 @@ use crate::time::{Deadlines, Instant}; /// closure on the next poll. Dropping the driver cancels the session, and /// [`crate::Session::closed`] resolves with [`Error::Cancel`]. It is `Send` on /// native, like the [`Boxable`](crate::transport::poll::Boxable) transport it drives. +/// A panic during polling is published as a transport failure before unwinding to the caller. #[must_use = "the session makes no progress unless its driver is polled"] pub struct Driver { state: State, clock: Deadlines, } +// An executor may catch the panic and only drop the future after unwinding ended. Publish at +// the actual poll boundary, while the failure is still distinguishable from cancellation. +struct Polling<'a>(&'a mut State); + +impl Drop for Polling<'_> { + fn drop(&mut self) { + if !std::thread::panicking() { + return; + } + let error = Error::Transport("session driver panicked".into()); + self.0.result = Some(Err(error.clone())); + if let Some(mut supervisor) = self.0.supervisor.take() { + supervisor.panicked(error); + } + } +} + /// The protocol half of a machine, one variant per negotiated wire protocol. pub(crate) enum Protocol { /// Boxed for size only. @@ -57,9 +75,10 @@ impl Driver { /// /// Panics if `now` is earlier than the previous poll or construction time. pub fn poll(&mut self, now: Instant, waiter: &kio::Waiter) -> Result, Error> { + let state = Polling(&mut self.state); waiter.batch(|| { self.clock.advance(now); - match self.state.poll(waiter) { + match state.0.poll(waiter) { Poll::Ready(Ok(())) => Err(Error::Closed), Poll::Ready(Err(err)) => Err(err), Poll::Pending => Ok(self.clock.park(waiter)), diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 5be0d444768..efa3c0b7cb6 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -312,7 +312,8 @@ impl Session { /// A close carrying no application code is otherwise [`Error::IdleTimeout`] for an /// idle timeout and [`Error::Transport`] for anything else. See /// [`Error::from_transport`]. If the runtime drops the driver instead of running it to - /// completion, this resolves with [`Error::Cancel`]. + /// completion, this resolves with [`Error::Cancel`]. A panic while polling the driver is + /// a transport failure instead. pub async fn closed(&self) -> Error { match self .closed @@ -540,6 +541,20 @@ enum SamplerMode { } impl Supervisor { + pub(crate) fn panicked(&mut self, error: Error) { + self.session + .closing() + .local + .store(true, std::sync::atomic::Ordering::Relaxed); + if let Ok(mut closed) = self.closed.write() { + *closed = Some(Ended { + err: error.clone(), + drain: Some(Err(error.clone())), + }); + } + let code = self.version.session_code(&SessionError::Internal); + self.session.abort(&error, code, "session driver panicked"); + } const POLL_INTERVAL: Duration = Duration::from_millis(100); pub(crate) fn poll(&mut self, waiter: &kio::Waiter) -> Poll<()> { diff --git a/rs/moq-quic/Cargo.toml b/rs/moq-quic/Cargo.toml index 7b6d905274e..2953158bf3b 100644 --- a/rs/moq-quic/Cargo.toml +++ b/rs/moq-quic/Cargo.toml @@ -12,36 +12,18 @@ keywords = ["quic"] categories = ["network-programming", "asynchronous"] [package.metadata.docs.rs] -# all non-default features except fips (cannot build on docs.rs environment) -features = ["rustls-aws-lc-rs", "rustls-ring", "platform-verifier", "rustls-log"] +features = ["rustls-aws-lc-rs", "ring"] [package.metadata.cargo-shear] # Enables the `web` feature on wasm32-unknown-unknown; never named in code. ignored = ["rustls-pki-types"] [features] -# quinn's own defaults turn on `platform-verifier`, MoQ's clients use it, and -# the rustdoc links to it resolve only with it on. -default = ["rustls-ring", "tracing-log", "bloom", "platform-verifier"] -aws-lc-rs = ["dep:aws-lc-rs", "aws-lc-rs?/aws-lc-sys", "aws-lc-rs?/prebuilt-nasm"] -aws-lc-rs-fips = ["aws-lc-rs", "aws-lc-rs?/fips"] -# Enables BloomTokenLog, and uses it by default -bloom = ["dep:fastbloom"] -# For backwards compatibility, `rustls` forwards to `rustls-ring` -rustls = ["rustls-ring"] -# Enable rustls with the `aws-lc-rs` crypto provider -rustls-aws-lc-rs = ["dep:rustls", "rustls?/aws-lc-rs", "aws-lc-rs"] -rustls-aws-lc-rs-fips = ["rustls-aws-lc-rs", "aws-lc-rs-fips"] -# Enable rustls with the `ring` crypto provider -rustls-ring = ["dep:rustls", "rustls?/ring", "ring"] -ring = ["dep:ring"] -# Enable rustls ring provider and direct ring usage -# Provides `ClientConfig::with_platform_verifier()` convenience method -platform-verifier = ["dep:rustls-platform-verifier"] -# Configure `tracing` to log events via `log` if no `tracing` subscriber exists. -tracing-log = ["tracing/log"] -# Enable rustls logging -rustls-log = ["rustls?/logging"] +default = ["ring"] +aws-lc-rs = ["dep:aws-lc-rs", "aws-lc-rs?/aws-lc-sys", "aws-lc-rs?/prebuilt-nasm", "dep:rustls", "rustls/aws-lc-rs"] +# Enable rustls with the aws-lc-rs crypto provider. +rustls-aws-lc-rs = ["aws-lc-rs"] +ring = ["dep:ring", "dep:rustls", "rustls/ring"] # Enable qlog support qlog = ["dep:qlog"] # The sans-IO QMux connection, which runs the stream state machine over a reliable, ordered @@ -61,7 +43,7 @@ fuzz = ["dep:arbitrary", "ring"] arbitrary = { version = "1.5", features = ["derive"], optional = true } aws-lc-rs = { version = "1.9", default-features = false, optional = true } bytes = { workspace = true } -fastbloom = { version = "0.17", default-features = false, optional = true } +fastbloom = { version = "0.17", default-features = false } indexmap = { workspace = true } lru-slab = "0.1.2" # Without the runtime, which is meant for dev-dependencies; a driver brings its own. @@ -72,7 +54,6 @@ rand = { workspace = true } ring = { version = "0.17", optional = true } rustc-hash = { workspace = true } rustls = { version = "0.23.5", default-features = false, features = ["std"], optional = true } -rustls-platform-verifier = { version = "0.7", optional = true } slab = { workspace = true } thiserror = { workspace = true } tinyvec = { version = "1.1", features = ["alloc"] } diff --git a/rs/moq-quic/src/config/mod.rs b/rs/moq-quic/src/config/mod.rs index 1c585e2ea85..660965d7ebc 100644 --- a/rs/moq-quic/src/config/mod.rs +++ b/rs/moq-quic/src/config/mod.rs @@ -5,17 +5,14 @@ use std::{ sync::Arc, }; -#[cfg(any(feature = "rustls-aws-lc-rs", feature = "rustls-ring"))] +#[cfg(any(feature = "aws-lc-rs", feature = "ring"))] use rustls::client::WebPkiServerVerifier; -#[cfg(any(feature = "rustls-aws-lc-rs", feature = "rustls-ring"))] +#[cfg(any(feature = "aws-lc-rs", feature = "ring"))] use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use thiserror::Error; -#[cfg(feature = "bloom")] use crate::BloomTokenLog; -#[cfg(not(feature = "bloom"))] -use crate::NoneTokenLog; -#[cfg(any(feature = "rustls-aws-lc-rs", feature = "rustls-ring"))] +#[cfg(any(feature = "aws-lc-rs", feature = "ring"))] use crate::crypto::rustls::{QuicServerConfig, configured_provider}; use crate::{ DEFAULT_SUPPORTED_VERSIONS, Duration, MAX_CID_SIZE, RandomConnectionIdGenerator, TokenLog, @@ -416,7 +413,7 @@ impl ServerConfig { } } -#[cfg(any(feature = "rustls-aws-lc-rs", feature = "rustls-ring"))] +#[cfg(any(feature = "aws-lc-rs", feature = "ring"))] impl ServerConfig { /// Create a server config with the given certificate chain to be presented to clients /// @@ -544,12 +541,8 @@ impl ValidationTokenConfig { )] /// Set a custom [`TokenLog`] /// - /// If the `bloom` feature is enabled (which it is by default), defaults to a default - /// [`BloomTokenLog`][crate::BloomTokenLog], which is suitable for most internet applications. - /// - /// If the `bloom` feature is disabled, defaults to [`NoneTokenLog`][crate::NoneTokenLog], - /// which makes the server ignore all address validation tokens (that is, tokens originating - /// from NEW_TOKEN frames--retry tokens are not affected). + /// Defaults to [`BloomTokenLog`][crate::BloomTokenLog], suitable for internet applications. + /// Set [`NoneTokenLog`][crate::NoneTokenLog] to ignore NEW_TOKEN address validation tokens. pub fn log(&mut self, log: Arc) -> &mut Self { self.log = log; self @@ -559,8 +552,7 @@ impl ValidationTokenConfig { /// /// This refers only to tokens sent in NEW_TOKEN frames, in contrast to retry tokens. /// - /// If the `bloom` feature is enabled (which it is by default), defaults to 2. Otherwise, - /// defaults to 0. + /// Defaults to 2, including builds without default crypto features. pub fn sent(&mut self, value: u32) -> &mut Self { self.sent = value; self @@ -569,14 +561,11 @@ impl ValidationTokenConfig { impl Default for ValidationTokenConfig { fn default() -> Self { - #[cfg(feature = "bloom")] let log = Arc::new(BloomTokenLog::default()); - #[cfg(not(feature = "bloom"))] - let log = Arc::new(NoneTokenLog); Self { lifetime: Duration::from_secs(2 * 7 * 24 * 60 * 60), log, - sent: if cfg!(feature = "bloom") { 2 } else { 0 }, + sent: 2, } } } @@ -664,16 +653,8 @@ impl ClientConfig { } } -#[cfg(any(feature = "rustls-aws-lc-rs", feature = "rustls-ring"))] +#[cfg(any(feature = "aws-lc-rs", feature = "ring"))] impl ClientConfig { - /// Create a client configuration that trusts the platform's native roots - #[cfg(feature = "platform-verifier")] - pub fn try_with_platform_verifier() -> Result { - Ok(Self::new(Arc::new( - crypto::rustls::QuicClientConfig::with_platform_verifier()?, - ))) - } - /// Create a client configuration that trusts specified trust anchors pub fn with_root_certificates( roots: Arc, diff --git a/rs/moq-quic/src/connection/mod.rs b/rs/moq-quic/src/connection/mod.rs index c98a22a44b3..ab71d973943 100644 --- a/rs/moq-quic/src/connection/mod.rs +++ b/rs/moq-quic/src/connection/mod.rs @@ -4567,7 +4567,7 @@ fn negotiate_max_idle_timeout(x: Option, y: Option) -> Option, initial: Suite, } impl QuicClientConfig { - #[cfg(feature = "platform-verifier")] - pub(crate) fn with_platform_verifier() -> Result { - // Keep in sync with `inner()` below - let mut inner = rustls::ClientConfig::builder_with_provider(configured_provider()) - .with_protocol_versions(&[&rustls::version::TLS13]) - .unwrap() // The default providers support TLS 1.3 - .with_platform_verifier()? - .with_no_client_auth(); - - inner.enable_early_data = true; - Ok(Self { - // We're confident that the *ring* default provider contains TLS13_AES_128_GCM_SHA256 - initial: initial_suite_from_provider(inner.crypto_provider()) - .expect("no initial cipher suite found"), - inner: Arc::new(inner), - }) - } - /// Initialize a sane QUIC-compatible TLS client configuration /// /// QUIC requires that TLS 1.3 be enabled. Advanced users can use any [`rustls::ClientConfig`] that @@ -593,9 +571,9 @@ pub(crate) fn initial_suite_from_provider( } pub(crate) fn configured_provider() -> Arc { - #[cfg(all(feature = "rustls-aws-lc-rs", not(feature = "rustls-ring")))] + #[cfg(all(feature = "aws-lc-rs", not(feature = "ring")))] let provider = rustls::crypto::aws_lc_rs::default_provider(); - #[cfg(feature = "rustls-ring")] + #[cfg(feature = "ring")] let provider = rustls::crypto::ring::default_provider(); Arc::new(provider) } diff --git a/rs/moq-quic/src/entropy.rs b/rs/moq-quic/src/entropy.rs index fa91e741d5d..708ec4ac7e4 100644 --- a/rs/moq-quic/src/entropy.rs +++ b/rs/moq-quic/src/entropy.rs @@ -21,7 +21,7 @@ pub(crate) fn std_rng() -> rand::rngs::StdRng { pub(crate) use seeded::{rng, std_rng}; // Only the crate's `tests` module reseeds, and it needs a rustls provider. -#[cfg(all(test, any(feature = "rustls-aws-lc-rs", feature = "rustls-ring")))] +#[cfg(all(test, any(feature = "aws-lc-rs", feature = "ring")))] pub(crate) use seeded::reseed; #[cfg(test)] diff --git a/rs/moq-quic/src/lib.rs b/rs/moq-quic/src/lib.rs index 66be2b40cc9..c5b9779f036 100644 --- a/rs/moq-quic/src/lib.rs +++ b/rs/moq-quic/src/lib.rs @@ -2,7 +2,7 @@ //! //! moq-quic contains a fully deterministic implementation of QUIC protocol logic. It contains //! no networking code and does not get any relevant timestamps from the operating system. Most -//! users may want to use the futures-based quinn API instead. +//! users can use the Tokio driver in `moq_tokio::quic::driver` instead. //! //! The moq-quic API might be of interest if you want to use it from a C or C++ project //! through C bindings or if you want to use a different event loop than the one tokio provides. @@ -37,16 +37,14 @@ mod entropy; #[doc(hidden)] pub mod fuzz; mod range_set; -#[cfg(all(test, any(feature = "rustls-aws-lc-rs", feature = "rustls-ring")))] +#[cfg(all(test, any(feature = "aws-lc-rs", feature = "ring")))] mod tests; pub mod transport_parameters; mod varint; pub use varint::{VarInt, VarIntBoundsExceeded}; -#[cfg(feature = "bloom")] mod bloom_token_log; -#[cfg(feature = "bloom")] pub use bloom_token_log::BloomTokenLog; mod connection; @@ -62,7 +60,7 @@ pub use connection::mux; #[cfg(feature = "qlog")] pub use connection::qlog::QlogStream; -#[cfg(feature = "rustls")] +#[cfg(any(feature = "ring", feature = "aws-lc-rs"))] pub use rustls; mod config; diff --git a/rs/moq-quic/src/packet.rs b/rs/moq-quic/src/packet.rs index 81c814184ce..ffe9eee931a 100644 --- a/rs/moq-quic/src/packet.rs +++ b/rs/moq-quic/src/packet.rs @@ -959,14 +959,14 @@ mod tests { } } - #[cfg(any(feature = "rustls-aws-lc-rs", feature = "rustls-ring"))] + #[cfg(any(feature = "aws-lc-rs", feature = "ring"))] #[test] fn header_encoding() { use crate::Side; use crate::crypto::rustls::{initial_keys, initial_suite_from_provider}; - #[cfg(all(feature = "rustls-aws-lc-rs", not(feature = "rustls-ring")))] + #[cfg(all(feature = "aws-lc-rs", not(feature = "ring")))] use rustls::crypto::aws_lc_rs::default_provider; - #[cfg(feature = "rustls-ring")] + #[cfg(feature = "ring")] use rustls::crypto::ring::default_provider; use rustls::quic::Version; diff --git a/rs/moq-quic/src/tests/mod.rs b/rs/moq-quic/src/tests/mod.rs index b9cc83c1d49..2ca3d81968f 100644 --- a/rs/moq-quic/src/tests/mod.rs +++ b/rs/moq-quic/src/tests/mod.rs @@ -18,9 +18,9 @@ use hex_literal::hex; use rand::Rng; #[cfg(feature = "ring")] use ring::hmac; -#[cfg(all(feature = "rustls-aws-lc-rs", not(feature = "rustls-ring")))] +#[cfg(all(feature = "aws-lc-rs", not(feature = "ring")))] use rustls::crypto::aws_lc_rs::default_provider; -#[cfg(feature = "rustls-ring")] +#[cfg(feature = "ring")] use rustls::crypto::ring::default_provider; use rustls::{ AlertDescription, RootCertStore, diff --git a/rs/moq-quic/src/tests/util.rs b/rs/moq-quic/src/tests/util.rs index 2672e74e74c..f0882f5f6d0 100644 --- a/rs/moq-quic/src/tests/util.rs +++ b/rs/moq-quic/src/tests/util.rs @@ -812,12 +812,7 @@ impl Write for SharedBuffer { pub(super) fn server_config() -> ServerConfig { let mut config = ServerConfig::with_crypto(Arc::new(server_crypto())); config.transport = Arc::new(TransportConfig::default()); - if !cfg!(feature = "bloom") { - config - .validation_token - .sent(2) - .log(Arc::new(SimpleTokenLog::default())); - } + config } diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index d3760e95044..c725568d4dd 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -68,7 +68,6 @@ http-body = "1.0.1" # reqwest-middleware 0.4, while this line matches reqwest 0.13 below. http-cache-reqwest = { version = "1.0.0-alpha.6", features = ["manager-moka", "reqwest-middleware", "url-standard"], default-features = false } humantime = { workspace = true } -humantime-serde = { workspace = true } hyper-util = { workspace = true } moq-auth = { workspace = true, features = ["client"] } moq-clock = { workspace = true, features = ["serde"] } @@ -127,7 +126,7 @@ tracing-subscriber = { workspace = true } [target.'cfg(target_os = "linux")'.dev-dependencies] # Workers built inside a runtime with a manual clock installed run on that clock. -moq-uring = { workspace = true, features = ["manual"] } +moq-uring = { workspace = true, features = ["_manual"] } # Which sockets the process still holds, so the embed tests can prove a stopped # relay closed its own without rebinding a port another test could take. socket2 = { workspace = true, features = ["all"] } diff --git a/rs/moq-relay/benches/auth.rs b/rs/moq-relay/benches/auth.rs index 300bedb67c8..d0a404be3ae 100644 --- a/rs/moq-relay/benches/auth.rs +++ b/rs/moq-relay/benches/auth.rs @@ -69,9 +69,8 @@ impl Fixture { } }); let mut config = moq_tokio::connect::Config::default(); - config.once = Some(true); config.version = vec!["moq-transport-22".parse().unwrap()]; - let client = config.init(Default::default()).unwrap(); + let client = config.init(Default::default()).unwrap().with_reconnect(false); let url: url::Url = format!("tcp://127.0.0.1:{port}/").parse().unwrap(); let mut connections = Vec::new(); let mut retained = Vec::new(); @@ -181,9 +180,8 @@ impl Queue { } }); let mut config = moq_tokio::connect::Config::default(); - config.once = Some(true); config.version = vec!["moq-transport-22".parse().unwrap()]; - let client = config.init(Default::default()).unwrap(); + let client = config.init(Default::default()).unwrap().with_reconnect(false); let url: url::Url = format!("tcp://127.0.0.1:{port}/").parse().unwrap(); let (connection, ()) = tokio::join!(async { client.connect(url).established().await.unwrap() }, async { let admission = admissions.next().await.unwrap(); diff --git a/rs/moq-relay/src/acme.rs b/rs/moq-relay/src/acme.rs index 86287c216a2..1f73f407e5d 100644 --- a/rs/moq-relay/src/acme.rs +++ b/rs/moq-relay/src/acme.rs @@ -63,13 +63,9 @@ pub struct Config { pub directory: Option, /// How long the first issuance may take before startup fails, e.g. "5m". Defaults to 5 minutes. - #[usage(skip)] - #[serde(with = "humantime_serde")] - pub timeout: Option, - #[usage(long = "acme-timeout", env = "MOQ_ACME_TIMEOUT", setting = "acme.timeout")] - #[serde(default, rename = "__cli_timeout", skip_serializing_if = "Option::is_none")] - timeout_arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, #[usage(long = "acme-dir", env = "MOQ_ACME_DIR", hide = true)] #[serde(skip)] legacy_dir: Option, @@ -80,7 +76,7 @@ pub struct Config { impl Config { fn timeout(&self) -> Option { - self.timeout_arg.map(Into::into).or(self.timeout) + self.timeout.map(Into::into) } pub(crate) fn deprecated(&self) -> moq_tokio::cli::Deprecated { diff --git a/rs/moq-relay/src/acme/tests.rs b/rs/moq-relay/src/acme/tests.rs index c04b9f5221e..5a5e73a4194 100644 --- a/rs/moq-relay/src/acme/tests.rs +++ b/rs/moq-relay/src/acme/tests.rs @@ -362,7 +362,7 @@ async fn certificate(State(state): State>, Route(n): Route) - /// A relay that takes its certificate from `directory` for `domains`, keeping state in `dir`. fn relay_config(directory: &Url, dir: &Path, domains: &[&str]) -> RelayConfig { let mut config = RelayConfig::default(); - config.drain_timeout = Duration::ZERO; + config.drain_timeout = Some((Duration::ZERO).into()); config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); config.web.https.listen = Some("127.0.0.1:0".parse().unwrap()); config.auth.public = vec![moq_auth::Pattern::all()]; @@ -441,9 +441,8 @@ impl Running { async fn quic_serves(&self, leaf: &CertificateDer<'static>) { let mut config = moq_tokio::connect::Config::default(); config.tls.fingerprint = vec![fingerprint(leaf)]; - config.once = Some(true); config.bind = Some("127.0.0.1:0".parse().unwrap()); - let client = config.init(Default::default()).unwrap(); + let client = config.init(Default::default()).unwrap().with_reconnect(false); let url: Url = format!("moql://127.0.0.1:{}/", self.quic.port()).parse().unwrap(); let _session = tokio::time::timeout(TIMEOUT, client.with_reconnect(false).connect(url).established()) .await @@ -553,7 +552,7 @@ async fn unreachable_directory_fails_inside_the_budget() { let dir = tempfile::tempdir().unwrap(); let mut config = relay_config(&url, dir.path(), &[DOMAIN]); let budget = Duration::from_secs(2); - config.acme.timeout = Some(budget); + config.acme.timeout = Some((budget).into()); let relay = Relay::load(config).await.unwrap().with_signals(false); let https = relay.web_addrs().https.unwrap(); @@ -583,7 +582,7 @@ async fn a_signal_during_issuance_stops_startup() { let url: Url = format!("http://{closed}/directory").parse().unwrap(); let dir = tempfile::tempdir().unwrap(); let mut config = relay_config(&url, dir.path(), &[DOMAIN]); - config.acme.timeout = Some(Duration::from_secs(60 * 60)); + config.acme.timeout = Some((Duration::from_secs(60 * 60)).into()); let relay = Relay::load(config).await.unwrap(); let ready = relay.ready(); @@ -609,7 +608,7 @@ async fn failed_validations_stop_short_of_the_rate_limit() { let dir = tempfile::tempdir().unwrap(); let start = || async { let mut config = relay_config(&directory.url, dir.path(), &[DOMAIN]); - config.acme.timeout = Some(TIMEOUT); + config.acme.timeout = Some((TIMEOUT).into()); let relay = Relay::load(config).await.unwrap().with_signals(false); let err = tokio::time::timeout(TIMEOUT, relay.run()) .await diff --git a/rs/moq-relay/src/cache.rs b/rs/moq-relay/src/cache.rs index c16d1eb2b3a..deab5256d8f 100644 --- a/rs/moq-relay/src/cache.rs +++ b/rs/moq-relay/src/cache.rs @@ -61,13 +61,9 @@ 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 = "humantime_serde")] - pub duration: Option, - #[usage(long = "cache-duration", env = "MOQ_CACHE_DURATION", setting = "cache.duration")] - #[serde(default, rename = "__cli_duration", skip_serializing_if = "Option::is_none")] - duration_arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration: Option, } /// The relay's resolved cache settings: the shared byte-budget pool plus the @@ -105,7 +101,7 @@ 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_arg.map(Into::into).or(self.duration); + let duration = self.duration.map(Into::into); let config = cache::Config::default() .with_capacity(capacity) .with_expiry(duration.unwrap_or(cache::DEFAULT_EXPIRY)); @@ -218,7 +214,7 @@ mod tests { fn explicit_duration_configures_both_bounds() { let duration = Duration::from_secs(5); let config = Config { - duration: Some(duration), + duration: Some((duration).into()), ..Default::default() }; let cache = config.init().unwrap(); diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index f0de1bbc878..58e3f6d69e3 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -2045,10 +2045,10 @@ impl Cluster { // through backoff and eventually gives up instead of migrating forever. // Downstream sessions never see a GOAWAY of their own. // - // Forced on regardless of `--client-reconnect`: that flag is about the - // relay's own upstream dial, and a cluster peer link that stopped following - // GOAWAY would break rolling handoff, redialing the drained URL after the - // old session finally closed instead of migrating to the replacement. + // Forced on, whatever the template client says: a cluster peer link that + // stopped following GOAWAY would break rolling handoff, redialing the drained + // URL after the old session finally closed instead of migrating to the + // replacement. // // A GOAWAY naming no URI is a relay draining to restart. A peer URL names // that one node, so a redial would only reach the draining process again @@ -2086,8 +2086,7 @@ impl Cluster { let mut bind = connect.resolve().bind; bind.set_port(0); connect.bind = Some(bind); - connect.backoff.timeout = Some(std::time::Duration::ZERO); - connect.once = Some(false); + connect.backoff.timeout = Some((std::time::Duration::ZERO).into()); connect.version = connect .versions() .iter() @@ -3033,8 +3032,10 @@ mod tests { #[tokio::test] async fn constructed_origin_keeps_cache_and_handles() { let duration = Duration::from_secs(5); - let mut cache = crate::cache::Config::default(); - cache.duration = Some(duration); + let cache = crate::cache::Config { + duration: Some(duration.into()), + ..Default::default() + }; let cache = cache.init().expect("cache"); let pool = cache.pool.clone(); @@ -3504,8 +3505,7 @@ mod tests { } }); - let mut connect = moq_tokio::connect::Config::default(); - connect.once = Some(true); + let connect = moq_tokio::connect::Config::default(); let fingerprint = fingerprint .with_connect(connect, Default::default()) .with_advertise(LanAdvertise::new(port).with_fingerprint(fp.clone())); @@ -3650,7 +3650,7 @@ mod tests { ..Default::default() }; let mut quic = moq_tokio::quic::Config::default(); - quic.idle_timeout = Some(IDLE); + quic.idle_timeout = Some((IDLE).into()); let cluster = new_cluster(config).expect("cluster").with_connect(connect, quic); let started = cluster.clone().start().await.expect("start"); let running = tokio::spawn(started.run()); @@ -3691,7 +3691,7 @@ mod tests { ..Default::default() }; let mut quic = moq_tokio::quic::Config::default(); - quic.idle_timeout = Some(IDLE); + quic.idle_timeout = Some((IDLE).into()); let runtime = tokio::runtime::Builder::new_multi_thread() .worker_threads(1) .enable_all() @@ -3769,7 +3769,7 @@ mod tests { const IDLE: std::time::Duration = std::time::Duration::from_secs(300); let _ = moq_tokio::crypto::install_default(); let mut quic = moq_tokio::quic::Config::default(); - quic.idle_timeout = Some(IDLE); + quic.idle_timeout = Some((IDLE).into()); let node = new_cluster(Config::default()).expect("node cluster"); let mut listen = moq_tokio::listen::Config::default(); diff --git a/rs/moq-relay/src/config.rs b/rs/moq-relay/src/config.rs index 76e92579f8c..e39bec088fb 100644 --- a/rs/moq-relay/src/config.rs +++ b/rs/moq-relay/src/config.rs @@ -20,6 +20,7 @@ use crate::{auth, cache, cluster, internal, stats, web}; #[usage(unknown_flags = "error", args_override_self = false)] #[serde(deny_unknown_fields, default)] #[non_exhaustive] +#[derive(Default)] pub struct Config { /// Request and transport limits grouped by client and cluster-peer role. #[usage(flatten)] @@ -118,18 +119,14 @@ pub struct Config { /// them, exiting as soon as they have all left; 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 = "humantime_serde")] - pub drain_timeout: std::time::Duration, - #[usage( name = "drain-timeout", long = "drain-timeout", env = "MOQ_DRAIN_TIMEOUT", setting = "drain_timeout" )] - #[serde(default, rename = "__cli_drain_timeout", skip_serializing_if = "Option::is_none")] - drain_timeout_arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub drain_timeout: Option, /// If provided, load the configuration from this file. #[serde(default)] @@ -148,38 +145,6 @@ pub struct Config { pub iroh: moq_tokio::iroh::Config, } -impl Default for Config { - fn default() -> Self { - Self { - limits: Default::default(), - legacy_session: None, - legacy_peer: None, - listen: Default::default(), - server: None, - connect: Default::default(), - client: None, - quic: Default::default(), - log: Default::default(), - runtime: Default::default(), - cluster: Default::default(), - auth: Default::default(), - web: Default::default(), - acme: Default::default(), - #[cfg(feature = "stun")] - stun: Default::default(), - stats: Default::default(), - cache: Default::default(), - internal: Default::default(), - drain_timeout: crate::DEFAULT_DRAIN_TIMEOUT, - drain_timeout_arg: None, - file: None, - origins: None, - #[cfg(feature = "iroh")] - iroh: Default::default(), - } - } -} - /// Top-level relay configuration, loadable from CLI arguments, environment /// variables, or a TOML file. // @@ -216,7 +181,9 @@ pub fn spec() -> &'static usage_rs::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(Into::into).unwrap_or(self.drain_timeout) + self.drain_timeout + .map(Into::into) + .unwrap_or(crate::DEFAULT_DRAIN_TIMEOUT) } /// Parses configuration from CLI arguments, optionally merging with a @@ -404,8 +371,6 @@ impl Config { self.quic .max_streams .get_or_insert(moq_tokio::quic::DEFAULT_MAX_STREAMS); - self.drain_timeout = self.drain_timeout(); - self.drain_timeout_arg = None; Ok(()) } } @@ -1064,7 +1029,7 @@ ws = false #[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))); + assert_eq!(set.duration, Some(std::time::Duration::from_secs(30).into())); let unset: cache::Config = toml::from_str("").expect("deserialize absent"); assert_eq!(unset.duration, None); @@ -1338,7 +1303,7 @@ ws = false toml: "[cache]\nduration = \"30s\"\n", cli: &[], key: "cache.duration", - check: |config| assert_eq!(config.cache.duration, Some(std::time::Duration::from_secs(30))), + check: |config| assert_eq!(config.cache.duration, Some(std::time::Duration::from_secs(30).into())), source: "duration", }, Case { @@ -1346,7 +1311,7 @@ ws = false toml: "[connect]\ntimeout = \"2m\"\n", cli: &[], key: "connect.timeout", - check: |config| assert_eq!(config.connect.timeout, Some(std::time::Duration::from_secs(120))), + check: |config| assert_eq!(config.connect.timeout, Some(std::time::Duration::from_secs(120).into())), source: "timeout", }, Case { @@ -1626,8 +1591,8 @@ ws = false } } - /// A QUIC-LB nonce with no server id stops startup from TOML, and `lb_id` no - /// longer hides `load_balancer`. + /// A QUIC-LB nonce with no server id stops startup from TOML, and CLI + /// assignments override the same nested fields. /// /// The CLI flag pair already requires `--listen-quic-lb-id`, so this does not /// add a second command-line error for that spelling. @@ -1643,33 +1608,32 @@ ws = false refuse_quic_lb( "nonce.toml", - "[listen]\nlb_nonce = 8\n", + "[listen.lb]\nnonce = 8\n", &[], "requires --listen-quic-lb-id", ); - refuse_quic_lb( - "both.toml", - "[listen]\nlb_id = \"ab\"\nload_balancer = { id = \"cd\", nonce = 4 }\n", - &[], - "cannot be combined", - ); - refuse_quic_lb( - "cli-id.toml", - "[listen]\nload_balancer = { id = \"cd\", nonce = 4 }\n", - &["--listen-quic-lb-id", "ee"], - "cannot be combined", - ); #[cfg(feature = "quic")] { let dir = std::env::temp_dir().join("moq-relay-lb-refusals"); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("typed.toml"); - std::fs::write(&path, "[listen]\nload_balancer = { id = \"ab\", nonce = 8 }\n").unwrap(); - let config = Config::parse_and_merge([std::ffi::OsString::from("moq-relay"), path.into()]).expect("typed"); - let load_balancer = config.listen.load_balancer().expect("typed load_balancer"); - assert_eq!(load_balancer.id, "ab".parse().unwrap()); - assert_eq!(load_balancer.nonce, 8); + std::fs::write(&path, "[listen.lb]\nid = \"ab\"\nnonce = 8\n").unwrap(); + let config = + Config::parse_and_merge([std::ffi::OsString::from("moq-relay"), path.clone().into()]).expect("typed"); + let load_balancer = config.listen.lb; + assert_eq!(load_balancer.id, Some("ab".parse().unwrap())); + assert_eq!(load_balancer.nonce, Some(8)); + let config = Config::parse_and_merge([ + std::ffi::OsString::from("moq-relay"), + path.into(), + "--listen-quic-lb-id".into(), + "ee".into(), + ]) + .expect("CLI overrides the same nested config field"); + let load_balancer = config.listen.lb; + assert_eq!(load_balancer.id, Some("ee".parse().unwrap())); + assert_eq!(load_balancer.nonce, Some(8)); } } @@ -1685,21 +1649,4 @@ ws = false let err = Config::parse_and_merge(["moq-relay"]).expect_err("env nonce without id"); assert!(err.to_string().contains("--listen-quic-lb-id"), "{err}"); } - - /// A server id from the environment cannot be combined with a file's `load_balancer`. - #[test] - fn quic_lb_env_id_conflicts_with_the_file() { - if !test_env::Env::without(QUIC_LB_ENV) - .set("MOQ_LISTEN_QUIC_LB_ID", "ab") - .isolate() - { - return; - } - refuse_quic_lb( - "env-id.toml", - "[listen]\nload_balancer = { id = \"cd\", nonce = 4 }\n", - &[], - "cannot be combined", - ); - } } diff --git a/rs/moq-relay/src/incoming.rs b/rs/moq-relay/src/incoming.rs index 350e8b0f753..be4034c13e6 100644 --- a/rs/moq-relay/src/incoming.rs +++ b/rs/moq-relay/src/incoming.rs @@ -60,7 +60,7 @@ pub trait Sealed: Sized { /// The QUIC connection under the request, to widen a peer's limits before SETUP OK. #[cfg(feature = "quic")] - fn quic(&self) -> Option<&moq_tokio::quic::Connection> { + fn quic(&self) -> Option<&moq_tokio::quic::driver::Connection> { None } @@ -81,7 +81,7 @@ pub trait Sealed: Sized { impl Sealed for moq_tokio::server::Request { /// Spawned by [`Request::ok`](moq_tokio::server::Request::ok) on its own task. - type Driver = tokio::task::JoinHandle>; + type Driver = (); fn remote(&self) -> Option { self.remote_addr() @@ -109,7 +109,7 @@ impl Sealed for moq_tokio::server::Request { } #[cfg(feature = "quic")] - fn quic(&self) -> Option<&moq_tokio::quic::Connection> { + fn quic(&self) -> Option<&moq_tokio::quic::driver::Connection> { moq_tokio::server::Request::quic(self) } @@ -143,14 +143,14 @@ impl Sealed for moq_tokio::server::Request { async fn ok(self) -> anyhow::Result<(moq_net::Session, Self::Driver)> { match moq_tokio::server::Request::ok(self).await { - Ok(ok) => Ok(ok), + Ok(session) => Ok((session, ())), Err(moq_tokio::Error::MoqNet(err)) => Err(err.into()), Err(err) => Err(err.into()), } } - async fn drive(driver: Self::Driver) -> anyhow::Result<()> { - Ok(driver.await??) + async fn drive((): Self::Driver) -> anyhow::Result<()> { + Ok(()) } } diff --git a/rs/moq-relay/src/relay.rs b/rs/moq-relay/src/relay.rs index a9325a29ea3..6979b978f61 100644 --- a/rs/moq-relay/src/relay.rs +++ b/rs/moq-relay/src/relay.rs @@ -140,10 +140,6 @@ impl Relay { // Every TLS listener serves the ACME certificate, so install it before any is built. #[cfg(feature = "acme")] let acme = config.acme.init(&config.listen.tls, &config.web.https)?; - #[cfg(feature = "acme")] - if let Some(acme) = &acme { - config.listen.tls.resolver = Some(acme.resolver()); - } let resolved_config = config.clone(); let drain_timeout = config.drain_timeout(); // The name this relay reports in every auth request: the stats node label, @@ -203,7 +199,17 @@ impl Relay { // Workers and the stream server split one relay across listeners, so they share // one loaded identity. A lone server loads its own, only if a listener needs it. - let served = config.runtime.workers().map(|_| config.listen.tls.load()).transpose()?; + #[cfg(feature = "acme")] + let resolved = acme + .as_ref() + .map(|acme| moq_tokio::tls::Serve::resolver(acme.resolver(), &config.listen.tls)) + .transpose()?; + #[cfg(not(feature = "acme"))] + let resolved = None; + let served = match resolved { + Some(served) => Some(served), + None => config.runtime.workers().map(|_| config.listen.tls.load()).transpose()?, + }; let mut listen = config.listen.clone(); if served.is_some() { listen.tls = Default::default(); diff --git a/rs/moq-relay/src/runtime.rs b/rs/moq-relay/src/runtime.rs index 113f85d02a0..92cf1ae4224 100644 --- a/rs/moq-relay/src/runtime.rs +++ b/rs/moq-relay/src/runtime.rs @@ -59,7 +59,9 @@ impl Config { /// to switch the mode off from a config file that already has the section. pub fn workers(&self) -> Option { let count = self.workers.filter(|count| *count > 0)?; - Some(moq_tokio::worker::Config::new(count).with_pin(self.pin)) + let mut config = moq_tokio::worker::Config::new(count); + config.pin = self.pin; + Some(config) } /// Whether the workers should run on io_uring instead of tokio. diff --git a/rs/moq-relay/src/shutdown.rs b/rs/moq-relay/src/shutdown.rs index 8ac3919aaba..8e25d9c9968 100644 --- a/rs/moq-relay/src/shutdown.rs +++ b/rs/moq-relay/src/shutdown.rs @@ -442,13 +442,13 @@ mod tests { async fn withheld_websocket_setup_ends_at_the_deadline() { let window = Duration::from_secs(1); let mut config = crate::Config::default(); - config.listen.timeout = Some(Duration::ZERO); + config.listen.timeout = Some((Duration::ZERO).into()); config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); config.listen.tls.generate = vec!["localhost".into()]; config.web.ws = true; config.web.http.listen = Some("127.0.0.1:0".parse().unwrap()); config.auth.public = vec![moq_auth::Pattern::all()]; - config.drain_timeout = window; + config.drain_timeout = Some((window).into()); let relay = crate::Relay::load(config).await.unwrap().with_signals(false); let addr = relay.web_addrs().http.unwrap(); let trigger = relay.shutdown_trigger().clone(); @@ -513,7 +513,7 @@ mod tests { let window = Duration::from_secs(1); let mut config = crate::Config::default(); config.listen.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); - config.drain_timeout = window; + config.drain_timeout = Some((window).into()); let mut relay = crate::Relay::load(config).await.unwrap().with_signals(false); let url: url::Url = format!("tcp://{}/room", relay.tcp_addr().unwrap()).parse().unwrap(); let mut admissions = relay.admissions().unwrap(); diff --git a/rs/moq-relay/src/stats.rs b/rs/moq-relay/src/stats.rs index 4e1b0e9c54e..4a18c34e296 100644 --- a/rs/moq-relay/src/stats.rs +++ b/rs/moq-relay/src/stats.rs @@ -47,14 +47,9 @@ pub struct Config { /// How often a snapshot is published, e.g. "1s" or "500ms". Defaults to 1 /// second. - #[usage(skip)] - #[serde(with = "humantime_serde")] - pub interval: Option, - - /// How often stats snapshots are published. Defaults to 1s. #[usage(long = "stats-interval", env = "MOQ_STATS_INTERVAL", setting = "stats.interval")] - #[serde(default, rename = "__cli_interval", skip_serializing_if = "Option::is_none")] - pub(crate) interval_arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interval: Option, /// Node identifier appended to the advertised stats path to disambiguate /// broadcasts when multiple relays share a cluster origin. Without this, @@ -87,14 +82,9 @@ pub struct Config { /// churn doesn't unannounce and re-announce it across the mesh; one that /// returns after it announces under a new epoch. Only applies at `depth` 1 /// or more. See [`moq_stats::produce::Config::linger`]. - #[usage(skip)] - #[serde(with = "humantime_serde")] - pub linger: Option, - - /// How long an empty group's stats broadcast stays announced. Defaults to 5m. #[usage(long = "stats-linger", env = "MOQ_STATS_LINGER", setting = "stats.linger")] - #[serde(default, rename = "__cli_linger", skip_serializing_if = "Option::is_none")] - pub(crate) linger_arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub linger: Option, } impl Default for Config { @@ -103,24 +93,22 @@ impl Default for Config { enabled: false, prefix: ".stats".into(), interval: None, - interval_arg: None, node: None, depth: 0, linger: None, - linger_arg: None, } } } impl Config { - /// The interval after command-line overrides; `None` keeps the producer's default. + /// The publish interval; `None` keeps the producer's default. pub(crate) fn interval(&self) -> Option { - self.interval_arg.map(Into::into).or(self.interval) + self.interval.map(Into::into) } /// The linger after command-line overrides; `None` keeps the producer's default. pub(crate) fn linger(&self) -> Option { - self.linger_arg.map(Into::into).or(self.linger) + self.linger.map(Into::into) } /// Build a [`moq_stats::Producer`] from this config, publishing on `origin`. diff --git a/rs/moq-relay/src/test_support.rs b/rs/moq-relay/src/test_support.rs index 419f5a6f9de..d2eca1daeae 100644 --- a/rs/moq-relay/src/test_support.rs +++ b/rs/moq-relay/src/test_support.rs @@ -23,7 +23,7 @@ pub struct TestRelay { /// It also accepts the work-in-progress moq-lite-07, for a client that opts in. pub async fn test_relay() -> anyhow::Result { let mut config = Config::default(); - config.drain_timeout = std::time::Duration::ZERO; + config.drain_timeout = Some(std::time::Duration::ZERO.into()); config.listen.bind = Some("127.0.0.1:0".parse()?); config.listen.version = std::iter::once("moq-lite-07-wip".parse().map_err(anyhow::Error::msg)?) .chain(moq_net::Versions::all().iter().copied()) diff --git a/rs/moq-relay/src/uring.rs b/rs/moq-relay/src/uring.rs index 28bed824784..f6c5dd151dc 100644 --- a/rs/moq-relay/src/uring.rs +++ b/rs/moq-relay/src/uring.rs @@ -146,7 +146,6 @@ impl Workers { && listen.tls.generate.is_empty() && listen.tls.root.is_empty() && listen.tls.identity.is_none() - && listen.tls.resolver.is_none() && listen.tls.peers.is_none(), "a shared TLS policy cannot be combined with listen.tls settings" ); @@ -160,10 +159,10 @@ impl Workers { // beats starting and quietly behaving differently from what the // operator configured, which is the whole failure mode this mode is // most likely to produce. - if listen.load_balancer().is_some() { + if listen.lb.id.is_some() { anyhow::bail!( "io_uring workers issue shard-steered connection ids and cannot also carry a \ - QUIC-LB server id (listen.lb_id)" + QUIC-LB server id (listen.lb.id)" ); } // One resolution for the whole group, so a DNS answer that rotates @@ -212,9 +211,9 @@ impl Workers { .collect(); let quic = quic.resolve(); let mut tls = tls.config(alpn)?; - if quic.keylog { + if let Some(path) = &quic.keylog { let tls = Arc::make_mut(&mut tls); - tls.key_log = Arc::new(rustls::KeyLogFile::new()); + tls.key_log = Arc::new(moq_tokio::tls::KeyLog::open(path)?); } let mut server = moq_uring::quic::server::Config::new(tls); server.transport = transport(&quic)?; @@ -1009,14 +1008,11 @@ mod tests { /// A direct `bind` validates `[quic]` too, so a key log nobody can read back fails /// instead of binding and logging nothing. #[test] - fn bind_refuses_a_keylog_without_sslkeylogfile() { - if !test_env::Env::without(&["SSLKEYLOGFILE"]).isolate() { - return; - } + fn bind_refuses_an_empty_keylog_path() { let mut server = moq_tokio::server::Config::default(); - server.quic.keylog = Some(true); - let err = Workers::bind(server, moq_tokio::worker::Config::new(1)) - .expect_err("a keylog without SSLKEYLOGFILE must be refused"); + server.quic.keylog = Some(std::path::PathBuf::new()); + let err = + Workers::bind(server, moq_tokio::worker::Config::new(1)).expect_err("an empty keylog path must be refused"); assert!( matches!(err.downcast_ref(), Some(moq_tokio::Error::KeylogUnset)), "{err:#}" @@ -1080,7 +1076,7 @@ mod tests { config.runtime.workers = Some(1); config.runtime.pin = false; config.runtime.io_uring = true; - config.drain_timeout = window; + config.drain_timeout = Some(window.into()); let mut relay = crate::Relay::load(config).await.unwrap().with_signals(false); let port = relay.addr().unwrap().port(); let mut admissions = relay.admissions().unwrap(); @@ -1089,7 +1085,7 @@ mod tests { let mut client = moq_tokio::connect::Config::default(); client.tls.insecure = Some(true); - client.once = Some(true); + client.bind = Some("127.0.0.1:0".parse().unwrap()); let url: url::Url = format!("moql://127.0.0.1:{port}/drain").parse().unwrap(); let client = client.init(Default::default()).unwrap(); @@ -1134,11 +1130,11 @@ mod tests { config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); config.listen.tls.cert = vec![cert_path]; config.listen.tls.key = vec![key_path]; - config.listen.timeout = Some(timeout); + config.listen.timeout = Some(timeout.into()); config.runtime.workers = Some(1); config.runtime.pin = false; config.runtime.io_uring = true; - config.drain_timeout = std::time::Duration::from_millis(100); + config.drain_timeout = Some(std::time::Duration::from_millis(100).into()); let mut relay = crate::Relay::load(config).await.unwrap().with_signals(false); let port = relay.addr().unwrap().port(); let mut admissions = relay.admissions().unwrap(); @@ -1147,7 +1143,7 @@ mod tests { let mut client = moq_tokio::connect::Config::default(); client.tls.insecure = Some(true); - client.once = Some(true); + client.bind = Some("127.0.0.1:0".parse().unwrap()); let url: url::Url = format!("moql://127.0.0.1:{port}/deadline").parse().unwrap(); let client = client.init(Default::default()).unwrap(); diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 8a1087f1548..56a9ff01047 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -588,11 +588,12 @@ fn build_acme_config( root: &[PathBuf], ) -> anyhow::Result> { let mut tls = moq_tokio::tls::Listen::default(); - tls.resolver = Some(resolver); tls.root = root.to_vec(); let alpn = vec![b"h2".to_vec(), b"http/1.1".to_vec(), b"acme-tls/1".to_vec()]; - tls.server_config(alpn).context("failed to build https TLS config") + moq_tokio::tls::Serve::resolver(resolver, &tls)? + .config(alpn) + .context("failed to build https TLS config") } fn https_watch_paths(cert: &[PathBuf], key: &[PathBuf], root: &[PathBuf]) -> Vec { @@ -2351,10 +2352,10 @@ mod tests { let (ca, cert, key) = make_certs(&certs); let mut config = crate::Config::default(); config.listen.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); - config.listen.timeout = Some(std::time::Duration::ZERO); + config.listen.timeout = Some((std::time::Duration::ZERO).into()); config.web.ws = true; config.auth.public = vec![moq_auth::Pattern::all()]; - config.drain_timeout = DRAIN_WINDOW; + config.drain_timeout = Some((DRAIN_WINDOW).into()); if tls { config.web.https.listen = Some("127.0.0.1:0".parse().unwrap()); config.web.https.cert = vec![cert]; diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index b555af8ba70..d5d953a91c5 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -73,7 +73,8 @@ pub(crate) async fn serve_ws( options.link.server_name = server_name.map(|Extension(ServerName(name))| name); options.identity = mtls.map(|Extension(MtlsPeer(identity))| identity); options.socket_stats = socket_stats.map(|Extension(stats)| stats.0); - options.timeout = state.timeout; + // The listener reads `None` as no deadline; `Options` reads it as the default. + options.timeout = Some(state.timeout.unwrap_or_default()); Ok(ws.on_upgrade(async move |socket| { // Axum hides its Tungstenite socket to avoid exposing its dependency version. @@ -794,7 +795,7 @@ mod tests { activity.enter().unwrap(), ); options.link.alpn = Some(alpn.clone()); - options.timeout = None; + options.timeout = Some(Duration::ZERO); let server = tokio::spawn(moq_tokio::websocket::accept( Pipe::new(server_incoming, server_to_client, frozen.clone()), options, diff --git a/rs/moq-relay/tests/drills.rs b/rs/moq-relay/tests/drills.rs index 768d7312981..6d4a5266632 100644 --- a/rs/moq-relay/tests/drills.rs +++ b/rs/moq-relay/tests/drills.rs @@ -319,8 +319,8 @@ fn quic() -> moq_tokio::quic::Config { // 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 = Some(Duration::from_secs(2)); - quic.keep_alive = Some(Duration::from_millis(250)); + quic.idle_timeout = Some((Duration::from_secs(2)).into()); + quic.keep_alive = Some((Duration::from_millis(250)).into()); quic } @@ -341,9 +341,9 @@ fn dial() -> moq_tokio::connect::Config { // Fast enough to keep a relay bounce or a link flap inside the drill's // budget, paced enough that the loop is still a backoff. - config.backoff.initial = Some(Duration::from_millis(50)); - config.backoff.max = Some(Duration::from_millis(200)); - config.backoff.timeout = Some(Duration::from_secs(5)); + config.backoff.initial = Some((Duration::from_millis(50)).into()); + config.backoff.max = Some((Duration::from_millis(200)).into()); + config.backoff.timeout = Some((Duration::from_secs(5)).into()); config } @@ -1015,7 +1015,7 @@ async fn cross_cluster(lane: Lane, link: PeerLink) { config.connect = dial(); // Retry forever: a give-up after a slow impaired handshake would look exactly // like the never-redialed regression, and a stall still fails the drill. - config.connect.backoff.timeout = Some(Duration::ZERO); + config.connect.backoff.timeout = Some((Duration::ZERO).into()); if link == PeerLink::Transport { config.connect.version = vec!["moq-transport-19".parse().expect("parse version")]; } diff --git a/rs/moq-relay/tests/integration/auth_lifetime.rs b/rs/moq-relay/tests/integration/auth_lifetime.rs index 5020bec9ba0..7a45454b1bf 100644 --- a/rs/moq-relay/tests/integration/auth_lifetime.rs +++ b/rs/moq-relay/tests/integration/auth_lifetime.rs @@ -231,10 +231,12 @@ async fn spawn_ws_relay_with( 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 = Some(Duration::ZERO); + config.websocket.delay = Some((Duration::ZERO).into()); config.bind = Some("127.0.0.1:0".parse().expect("parse bind")); - config.init(Default::default()).expect("client init") + config + .init(Default::default()) + .expect("client init") + .with_reconnect(false) } fn room_url(scheme: &str, port: u16) -> url::Url { @@ -1133,11 +1135,13 @@ async fn a_certificate_admits_only_what_the_server_grants() { let mtls_client = || { let mut config = moq_tokio::connect::Config::default(); config.tls.insecure = Some(true); - config.once = Some(true); config.bind = Some("127.0.0.1:0".parse().expect("parse bind")); config.tls.cert = Some(client_cert.clone()); config.tls.key = Some(client_key.clone()); - config.init(Default::default()).expect("client init") + config + .init(Default::default()) + .expect("client init") + .with_reconnect(false) }; // No grant for certificates: refused, over QUIC with a certificate and over // WebSocket without one. @@ -1464,7 +1468,7 @@ async fn a_relay_without_an_auth_source_is_decided_by_the_embedder() { let mut config = Config::default(); config.listen.tcp.bind = Some("127.0.0.1:0".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); + config.drain_timeout = Some((Duration::from_millis(100)).into()); config }; @@ -1505,8 +1509,8 @@ async fn connect_through_embedded_admission( let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let mut config = Config::default(); config.listen.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); - config.listen.timeout = Some(timeout); - config.drain_timeout = Duration::from_millis(100); + config.listen.timeout = Some((timeout).into()); + config.drain_timeout = Some((Duration::from_millis(100)).into()); let mut relay = Relay::load(config).await.expect("load relay").with_signals(false); let port = relay.tcp_addr().expect("TCP listener bound").port(); let admissions = relay.admissions().expect("an empty [auth] hands over the admissions"); @@ -1642,10 +1646,9 @@ async fn in_band_tokens_widen_and_withdraw_live_prefixes() { }; let client = || { let mut config = moq_tokio::connect::Config::default(); - config.once = Some(true); config.version = vec!["moq-transport-22".parse().unwrap()]; - config.websocket.delay = Some(Duration::ZERO); - config.init(Default::default()).unwrap() + config.websocket.delay = Some((Duration::ZERO).into()); + config.init(Default::default()).unwrap().with_reconnect(false) }; let origin = moq_tokio::origin::spawn(); let a = origin.create_broadcast("a/cam").unwrap(); diff --git a/rs/moq-relay/tests/integration/cluster_close.rs b/rs/moq-relay/tests/integration/cluster_close.rs index 31bdb9bb9f5..a512cd97425 100644 --- a/rs/moq-relay/tests/integration/cluster_close.rs +++ b/rs/moq-relay/tests/integration/cluster_close.rs @@ -27,7 +27,7 @@ fn dial_template() -> (moq_tokio::connect::Config, moq_tokio::quic::Config) { let mut connect = moq_tokio::connect::Config::default(); connect.tls.insecure = Some(true); let mut quic = moq_tokio::quic::Config::default(); - quic.idle_timeout = Some(BARE_IDLE); + quic.idle_timeout = Some((BARE_IDLE).into()); (connect, quic) } diff --git a/rs/moq-relay/tests/integration/cluster_drain.rs b/rs/moq-relay/tests/integration/cluster_drain.rs index 531517c7619..beeffa14cce 100644 --- a/rs/moq-relay/tests/integration/cluster_drain.rs +++ b/rs/moq-relay/tests/integration/cluster_drain.rs @@ -109,7 +109,7 @@ async fn relay(name: &str) -> (Relay, SocketAddr, moq_net::broadcast::Producer) let mut config = moq_relay::Config::default(); config.listen.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); config.auth.public = vec![moq_auth::Pattern::all()]; - config.drain_timeout = DRAIN; + config.drain_timeout = Some(DRAIN.into()); let relay = Relay::load(config).await.expect("load relay").with_signals(false); let addr = relay.tcp_addr().expect("tcp listener"); let broadcast = relay.cluster().origin.create_broadcast(name).expect("create broadcast"); diff --git a/rs/moq-relay/tests/integration/cluster_idle.rs b/rs/moq-relay/tests/integration/cluster_idle.rs index 115c2751dab..191e958d641 100644 --- a/rs/moq-relay/tests/integration/cluster_idle.rs +++ b/rs/moq-relay/tests/integration/cluster_idle.rs @@ -68,7 +68,7 @@ async fn spawn_peer() -> (u16, tokio::runtime::Runtime) { while let Some(request) = listener.accept().await { let origin = origin.clone(); tokio::spawn(async move { - if let Ok((session, _driver)) = request.with_publisher(&origin).ok().await { + if let Ok(session) = request.with_publisher(&origin).ok().await { let _ = session.closed().await; } }); @@ -105,8 +105,8 @@ async fn silent_peer_detected() { config.connect = vec![Peer::new(format!("https://127.0.0.1:{port}/"))]; // Keep-alives under the idle timeout, so only the silenced peer idles out. let mut quic = moq_tokio::quic::Config::default(); - quic.idle_timeout = Some(IDLE); - quic.keep_alive = Some(IDLE / 4); + quic.idle_timeout = Some((IDLE).into()); + quic.keep_alive = Some((IDLE / 4).into()); let cluster = cluster::Cluster::new(cluster::Options::new(config)) .expect("cluster init") .with_connect(connect, quic); diff --git a/rs/moq-relay/tests/integration/cluster_unknown.rs b/rs/moq-relay/tests/integration/cluster_unknown.rs index 4c6e148318f..267bc3471a8 100644 --- a/rs/moq-relay/tests/integration/cluster_unknown.rs +++ b/rs/moq-relay/tests/integration/cluster_unknown.rs @@ -123,7 +123,7 @@ impl Drop for Mesh { fn client(version: Option) -> moq_tokio::Client { let mut config = moq_tokio::connect::Config::default(); config.tls.insecure = Some(true); - config.websocket.delay = Some(Duration::ZERO); + config.websocket.delay = Some((Duration::ZERO).into()); 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/integration/embed.rs b/rs/moq-relay/tests/integration/embed.rs index a34400c6682..e4394add545 100644 --- a/rs/moq-relay/tests/integration/embed.rs +++ b/rs/moq-relay/tests/integration/embed.rs @@ -40,9 +40,11 @@ fn public_auth(config: &mut Config) { fn client() -> moq_tokio::Client { let mut config = moq_tokio::connect::Config::default(); config.tls.insecure = Some(true); - config.once = Some(true); config.bind = Some("127.0.0.1:0".parse().expect("parse bind")); - config.init(Default::default()).expect("client init") + config + .init(Default::default()) + .expect("client init") + .with_reconnect(false) } async fn assert_owner_stopped(quic: SocketAddr, http: SocketAddr) { @@ -151,7 +153,7 @@ 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; + config.drain_timeout = Some((Duration::ZERO).into()); let relay = Relay::load(config).await.expect("load relay").with_signals(false); let quic = relay.quic_addr().expect("quic listener bound"); let http = relay.web_addrs().http.expect("http listener bound"); @@ -429,7 +431,7 @@ async fn embedded_listener_health_reaches_metrics() { let (cert, key) = certificate(dir.path()); let mut config = http_and_quic(&cert, &key, "127.0.0.1:0".into()); config.internal.listen = Some("127.0.0.1:0".parse().unwrap()); - config.drain_timeout = Duration::ZERO; + config.drain_timeout = Some((Duration::ZERO).into()); let relay = Relay::load(config).await.expect("load relay").with_signals(false); let internal = relay.internal().addr().expect("internal listener bound"); let ready = relay.ready(); diff --git a/rs/moq-relay/tests/integration/goaway_cluster.rs b/rs/moq-relay/tests/integration/goaway_cluster.rs index a541800d1d4..cdf9b7328c7 100644 --- a/rs/moq-relay/tests/integration/goaway_cluster.rs +++ b/rs/moq-relay/tests/integration/goaway_cluster.rs @@ -158,8 +158,8 @@ async fn cluster_continues_a_group_split_by_goaway_inner() { let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); - client_config.goaway.handover = Some(HANDOVER); - client_config.backoff.initial = Some(BACKOFF); + client_config.goaway.handover = Some((HANDOVER).into()); + client_config.backoff.initial = Some((BACKOFF).into()); let mut cluster_config = cluster::Config::default(); cluster_config.connect = vec![Peer::new(format!("tcp://127.0.0.1:{port_a}/"))]; @@ -263,7 +263,7 @@ async fn spawn_upstream( while let Some(request) = server.accept().await { // Serve the shared origin bidirectionally, like a relay peer would. let scratch = moq_tokio::origin::spawn(); - let (session, _driver) = match request.with_publisher(&origin).with_subscriber(scratch).ok().await { + let session = match request.with_publisher(&origin).with_subscriber(scratch).ok().await { Ok(session) => session, Err(err) => { tracing::warn!(%err, "upstream accept failed"); @@ -297,8 +297,8 @@ async fn cluster_migrates_on_upstream_goaway_inner() { // ── the relay cluster under test, dialing sibling A ───────────── let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); - client_config.goaway.handover = Some(HANDOVER); - client_config.backoff.initial = Some(BACKOFF); + client_config.goaway.handover = Some((HANDOVER).into()); + client_config.backoff.initial = Some((BACKOFF).into()); let mut cluster_config = cluster::Config::default(); cluster_config.connect = vec![Peer::new(format!("tcp://127.0.0.1:{port_a}/"))]; @@ -421,8 +421,8 @@ async fn spawn_relay_with_upstream( let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); - client_config.goaway.handover = Some(HANDOVER); - client_config.backoff.initial = Some(BACKOFF); + client_config.goaway.handover = Some((HANDOVER).into()); + client_config.backoff.initial = Some((BACKOFF).into()); // Only lite-07 carries the epoch that lets a failover resume the subscription. client_config.version = vec![LITE_07.parse().unwrap()]; @@ -504,8 +504,8 @@ async fn cluster_diamond_goaway_seamless_failover_inner() { let mid_a_origin = moq_tokio::origin::spawn(); let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); - client_config.goaway.handover = Some(HANDOVER); - client_config.backoff.initial = Some(BACKOFF); + client_config.goaway.handover = Some((HANDOVER).into()); + client_config.backoff.initial = Some((BACKOFF).into()); client_config.version = vec![LITE_07.parse().unwrap()]; let mid_a_client = client_config.init(Default::default()).expect("mid-a client init"); let (_mid_a_upstream_client, mid_a_upstream) = within( @@ -800,11 +800,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 = Some(handover); + client_config.goaway.handover = Some((handover).into()); // 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 = Some(Duration::from_millis(50)); + client_config.backoff.initial = Some((Duration::from_millis(50)).into()); 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/integration/hidden_cluster.rs b/rs/moq-relay/tests/integration/hidden_cluster.rs index ae19fd9ff59..f9e4ef7cd24 100644 --- a/rs/moq-relay/tests/integration/hidden_cluster.rs +++ b/rs/moq-relay/tests/integration/hidden_cluster.rs @@ -57,7 +57,7 @@ async fn old_peer_keeps_hidden_paths(version: moq_net::Version) { let mut discovered = peer_origin.consume().with_hidden(true).announced(); let accept = tokio::spawn(async move { let request = server.accept().await.expect("cluster dial"); - let (session, _driver) = request.with_subscriber(peer_origin).ok().await.expect("accept"); + let session = request.with_subscriber(peer_origin).ok().await.expect("accept"); session.closed().await; }); diff --git a/rs/moq-relay/tests/integration/public_rules.rs b/rs/moq-relay/tests/integration/public_rules.rs index 7303adc4bdb..d7f88715f2e 100644 --- a/rs/moq-relay/tests/integration/public_rules.rs +++ b/rs/moq-relay/tests/integration/public_rules.rs @@ -21,10 +21,12 @@ const TIMEOUT: Duration = Duration::from_secs(10); fn client() -> moq_tokio::Client { let mut config = moq_tokio::connect::Config::default(); - config.once = Some(true); - config.websocket.delay = Some(Duration::ZERO); + config.websocket.delay = Some((Duration::ZERO).into()); config.bind = Some("127.0.0.1:0".parse().expect("parse bind")); - config.init(Default::default()).expect("client init") + config + .init(Default::default()) + .expect("client init") + .with_reconnect(false) } /// The relay's web stack with WebSocket and the HTTP routes, admitting through `auth`. diff --git a/rs/moq-relay/tests/integration/refusals.rs b/rs/moq-relay/tests/integration/refusals.rs index 8e301ee79ea..0cb68bf92a1 100644 --- a/rs/moq-relay/tests/integration/refusals.rs +++ b/rs/moq-relay/tests/integration/refusals.rs @@ -34,7 +34,7 @@ fn config(dir: &std::path::Path, ws: bool) -> Config { config.web.http.listen = Some("127.0.0.1:0".parse().expect("parse http")); config.web.ws = ws; config.internal.listen = Some("127.0.0.1:0".parse().unwrap()); - config.drain_timeout = Duration::ZERO; + config.drain_timeout = Some((Duration::ZERO).into()); config } @@ -105,9 +105,11 @@ impl Running { fn client() -> moq_tokio::Client { let mut config = moq_tokio::connect::Config::default(); config.tls.insecure = Some(true); - config.once = Some(true); config.bind = Some("127.0.0.1:0".parse().expect("parse bind")); - config.init(Default::default()).expect("client init") + config + .init(Default::default()) + .expect("client init") + .with_reconnect(false) } /// Dial `url` as a publisher or a subscriber and require the relay to refuse. diff --git a/rs/moq-relay/tests/integration/runtime_uring.rs b/rs/moq-relay/tests/integration/runtime_uring.rs index 47ed2763b70..f586b950e30 100644 --- a/rs/moq-relay/tests/integration/runtime_uring.rs +++ b/rs/moq-relay/tests/integration/runtime_uring.rs @@ -91,9 +91,11 @@ fn uring_config(cert: &std::path::Path, key: &std::path::Path) -> Config { fn client() -> moq_tokio::Client { let mut config = moq_tokio::connect::Config::default(); config.tls.insecure = Some(true); - config.once = Some(true); config.bind = Some("127.0.0.1:0".parse().expect("parse bind")); - config.init(Default::default()).expect("client init") + config + .init(Default::default()) + .expect("client init") + .with_reconnect(false) } async fn connect(client: moq_tokio::Client, url: url::Url) -> moq_tokio::Connection { @@ -211,10 +213,15 @@ async fn uring_workers_serve_every_configured_version() { let version: moq_net::Version = version.parse().expect("known version"); let mut config = moq_tokio::connect::Config::default(); config.tls.insecure = Some(true); - config.once = Some(true); config.bind = Some("127.0.0.1:0".parse().expect("parse bind")); config.version = vec![version]; - (version, config.init(Default::default()).expect("client init")) + ( + version, + config + .init(Default::default()) + .expect("client init") + .with_reconnect(false), + ) }; let dir = tempfile::tempdir().expect("tempdir"); @@ -372,7 +379,7 @@ async fn uring_workers_drain_on_the_trigger() { let (cert, key) = certificate(dir.path()); let mut config = uring_config(&cert, &key); - config.drain_timeout = DRAIN; + config.drain_timeout = Some((DRAIN).into()); config.auth.public.clear(); let mut relay = Relay::load(config).await.expect("load relay").with_signals(false); let port = relay.addr().expect("workers bound an address").port(); @@ -487,9 +494,11 @@ async fn workers_publish_their_rotated_certificate_fingerprint() { for scheme in ["moql", "https"] { let mut dial = moq_tokio::connect::Config::default(); dial.tls.fingerprint = vec![expected.clone()]; - dial.once = Some(true); dial.bind = Some("127.0.0.1:0".parse().unwrap()); - let client = dial.init(Default::default()).expect("pinned client"); + let client = dial + .init(Default::default()) + .expect("pinned client") + .with_reconnect(false); let url = format!("{scheme}://localhost:{port}/rotation").parse().unwrap(); let connection = connect(client, url).await; drop(connection); @@ -538,10 +547,9 @@ async fn workers_share_a_generated_identity() { let fingerprint = http_get(addr, "/certificate.sha256").await; let mut dial = moq_tokio::connect::Config::default(); dial.tls.fingerprint = vec![fingerprint]; - dial.once = Some(true); dial.bind = Some("127.0.0.1:0".parse().unwrap()); let url = format!("https://localhost:{port}/generated").parse().unwrap(); - let connection = connect(dial.init(Default::default()).unwrap(), url).await; + let connection = connect(dial.init(Default::default()).unwrap().with_reconnect(false), url).await; drop(connection); trigger.start(); tokio::time::timeout(TIMEOUT, running) @@ -582,11 +590,12 @@ async fn an_mtls_client_authenticates_without_a_token() { let client = || { let mut dial = moq_tokio::connect::Config::default(); dial.tls.insecure = Some(true); - dial.once = Some(true); dial.bind = Some("127.0.0.1:0".parse().expect("parse bind")); dial.tls.cert = Some(client_cert.clone()); dial.tls.key = Some(client_key.clone()); - dial.init(Default::default()).expect("client init") + dial.init(Default::default()) + .expect("client init") + .with_reconnect(false) }; // The server grants a certificate everything, so one certificate covers @@ -986,7 +995,6 @@ async fn uring_workers_offer_the_configured_extensions() { let mut dial = moq_tokio::connect::Config::default(); dial.tls.insecure = Some(true); - dial.once = Some(true); dial.bind = Some("127.0.0.1:0".parse().unwrap()); dial.version = vec!["moq-transport-22".parse().unwrap()]; let client = dial.init(Default::default()).unwrap(); @@ -1055,7 +1063,7 @@ fn uring_workers_bound_stalled_handshakes() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); let mut config = uring_config(&cert, &key); - config.listen.timeout = Some(LISTEN); + config.listen.timeout = Some(LISTEN.into()); config.quic.congestion_control = Some(moq_tokio::quic::CongestionControl::None); let relay = Relay::load(config).await.expect("load relay").with_signals(false); let port = relay.addr().expect("workers bound an address").port(); diff --git a/rs/moq-relay/tests/integration/runtime_workers.rs b/rs/moq-relay/tests/integration/runtime_workers.rs index 63f7532ed54..de40e7273f5 100644 --- a/rs/moq-relay/tests/integration/runtime_workers.rs +++ b/rs/moq-relay/tests/integration/runtime_workers.rs @@ -50,9 +50,11 @@ fn worker_config(cert: &std::path::Path, key: &std::path::Path, workers: u16) -> fn client() -> moq_tokio::Client { let mut config = moq_tokio::connect::Config::default(); config.tls.insecure = Some(true); - config.once = Some(true); config.bind = Some("127.0.0.1:0".parse().expect("parse bind")); - config.init(Default::default()).expect("client init") + config + .init(Default::default()) + .expect("client init") + .with_reconnect(false) } async fn connect(client: moq_tokio::Client, url: url::Url) -> moq_tokio::Connection { diff --git a/rs/moq-relay/tests/integration/session_revalidate.rs b/rs/moq-relay/tests/integration/session_revalidate.rs index d23b544c23a..0b1b5912ffe 100644 --- a/rs/moq-relay/tests/integration/session_revalidate.rs +++ b/rs/moq-relay/tests/integration/session_revalidate.rs @@ -239,10 +239,12 @@ impl Fixture { 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 = Some(Duration::ZERO); + config.websocket.delay = Some((Duration::ZERO).into()); config.bind = Some(bind.parse().expect("parse bind")); - config.init(Default::default()).expect("client init") + config + .init(Default::default()) + .expect("client init") + .with_reconnect(false) } async fn connect(url: url::Url, bind: &str) -> moq_tokio::Connection { diff --git a/rs/moq-relay/tests/integration/shutdown_signal.rs b/rs/moq-relay/tests/integration/shutdown_signal.rs index 80eb2409237..ee214e825cf 100644 --- a/rs/moq-relay/tests/integration/shutdown_signal.rs +++ b/rs/moq-relay/tests/integration/shutdown_signal.rs @@ -345,7 +345,7 @@ async fn a_drain_ends_once_every_session_leaves_inner() { // A window the test would time out long before, so only an early exit passes. let mut config = relay_config(); - config.drain_timeout = Duration::from_secs(600); + config.drain_timeout = Some((Duration::from_secs(600)).into()); config.internal.listen = Some("127.0.0.1:0".parse().expect("parse addr")); let relay = Relay::load(config).await.expect("load relay").with_signals(false); let port = relay.tcp_addr().expect("TCP listener bound").port(); @@ -423,9 +423,9 @@ async fn a_reconnecting_client_rides_out_a_restart_inner() { broadcast.announce(Default::default()).expect("announce"); let mut publisher = moq_tokio::connect::Config::default(); // Redials paced well inside the drain, under a window that ends long before it. - publisher.backoff.initial = Some(DRAIN_TIMEOUT / 15); - publisher.backoff.max = Some(DRAIN_TIMEOUT / 15); - publisher.backoff.timeout = Some(DRAIN_TIMEOUT / 3); + publisher.backoff.initial = Some((DRAIN_TIMEOUT / 15).into()); + publisher.backoff.max = Some((DRAIN_TIMEOUT / 15).into()); + publisher.backoff.timeout = Some((DRAIN_TIMEOUT / 3).into()); let publisher = publisher .init(Default::default()) .expect("client init") @@ -515,7 +515,7 @@ fn relay_config() -> Config { let mut config = Config::default(); config.listen.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); config.auth = auth; - config.drain_timeout = DRAIN_TIMEOUT; + config.drain_timeout = Some((DRAIN_TIMEOUT).into()); config } diff --git a/rs/moq-relay/tests/integration/smoke.rs b/rs/moq-relay/tests/integration/smoke.rs index 330ce411d8b..4dc925b4f72 100644 --- a/rs/moq-relay/tests/integration/smoke.rs +++ b/rs/moq-relay/tests/integration/smoke.rs @@ -120,16 +120,18 @@ fn client_version(version: Option) -> moq_tokio::Client { config.tls.insecure = Some(true); // One-shot: these tests were written against a single dial, and a background // 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 = Some(std::time::Duration::ZERO); + config.websocket.delay = Some((std::time::Duration::ZERO).into()); // 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")); if let Some(version) = version { config.version = vec![version]; } - config.init(Default::default()).expect("client init") + config + .init(Default::default()) + .expect("client init") + .with_reconnect(false) } /// Connect a publisher and a subscriber to a real relay over `ws://`, push diff --git a/rs/moq-relay/tests/integration/stack.rs b/rs/moq-relay/tests/integration/stack.rs index 15eb3e114c4..488168de0e7 100644 --- a/rs/moq-relay/tests/integration/stack.rs +++ b/rs/moq-relay/tests/integration/stack.rs @@ -11,7 +11,7 @@ async fn load_and_run() { config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); config.listen.tls.generate = vec!["localhost".into()]; config.auth.public = vec![moq_auth::Pattern::all()]; - config.drain_timeout = std::time::Duration::ZERO; + config.drain_timeout = Some((std::time::Duration::ZERO).into()); let relay = Relay::load(config).await.expect("load relay").with_signals(false); let ready = relay.ready(); let trigger = relay.shutdown_trigger().clone(); diff --git a/rs/moq-relay/tests/integration/wire_stats.rs b/rs/moq-relay/tests/integration/wire_stats.rs index e24b3149741..b29e9984e42 100644 --- a/rs/moq-relay/tests/integration/wire_stats.rs +++ b/rs/moq-relay/tests/integration/wire_stats.rs @@ -36,8 +36,8 @@ async fn wire_sources(io_uring: bool) { config.auth.public = vec![moq_auth::Pattern::all()]; config.stats.enabled = true; config.internal.listen = Some("127.0.0.1:0".parse().unwrap()); - config.stats.interval = Some(Duration::from_secs(3600)); - config.drain_timeout = Duration::ZERO; + config.stats.interval = Some(Duration::from_secs(3600).into()); + config.drain_timeout = Some(Duration::ZERO.into()); if io_uring { config.runtime.workers = Some(1); config.runtime.pin = false; diff --git a/rs/moq-rtc/src/server/whep.rs b/rs/moq-rtc/src/server/whep.rs index 19ec37203ff..b3584c4260b 100644 --- a/rs/moq-rtc/src/server/whep.rs +++ b/rs/moq-rtc/src/server/whep.rs @@ -256,12 +256,11 @@ mod tests { let addr = server.tcp_local_addr().expect("tcp listener bound"); let accept = tokio::spawn(async move { let request = server.accept().await.expect("accept"); - let (session, driver) = request + let session = request .with_publisher(&pub_origin) .ok() .await .expect("publisher session"); - let _ = driver.await; let _ = session.closed().await; }); diff --git a/rs/moq-tokio/Cargo.toml b/rs/moq-tokio/Cargo.toml index 35f337e89b6..80782f665ad 100644 --- a/rs/moq-tokio/Cargo.toml +++ b/rs/moq-tokio/Cargo.toml @@ -64,7 +64,7 @@ tcp = ["_transport", "dep:qmux", "dep:dns-lookup"] uds = ["tcp", "qmux/uds"] # Native WebRTC data channels over str0m, with a full ICE agent (the `webrtc` module). webrtc = ["_transport", "dep:qmux", "dep:moq-ice", "dep:str0m", "dep:if-addrs", "dep:mdns-sd"] -ring = ["_certs", "rustls/ring", "rcgen/ring", "moq-quic?/rustls-ring"] +ring = ["_certs", "rustls/ring", "rcgen/ring", "moq-quic?/ring"] [dependencies] bytes = "1" @@ -87,7 +87,7 @@ moq-congestion = { workspace = true, optional = true } moq-ice = { workspace = true, optional = true } moq-net = { workspace = true, optional = true } # The QUIC state machine `quic::driver` drives; the crypto-provider features pick its provider. -moq-quic = { workspace = true, features = ["bloom"], optional = true } +moq-quic = { workspace = true, optional = true } moq-sock = { workspace = true } # iroh runs on upstream noq rather than moq-quic, and re-exports only the # ControllerFactory trait, not the concrete congestion configs. Version matched to @@ -161,8 +161,6 @@ socket2 = { workspace = true, features = ["all"] } # A fake STUN server for the WebRTC server-reflexive candidate test. stun-types = { workspace = true } tempfile = "3" -# Runs the key log tests with `SSLKEYLOGFILE` set, in a child process. -test-env = { path = "../test-env" } # test-util adds tokio::time::pause/auto-advance for the time-dependent tests. tokio = { workspace = true, features = ["test-util"] } tokio-rustls = { workspace = true } diff --git a/rs/moq-tokio/src/cli.rs b/rs/moq-tokio/src/cli.rs index fc65af205e9..f5cd2ef8354 100644 --- a/rs/moq-tokio/src/cli.rs +++ b/rs/moq-tokio/src/cli.rs @@ -216,9 +216,6 @@ 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 089bb2fd656..bb057f41f41 100644 --- a/rs/moq-tokio/src/cli/duration.rs +++ b/rs/moq-tokio/src/cli/duration.rs @@ -1,4 +1,4 @@ -//! Human-readable command-line durations. +//! Human-readable configuration durations. use std::fmt; use std::str::FromStr; @@ -64,15 +64,18 @@ macro_rules! test_config_duration { assert_eq!(resolve(&merged(&unset)), default, "unset round-trip"); for value in [std::time::Duration::ZERO, std::time::Duration::from_secs(3)] { for mut config in [parse(&[]), merged(&parse(&[])), <$config>::default()] { - config.$field = value.into(); + config.$field = Some(value.into()); assert_eq!(resolve(&config), expected(value), "typed {value:?}"); assert_eq!(resolve(&merged(&config)), expected(value), "round-trip {value:?}"); } let flag_value = if value.is_zero() { "3s" } else { "0s" }; - let flag_duration = humantime::parse_duration(flag_value).unwrap(); for mut config in [parse(&[$flag, flag_value]), merged(&parse(&[$flag, flag_value]))] { - config.$field = value.into(); - assert_eq!(resolve(&config), expected(flag_duration), "explicit flag"); + config.$field = Some(value.into()); + assert_eq!( + resolve(&config), + expected(value), + "typed assignment replaces the parsed flag" + ); } } } diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 6d4e069eabb..1d82aa33d03 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -84,8 +84,17 @@ pub struct Client { iroh: Option, #[cfg(feature = "iroh")] iroh_addrs: Vec, + /// Replaces the transport dial in tests, so the reconnect loop can run over + /// in-memory sessions on a paused clock. + #[cfg(test)] + pub(crate) dialer: Option>, } +/// A test stand-in for [`Client::dial`]. +#[cfg(test)] +pub(crate) type Dialer = + dyn Fn(crate::connect::Addr) -> std::pin::Pin> + Send>> + Send + Sync; + impl Client { /// Build a client from its config. /// @@ -113,6 +122,7 @@ impl Client { } quic.validate()?; + config.backoff.validate()?; // Only the rustls-backed transports use this. Iroh and the plaintext // qmux transports must not require a rustls crypto provider. @@ -147,7 +157,7 @@ impl Client { versions, connect: config.url, timeout, - reconnect: !config.once.unwrap_or(false), + reconnect: true, backoff: config.backoff, goaway: config.goaway, restart: Restart::default(), @@ -170,6 +180,8 @@ impl Client { iroh: None, #[cfg(feature = "iroh")] iroh_addrs: Vec::new(), + #[cfg(test)] + dialer: None, }) } @@ -245,7 +257,7 @@ impl Client { /// Override whether this client redials after a session drop. /// - /// Defaults to true, unless [`crate::connect::Config::once`] turned it off. + /// Defaults to true. Each clone can choose its own reconnect policy. pub fn with_reconnect(mut self, reconnect: bool) -> Self { self.reconnect = reconnect; self @@ -263,7 +275,7 @@ impl Client { /// redials with exponential backoff whenever the session drops. Wait for the /// first session with [`Connection::established`] and for the loop to stop /// with [`Connection::closed`]; drop the last clone to stop it. Disable the - /// redialing with [`crate::connect::Config::once`] / [`Self::with_reconnect`] for + /// redialing with [`Self::with_reconnect`] for /// a one-shot dial. /// /// Takes a [`Url`] for the usual case of a peer at a known address. Pass @@ -378,6 +390,11 @@ impl Client { feature = "uds" ))] pub(crate) async fn dial(&self, addr: crate::connect::Addr) -> crate::Result { + #[cfg(test)] + if let Some(dialer) = &self.dialer { + return dialer(addr).await; + } + // Each compiled backend adds state to this dispatch future. Keep it off the // caller's stack so all-feature builds remain safe on standard 2 MiB threads. let attempt = Box::pin(self.connect_inner(addr)); @@ -836,13 +853,16 @@ mod tests { let request = server.accept().await.unwrap(); assert_eq!(request.path(), "/room"); assert_eq!(request.query(), Some("jwt=secret")); - let (session, _driver) = request.with_publisher(&server_origin).ok().await.unwrap(); + let session = request.with_publisher(&server_origin).ok().await.unwrap(); (server, session) }); let mut config = crate::connect::Config::default(); config.tls.insecure = Some(true); - config.once = Some(once); - let client = config.init(Default::default()).unwrap().with_publisher(&origin); + let client = config + .init(Default::default()) + .unwrap() + .with_reconnect(!once) + .with_publisher(&origin); let url: url::Url = format!("https://relay.invalid:{}/room?jwt=secret", peer.port()) .parse() .unwrap(); @@ -1022,7 +1042,6 @@ mod tests { r#"tls.fingerprint = ["abcd1234", "ef567890"]"#, r#"version = ["moq-lite-02"]"#, r#"url = "https://relay.example.com/anon""#, - "once = true", r#"bind = "127.0.0.1:1234""#, r#"tls.host_name = "example.host""#, ] { @@ -1082,14 +1101,13 @@ mod tests { ); } - /// `--client-reconnect` means the opposite of the flag that replaced it, so the - /// line has to say so: carried across unchanged it would silently invert. + /// A removed CLI policy points embedders at the per-clone API. #[test] - fn an_inverted_replacement_says_so() { + fn removed_reconnect_names_the_native_policy() { let config = Cli::config_from(["test", "--client-reconnect=false"]); let reported = config.deprecated().to_string(); - assert!(reported.contains("--connect-once"), "{reported}"); - assert!(reported.contains("inverted"), "{reported}"); + assert!(reported.contains("Client::with_reconnect(bool)"), "{reported}"); + assert!(reported.contains("native API only"), "{reported}"); } /// Building a client is the backstop: a caller that skipped its own check must @@ -1111,7 +1129,7 @@ mod tests { #[test] fn test_cli_resolution_delay() { let config = Cli::config_from(["test", "--connect-resolution-delay", "0s"]); - assert_eq!(config.resolution_delay, None); + assert_eq!(config.resolution_delay, Some(std::time::Duration::ZERO.into())); assert_eq!(config.resolve().resolution_delay, std::time::Duration::ZERO); } @@ -1133,19 +1151,17 @@ mod tests { } } - /// The released TOML said `reconnect = false`; `once` says the opposite thing. - /// - /// Still parsed, because `deny_unknown_fields` would otherwise reject the file - /// with no hint of what to write instead, and the inversion is the whole reason - /// this can't be a rename someone applies mechanically. + /// A removed TOML policy still parses so it can name the per-clone native API. #[test] - fn test_toml_reconnect_is_reported_not_inverted() { + fn test_toml_reconnect_is_reported_as_native_policy() { let config: crate::connect::Config = toml::from_str("reconnect = false").unwrap(); - assert_eq!(config.once, None); let reported = config.deprecated().to_string(); - assert!(reported.contains("reconnect -> once"), "{reported}"); - assert!(reported.contains("inverted"), "{reported}"); + assert!( + reported.contains("reconnect -> Client::with_reconnect(bool)"), + "{reported}" + ); + assert!(reported.contains("native API only"), "{reported}"); } /// An explicit canonical bind remains distinguishable from an omitted bind. @@ -1182,18 +1198,6 @@ mod tests { ); } - #[test] - fn test_cli_once_flag() { - let config = Cli::config_from(["test"]); - assert_eq!(config.once, None, "unset means the default (reconnect)"); - - let config = Cli::config_from(["test", "--connect-once"]); - assert_eq!(config.once, Some(true)); - - let config = Cli::config_from(["test", "--connect-once=false"]); - assert_eq!(config.once, Some(false)); - } - #[test] fn test_cli_no_version_defaults_to_all() { let config = Cli::config_from(["test"]); @@ -1277,7 +1281,7 @@ mod tests { let mut config = crate::connect::Config::default(); config.tls.insecure = Some(true); // No head start, so the fallback dials while QUIC waits on the 403. - config.websocket.delay = Some(std::time::Duration::ZERO); + config.websocket.delay = Some((std::time::Duration::ZERO).into()); let client = config.init(Default::default()).unwrap(); // The same race `connect_inner` runs, except the fallback dials its own port, @@ -1354,7 +1358,7 @@ mod tests { let mut client_config = crate::connect::Config::default(); client_config.tls.insecure = Some(true); // Dial both arms immediately; the fallback handshake is gated by QUIC. - client_config.websocket.delay = Some(std::time::Duration::ZERO); + client_config.websocket.delay = Some((std::time::Duration::ZERO).into()); let client = client_config.init(Default::default()).expect("failed to init client"); let quic_addr: crate::connect::Addr = Url::parse(&format!("https://localhost:{quic_port}")).unwrap().into(); @@ -1370,7 +1374,7 @@ mod tests { crate::Transport::WebTransport, "expected the QUIC listener", ); - let (session, _driver) = request.with_publisher(&pub_origin).ok().await?; + let session = request.with_publisher(&pub_origin).ok().await?; assert_eq!(session.version(), expected_version, "server negotiated stale version"); let _broadcast = broadcast; let _track = track; @@ -1584,10 +1588,10 @@ mod tests { let timeout = crate::connect::DEFAULT_TIMEOUT; let mut config = crate::connect::Config { - timeout: Some(timeout), + timeout: Some((timeout).into()), ..Default::default() }; - config.websocket.delay = Some(std::time::Duration::ZERO); + config.websocket.delay = Some((std::time::Duration::ZERO).into()); 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 98fd525018a..4cf1c56ed4e 100644 --- a/rs/moq-tokio/src/connect.rs +++ b/rs/moq-tokio/src/connect.rs @@ -261,14 +261,11 @@ impl Legacy { ); } if self.reconnect.is_some() { - // Named as a change rather than a rename: the replacement means the - // opposite, so a bare mapping would have someone carry the value across - // unchanged and get the behavior they were trying to keep, inverted. found.changed( "--client-reconnect", Some("MOQ_CLIENT_RECONNECT"), - "--connect-once / MOQ_CONNECT_ONCE", - "inverted: --client-reconnect=false is --connect-once=true", + "Client::with_reconnect(bool)", + "native API only; CLI clients always reconnect", ); } if !self.version.is_empty() { @@ -488,7 +485,7 @@ pub(crate) fn all_extensions(extensions: &moq_net::setup::Extensions) -> bool { /// /// Derives [`usage_rs::Args`], so flatten it into a binary's own parser with /// `#[usage(flatten)]`. The accept side is [`crate::listen::Config`]. -#[derive(Clone, Debug, usage_rs::Args, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, Default, usage_rs::Args, serde::Serialize, serde::Deserialize)] #[usage(unknown_flags = "error", args_override_self = false)] #[serde(deny_unknown_fields, default)] #[non_exhaustive] @@ -529,22 +526,17 @@ pub struct Config { /// it fails), and the first connection to complete wins. `0s` dials every /// address at once. Defaults to 250ms. Applies to the QUIC, `tcp://`, and `tls://` dials. /// - /// This staggers the attempts within one [`crate::Client::connect`]; [`Self::timeout`] - /// bounds that call as a whole. - /// `None` uses the default; an explicit command-line flag or environment variable wins. - #[usage(skip)] - #[serde(default, with = "humantime_serde", skip_serializing_if = "Option::is_none")] - pub race: Option, - - /// Delay before also dialing the next resolved address. Defaults to 250ms; 0s dials every address at once. + /// This staggers the attempts within one connect; `--connect-timeout` bounds that + /// call as a whole. + /// `None` uses the default; zero disables this duration. #[usage( name = "connect-race", long = "connect-race", env = "MOQ_CONNECT_RACE", setting = "connect.race" )] - #[serde(default, rename = "__cli_race", skip_serializing_if = "Option::is_none")] - pub(crate) race_arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub race: Option, /// The released `failover_delay` key, kept so [`deprecated`](Self::deprecated) can name [`race`](Self::race). #[serde(default, skip_serializing)] @@ -558,44 +550,34 @@ 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. - /// `None` uses the default; an explicit command-line flag or environment variable wins. - #[usage(skip)] - #[serde(default, with = "humantime_serde", skip_serializing_if = "Option::is_none")] - pub resolution_delay: Option, - - /// Delay before dialing IPv4 while the full DNS answer is outstanding. Defaults to 50ms. + /// `None` uses the default; zero disables this duration. #[usage( name = "connect-resolution-delay", long = "connect-resolution-delay", env = "MOQ_CONNECT_RESOLUTION_DELAY", setting = "connect.resolution_delay" )] - #[serde(default, rename = "__cli_resolution_delay", skip_serializing_if = "Option::is_none")] - pub(crate) resolution_delay_arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resolution_delay: Option, - /// Maximum time for one [`crate::Client::connect`], covering the dial and the MoQ + /// Maximum time for one connect, covering the dial and the MoQ /// handshake. Defaults to 30 seconds; set to 0 to wait forever. /// /// This has to live above the transports rather than inside one: QUIC bounds its /// own dial, but the WebSocket fallback and the handshake that follows either /// transport have no deadline of their own, so a peer that accepts TCP and then - /// never speaks would hang the whole connect. [`crate::Connection`] only re-arms + /// never speaks would hang the whole connect. The reconnect loop only re-arms /// its backoff between attempts, so an attempt that never returns stalls the /// retry loop indefinitely. - /// `None` uses the default; an explicit command-line flag or environment variable wins. - #[usage(skip)] - #[serde(default, with = "humantime_serde", skip_serializing_if = "Option::is_none")] - pub timeout: Option, - - /// Maximum time for one connect, covering the dial and the MoQ handshake. Defaults to 30s; 0s waits forever. + /// `None` uses the default; zero disables this duration. #[usage( name = "connect-timeout", long = "connect-timeout", env = "MOQ_CONNECT_TIMEOUT", setting = "connect.timeout" )] - #[serde(default, rename = "__cli_timeout", skip_serializing_if = "Option::is_none")] - pub(crate) timeout_arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, /// Restrict the client to specific MoQ protocol version(s). /// @@ -646,28 +628,10 @@ pub struct Config { #[serde(default)] pub tls: crate::tls::Connect, - /// Dial once instead of redialing (with backoff) whenever the session drops. + /// The released reconnect key, parsed only to explain its removal. /// - /// The connection reconnects by default. With this set, the session's - /// close surfaces through its closed event instead. - #[serde(skip_serializing_if = "Option::is_none")] - #[usage( - name = "connect-once", - long = "connect-once", - env = "MOQ_CONNECT_ONCE", - setting = "connect.once", - default_missing = "true", - num_args = 0..=1, - require_equals = true, - )] - pub once: Option, - - /// The released `reconnect` key, which said the same thing the other way around. - /// - /// Parsed only so [`deprecated`](Self::deprecated) can name [`once`](Self::once) - /// and the inversion; `deny_unknown_fields` would otherwise reject the file with - /// nothing to migrate to. TOML-only: the `--client-reconnect` flag lives on - /// [`Legacy`] with the rest. + /// Native callers set [`crate::Client::with_reconnect`] on each client clone. + /// TOML-only: the `--client-reconnect` flag lives on [`Legacy`] with the rest. #[serde(default, skip_serializing)] #[usage(skip)] pub(crate) reconnect: Option, @@ -702,36 +666,7 @@ pub struct Config { /// Parsed only so [`deprecated`](Self::deprecated) can name the replacement. #[usage(skip)] #[serde(default, skip_serializing_if = "Option::is_none")] - pub quic: Option, -} - -impl Default for Config { - fn default() -> Self { - Self { - url: None, - connect: None, - bind: None, - race: Some(DEFAULT_RACE), - race_arg: None, - failover_delay: None, - resolution_delay: Some(DEFAULT_RESOLUTION_DELAY), - resolution_delay_arg: None, - timeout: Some(DEFAULT_TIMEOUT), - timeout_arg: None, - version: Vec::new(), - extensions: Default::default(), - implementation: None, - tls: Default::default(), - once: None, - reconnect: None, - backoff: Default::default(), - goaway: Default::default(), - #[cfg(feature = "websocket")] - websocket: Default::default(), - legacy: Default::default(), - quic: None, - } - } + pub(crate) quic: Option, } impl Config { @@ -753,7 +688,11 @@ impl Config { found.toml("failover_delay", "race", None); } if self.reconnect.is_some() { - found.toml("reconnect", "once", Some("inverted: reconnect = false is once = true")); + found.toml( + "reconnect", + "Client::with_reconnect(bool)", + Some("native API only; CLI clients always reconnect"), + ); } if self.quic.is_some() { found.toml("[client.quic]", "[quic]", Some("now applies to both directions")); @@ -791,17 +730,12 @@ impl Config { pub fn resolve(&self) -> Resolved { Resolved { bind: self.bind.unwrap_or_else(default_bind), - race: self.race_arg.map(Into::into).or(self.race).unwrap_or(DEFAULT_RACE), + race: self.race.map(Into::into).unwrap_or(DEFAULT_RACE), resolution_delay: self - .resolution_delay_arg + .resolution_delay .map(Into::into) - .or(self.resolution_delay) .unwrap_or(DEFAULT_RESOLUTION_DELAY), - timeout: self - .timeout_arg - .map(Into::into) - .or(self.timeout) - .unwrap_or(DEFAULT_TIMEOUT), + timeout: self.timeout.map(Into::into).unwrap_or(DEFAULT_TIMEOUT), } } } diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index a37be008977..d21d9554728 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -3,8 +3,6 @@ use std::task::{Poll, ready}; use std::time::Duration; -use crate::cli::Duration as CliDuration; - use moq_net::Version; use moq_net::bandwidth::{Consumer as BandwidthConsumer, Producer as BandwidthProducer}; use moq_net::kio; @@ -50,43 +48,37 @@ fn settles_walk(err: &Error) -> bool { /// Exponential backoff configuration for reconnection attempts. /// -/// The delays carry jitter, so a fleet -/// knocked offline together does not reconnect in lockstep. The timeout bounds every failure; -/// a settled rejection, malformed protocol input, or invalid local configuration stops retrying. -/// A GOAWAY naming no new URI is no failure: a restarting peer sends one to each session until -/// it goes, and each of those -/// serves, so it restarts the timeout instead. +/// Three knobs, each with one job: `initial` and `multiplier` set the wait before +/// each retry, `max` caps it, and `healthy` decides which sessions count as failed +/// attempts. The delays carry jitter, so a fleet knocked offline together does not +/// reconnect in lockstep. The timeout bounds every failure; a settled rejection, +/// malformed protocol input, or invalid local configuration stops retrying. A GOAWAY +/// naming no new URI is no failure: a restarting peer sends one to each session until +/// it goes, and each of those serves, so it restarts the timeout instead. /// -/// A GOAWAY off a session that outlived `initial` migrates at once, unless it sends the -/// connection back to an endpoint that already redirected it elsewhere and the session lived -/// less than `max`. Two peers redirecting to each other thus wait out this backoff and count +/// A GOAWAY off a healthy session migrates at once, unless it sends the connection back +/// to an endpoint that already redirected it elsewhere and the session lived less than +/// `max`. Two peers redirecting to each other thus wait out this backoff and count /// against the timeout. #[derive(Clone, Debug, usage_rs::Args, serde::Serialize, serde::Deserialize)] #[usage(unknown_flags = "error", args_override_self = false)] #[serde(default, deny_unknown_fields)] #[non_exhaustive] pub struct Backoff { - /// Initial delay before first reconnect attempt. Defaults to 1s. + /// Delay before the first reconnect attempt. Defaults to 1s; zero retries at once. /// - /// 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. - /// `None` uses the default; an explicit command-line flag or environment variable wins. - #[usage(skip)] - #[serde(default, with = "humantime_serde", skip_serializing_if = "Option::is_none")] - pub initial: Option, - - /// Initial delay before the first reconnect attempt. Defaults to 1s, floored at 50ms. + /// Later delays grow from it, so zero turns retry waiting off entirely: an immediate + /// close still counts as a failed attempt and spends the timeout. #[usage( name = "connect-backoff-initial", long, env = "MOQ_CONNECT_BACKOFF_INITIAL", setting = "connect.backoff.initial" )] - #[serde(default, rename = "__cli_initial", skip_serializing_if = "Option::is_none")] - initial_arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub initial: Option, - /// Multiplier applied to delay after each failure. Defaults to 2. + /// Multiplier applied to delay after each failure. Defaults to 2; 0 or 1 keeps it constant. #[usage( name = "connect-backoff-multiplier", long, @@ -96,51 +88,48 @@ pub struct Backoff { )] pub multiplier: u32, - /// Maximum delay between reconnect attempts. Defaults to 5s. - /// `None` uses the default; an explicit command-line flag or environment variable wins. - #[usage(skip)] - #[serde(default, with = "humantime_serde", skip_serializing_if = "Option::is_none")] - pub max: Option, - - /// Maximum delay between reconnect attempts. Defaults to 5s. + /// Cap on the delay between reconnect attempts. Defaults to 5s; zero leaves it uncapped. + /// + /// A cap below `initial` caps the first delay too. #[usage( name = "connect-backoff-max", long, env = "MOQ_CONNECT_BACKOFF_MAX", setting = "connect.backoff.max" )] - #[serde(default, rename = "__cli_max", skip_serializing_if = "Option::is_none")] - max_arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max: Option, - /// Maximum time to spend retrying before giving up. Defaults to 10s. + /// How long a session must stay up to count as healthy. Defaults to 1s; zero is refused. /// - /// No new walk of the peer's addresses starts once it has passed, but one under way finishes: - /// each candidate but the last gets up to 5s, and the last runs to - /// [`crate::connect::Config::timeout`](crate::connect::Config::timeout), so a handshake - /// slower than this still lands. Giving up can therefore take up to one whole walk longer. + /// A healthy session's end resets the delay and the timeout. One that ends sooner is a + /// failed attempt, so a peer that accepts and then drops every session still escalates + /// the delay and exhausts the timeout. + #[usage( + name = "connect-backoff-healthy", + long, + env = "MOQ_CONNECT_BACKOFF_HEALTHY", + setting = "connect.backoff.healthy" + )] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub healthy: Option, + + /// Maximum time to spend retrying before giving up. Defaults to 10s; zero retries forever. /// - /// Resets after a stable connection (one that outlives the initial backoff) outside a redirect - /// loop, so a flapping session that reconnects then immediately drops still counts toward the - /// timeout. Set to `Some(Duration::ZERO)` for unlimited retries. `None` takes the default. When this config was - /// parsed from the command line, a `--connect-backoff-timeout` given there wins over this field. + /// No new walk of the peer's addresses starts once it has passed, but one under way finishes: + /// each candidate but the last gets up to 5s, and the last runs to `--connect-timeout`, + /// so a handshake slower than this still lands. Giving up can therefore take up to one whole walk longer. /// - /// An `Option` because zero is a real setting: a command-line parse leaves this field at its - /// type's default, and only `None` tells "unset" apart from "retry forever". - #[usage(skip)] - #[serde(default, with = "humantime_serde", skip_serializing_if = "Option::is_none")] - pub timeout: Option, - - // No parser default: the parser stores one as if it were given, and it would then beat a - // window set in code. The default lives in [`Backoff::window`] instead. - /// Maximum time to spend retrying before giving up. Defaults to 10s; 0s retries forever. + /// Resets after a healthy session outside a redirect loop, so a flapping session that + /// reconnects then immediately drops still counts toward the timeout. #[usage( name = "connect-backoff-timeout", long, env = "MOQ_CONNECT_BACKOFF_TIMEOUT", setting = "connect.backoff.timeout" )] - #[serde(default, rename = "__cli_timeout", skip_serializing_if = "Option::is_none")] - timeout_arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, #[usage( name = "backoff-initial", long = "backoff-initial", @@ -148,7 +137,7 @@ pub struct Backoff { hide = true )] #[serde(skip)] - legacy_initial: Option, + legacy_initial: Option, #[usage( name = "backoff-multiplier", @@ -161,7 +150,7 @@ pub struct Backoff { #[usage(name = "backoff-max", long = "backoff-max", env = "MOQ_BACKOFF_MAX", hide = true)] #[serde(skip)] - legacy_max: Option, + legacy_max: Option, #[usage( name = "backoff-timeout", @@ -170,19 +159,17 @@ pub struct Backoff { hide = true )] #[serde(skip)] - legacy_timeout: Option, + legacy_timeout: Option, } impl Default for Backoff { fn default() -> Self { Self { - initial: Some(DEFAULT_INITIAL), - initial_arg: None, + initial: None, multiplier: DEFAULT_MULTIPLIER, - max: Some(DEFAULT_MAX), - max_arg: None, - timeout: Some(DEFAULT_TIMEOUT), - timeout_arg: None, + max: None, + healthy: None, + timeout: None, legacy_initial: None, legacy_multiplier: None, legacy_max: None, @@ -192,6 +179,35 @@ impl Default for Backoff { } impl Backoff { + /// The configured initial retry delay, or its default; zero retries at once. + pub fn initial(&self) -> Duration { + self.initial.map(Into::into).unwrap_or(DEFAULT_INITIAL) + } + + /// The configured retry-delay cap, or its default; zero leaves the delay uncapped. + pub fn max(&self) -> Duration { + self.max.map(Into::into).unwrap_or(DEFAULT_MAX) + } + + /// The retry deadline window, with zero meaning unlimited and unset using the default. + pub fn timeout(&self) -> Duration { + self.timeout.map(Into::into).unwrap_or(DEFAULT_TIMEOUT) + } + + /// How long a session must last to count as healthy. + fn healthy(&self) -> Duration { + self.healthy.map(Into::into).unwrap_or(DEFAULT_HEALTHY) + } + + /// Refuse a zero healthy threshold, which would count every session as healthy and + /// reset the timeout forever under a peer that drops each one on sight. + pub(crate) fn validate(&self) -> crate::Result<()> { + if self.healthy().is_zero() { + return Err(Error::BackoffHealthy); + } + Ok(()) + } + pub(crate) fn deprecated(&self) -> crate::cli::Deprecated { let mut found = crate::cli::Deprecated::default(); if self.legacy_initial.is_some() { @@ -224,15 +240,6 @@ impl Backoff { } found } - - /// The retry window in effect, where zero means unlimited: the flag, then the typed field, then - /// the default. - fn window(&self) -> Duration { - self.timeout_arg - .map(Into::into) - .or(self.timeout) - .unwrap_or(DEFAULT_TIMEOUT) - } } /// A connection lifecycle transition reported by [`Connection::status`]. @@ -425,20 +432,15 @@ 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. - /// `None` uses the default; an explicit command-line flag or environment variable wins. - #[usage(skip)] - #[serde(default, with = "humantime_serde", skip_serializing_if = "Option::is_none")] - pub handover: Option, - - /// How long the old session keeps serving after its replacement connects. Defaults to 10s. + /// `None` uses the default; zero disables this duration. #[usage( name = "connect-goaway-handover", long, env = "MOQ_CONNECT_GOAWAY_HANDOVER", setting = "connect.goaway.handover" )] - #[serde(default, rename = "__cli_handover", skip_serializing_if = "Option::is_none")] - handover_arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub handover: Option, #[usage( name = "goaway-redirect", long = "goaway-redirect", @@ -456,15 +458,14 @@ pub struct Goaway { hide = true )] #[serde(skip)] - legacy_handover: Option, + legacy_handover: Option, } impl Default for Goaway { fn default() -> Self { Self { redirect: Redirect::SameHost, - handover: Some(DEFAULT_HANDOVER), - handover_arg: None, + handover: None, legacy_redirect: None, legacy_handover: None, } @@ -475,51 +476,46 @@ impl Default for Goaway { const DEFAULT_INITIAL: Duration = Duration::from_secs(1); const DEFAULT_MULTIPLIER: u32 = 2; const DEFAULT_MAX: Duration = Duration::from_secs(5); +const DEFAULT_HEALTHY: Duration = Duration::from_secs(1); const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); -/// Floor for the retry delay, which also sets the bar for calling a session -/// healthy. Small enough to stay out of the way of a fast config, large enough -/// that a session closed on sight never clears it. -const MIN_BACKOFF: Duration = Duration::from_millis(50); - -/// The retry pacing a connection actually runs on: [`Backoff`] with the floors -/// applied. -/// -/// Every knob can zero the delay on its own, and a delay of zero is a dial loop -/// that runs as fast as the runtime allows. `initial` seeds it, `multiplier` -/// grows it, and `max` caps it, so a zero anywhere collapses the whole schedule. -/// Resolving them once, here, keeps that from having to be re-argued at each of -/// the three places the loop reaches for them. +/// The retry pacing a connection actually runs on, resolved once from [`Backoff`]. #[derive(Clone, Copy)] struct Pacing { - /// The first delay, and the bar a session must clear to count as healthy. At - /// zero every session looks healthy, which resets the give-up window forever. + /// The first delay, already under the cap. initial: Duration, - /// Never below `initial`, so the cap cannot undo the floor on later retries. - max: Duration, - /// At least 1, since zero would shrink the delay back to nothing. + /// The delay cap, or `None` for uncapped. + max: Option, + /// At least 1: a multiplier of zero means no growth, not a delay that shrinks to nothing. multiplier: u32, + /// The bar a session must clear to count as healthy. Never zero. + healthy: Duration, } impl Pacing { fn new(backoff: &Backoff) -> Self { - let initial = backoff - .initial_arg - .map(Into::into) - .or(backoff.initial) - .unwrap_or(DEFAULT_INITIAL) - .max(MIN_BACKOFF); - let max = backoff.max_arg.map(Into::into).or(backoff.max).unwrap_or(DEFAULT_MAX); + let max = Some(backoff.max()).filter(|max| !max.is_zero()); + let initial = backoff.initial(); Self { - initial, - max: max.max(initial), + initial: max.map_or(initial, |max| initial.min(max)), + max, multiplier: backoff.multiplier.max(1), + healthy: backoff.healthy(), } } /// The delay for the attempt after one that waited `delay`. fn next(&self, delay: Duration) -> Duration { - std::cmp::min(delay.saturating_mul(self.multiplier), self.max) + let next = delay.saturating_mul(self.multiplier); + self.max.map_or(next, |max| next.min(max)) + } + + /// How long a session must last to start a new run of redirects. + /// + /// One that outlived the capped delay already redials no faster than the backoff would + /// allow. Uncapped, the healthy bar is the only rate the config names. + fn settled(&self) -> Duration { + self.max.unwrap_or(self.healthy) } } @@ -550,11 +546,7 @@ impl Goaway { fn resolve(&self) -> Resolved { Resolved { redirect: self.redirect, - handover: self - .handover_arg - .map(Into::into) - .or(self.handover) - .unwrap_or(DEFAULT_HANDOVER), + handover: self.handover.map(Into::into).unwrap_or(DEFAULT_HANDOVER), } } } @@ -904,7 +896,7 @@ pub struct Snapshot { /// Handle to a connection maintained by a background task. /// /// The task connects, waits for the session to end, then (unless reconnecting is -/// disabled, see [`crate::connect::Config::once`](crate::connect::Config::once)) +/// disabled, see [`crate::Client::with_reconnect`]) /// redials with exponential backoff. The read surface mirrors [`moq_net::Session`] /// so a caller can treat it like a session that transparently reconnects: /// [`version`](Self::version), [`send_bandwidth`](Self::send_bandwidth), @@ -1080,9 +1072,8 @@ impl Connection { let backoff = client.backoff.clone(); let goaway = client.goaway.resolve(); let pacing = Pacing::new(&backoff); - let timeout = backoff.window(); - let initial = pacing.initial; - let mut retry = Retry::new(initial); + let timeout = backoff.timeout(); + let mut retry = Retry::new(pacing.initial); // Sticky across migrations: a redirect is an assignment, not a detour, so a // later drop redials wherever we were last sent. Scoped to this loop, so a // fresh Connection starts from the configured addresses again. @@ -1143,7 +1134,7 @@ impl Connection { return Ok(()); } - // Two peers that each outlive the initial backoff can still bounce us between + // Two peers that each hold a session past the healthy bar can still bounce us between // them forever. Returning to an endpoint that already sent us elsewhere in this // run of migrations skips the healthy-session reset, so the backoff escalates and // the retry window still applies. A one-way rolling restart never revisits one, @@ -1151,7 +1142,7 @@ impl Connection { // counts. A session that outlived the capped delay already redials no faster // than the backoff would allow, so it starts a new run: rolling restarts days // apart never add up to a loop. - if matches!(ended, Ended::Closed(_)) || tenure >= pacing.max { + if matches!(ended, Ended::Closed(_)) || tenure >= pacing.settled() { redirects.reset(); } // Only an accepted redirect is a move: an empty or ignored one reconnects in place. @@ -1161,9 +1152,9 @@ impl Connection { to != from && redirects.depart(from, &to) }); - // A session that stayed up past the initial backoff is healthy; one that + // A session that stayed up past the healthy bar is healthy; one that // ended sooner, or carried on a redirect loop, counts as a failed attempt. - let healthy = !looped && retry.settle(tenure, initial); + let healthy = !looped && retry.settle(tenure, &pacing); if let Ended::Goaway(msg) = &ended { // An accepted redirect is an assignment: keep dialing it from here on, and @@ -1579,12 +1570,11 @@ impl Retry { } } - /// Score how long a session served: past `initial` is healthy and starts a fresh - /// retry window. - fn settle(&mut self, tenure: Duration, initial: Duration) -> bool { - let healthy = tenure >= initial; + /// Score how long a session served: past the healthy bar starts a fresh retry window. + fn settle(&mut self, tenure: Duration, pacing: &Pacing) -> bool { + let healthy = tenure >= pacing.healthy; if healthy { - *self = Self::new(initial); + *self = Self::new(pacing.initial); } healthy } @@ -1929,24 +1919,27 @@ 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 = Some(Duration::from_secs(7)); + parsed.backoff.initial = Some((Duration::from_secs(7)).into()); parsed.backoff.multiplier = 5; - parsed.backoff.max = Some(Duration::from_secs(11)); - parsed.backoff.timeout = Some(Duration::ZERO); + parsed.backoff.max = Some((Duration::from_secs(11)).into()); + parsed.backoff.healthy = Some((Duration::from_secs(2)).into()); + parsed.backoff.timeout = Some((Duration::ZERO).into()); parsed.update_from(&[]); - assert_eq!(parsed.backoff.initial, Some(Duration::from_secs(7))); + assert_eq!(parsed.backoff.initial, Some((Duration::from_secs(7)).into())); assert_eq!(parsed.backoff.multiplier, 5); - assert_eq!(parsed.backoff.max, Some(Duration::from_secs(11))); - assert_eq!(parsed.backoff.window(), Duration::ZERO, "0 means retry forever"); + assert_eq!(parsed.backoff.max, Some((Duration::from_secs(11)).into())); + assert_eq!(parsed.backoff.healthy, Some((Duration::from_secs(2)).into())); + assert_eq!(parsed.backoff.timeout(), Duration::ZERO, "0 means retry forever"); // With no flags, the parser defaults resolve to the typed defaults. let parsed = Wrapper::parse_from(&[]).unwrap(); 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!(parsed.backoff.window(), DEFAULT_TIMEOUT); + assert_eq!(pacing.max, Some(DEFAULT_MAX)); + assert_eq!(pacing.healthy, DEFAULT_HEALTHY); + assert_eq!(parsed.backoff.timeout(), DEFAULT_TIMEOUT); // And a flag still lands where the merge can see it. let parsed = Wrapper::parse_from(&[ @@ -1993,7 +1986,7 @@ mod tests { #[test] fn handover_takes_the_earlier_deadline() { let config = Goaway { - handover: Some(Duration::from_secs(10)), + handover: Some((Duration::from_secs(10)).into()), ..Default::default() }; @@ -2261,60 +2254,72 @@ mod tests { ); } - /// Every pacing knob can zero the retry delay on its own, and a zero delay is a - /// dial loop bounded by nothing. The floors are what keep a degenerate config - /// (or a binding that passes zeros) from turning unlimited retries into a spin. + /// Each knob means one thing at zero: no wait, no cap, no growth. None of them touches + /// the healthy bar, which is what keeps an immediate close a failed attempt. #[test] - fn pacing_floors_every_degenerate_knob() { - // A zero initial would also make every session look healthy, resetting the - // give-up window forever. + fn zero_turns_each_pacing_knob_off() { let pacing = Pacing::new(&Backoff { - initial: Some(Duration::ZERO), + initial: Some((Duration::ZERO).into()), ..Default::default() }); - assert_eq!(pacing.initial, MIN_BACKOFF); - assert!(pacing.next(pacing.initial) >= MIN_BACKOFF); + assert_eq!(pacing.initial, Duration::ZERO); + assert_eq!(pacing.next(pacing.initial), Duration::ZERO, "zero initial never waits"); + assert_eq!(pacing.healthy, DEFAULT_HEALTHY); - // A cap below the floor would clamp the delay straight back down. let pacing = Pacing::new(&Backoff { - max: Some(Duration::ZERO), + max: Some((Duration::ZERO).into()), ..Default::default() }); - assert_eq!(pacing.max, pacing.initial); - assert_eq!(pacing.next(pacing.initial), pacing.initial); + assert_eq!(pacing.max, None); + let mut delay = pacing.initial; + for _ in 0..10 { + delay = pacing.next(delay); + } + assert_eq!(delay, DEFAULT_INITIAL * 1024, "zero max leaves the delay uncapped"); + assert_eq!(pacing.settled(), DEFAULT_HEALTHY); - // A zero multiplier would shrink it to nothing on the second attempt. let pacing = Pacing::new(&Backoff { multiplier: 0, ..Default::default() }); - assert_eq!(pacing.next(pacing.initial), pacing.initial); + assert_eq!( + pacing.next(pacing.initial), + pacing.initial, + "zero multiplier never grows" + ); - // All at once: still paced. + // A cap below the first delay caps it too. let pacing = Pacing::new(&Backoff { - initial: Some(Duration::ZERO), - max: Some(Duration::ZERO), - multiplier: 0, + max: Some((Duration::from_millis(300)).into()), ..Default::default() }); - let mut delay = pacing.initial; - for _ in 0..10 { - delay = pacing.next(delay); - assert!(delay >= MIN_BACKOFF, "the retry delay collapsed to {delay:?}"); - } + assert_eq!(pacing.initial, Duration::from_millis(300)); + } + + /// A zero healthy bar would count every session healthy, so it is refused at build. + #[test] + fn a_zero_healthy_bar_is_refused() { + let mut config = crate::connect::Config::default(); + config.backoff.healthy = Some((Duration::ZERO).into()); + let err = config + .init(Default::default()) + .err() + .expect("zero healthy built a client"); + assert!(matches!(err, Error::BackoffHealthy), "{err}"); } /// A sane config is left alone: the delay doubles up to the cap and stays there. #[test] fn pacing_grows_to_the_cap() { let pacing = Pacing::new(&Backoff { - initial: Some(Duration::from_millis(100)), + initial: Some((Duration::from_millis(100)).into()), multiplier: 2, - max: Some(Duration::from_millis(400)), + max: Some((Duration::from_millis(400)).into()), ..Default::default() }); assert_eq!(pacing.initial, Duration::from_millis(100)); + assert_eq!(pacing.settled(), Duration::from_millis(400)); assert_eq!(pacing.next(Duration::from_millis(100)), Duration::from_millis(200)); assert_eq!(pacing.next(Duration::from_millis(200)), Duration::from_millis(400)); assert_eq!(pacing.next(Duration::from_millis(400)), Duration::from_millis(400)); @@ -2446,6 +2451,9 @@ mod tests { const WALK_TIMEOUT: Duration = Duration::from_secs(30); + /// A retry delay and healthy bar short enough to stay out of a test's way. + const FAST: Duration = Duration::from_millis(50); + fn url(value: &str) -> Url { value.parse().expect("valid url") } @@ -2726,7 +2734,7 @@ mod tests { async fn an_unsupported_scheme_ends_unlimited_reconnects() { let mut config = crate::connect::Config::default(); config.tls.insecure = Some(true); - config.backoff.timeout = Some(Duration::ZERO); + config.backoff.timeout = Some((Duration::ZERO).into()); let client = config.init(Default::default()).expect("build client"); let started = tokio::time::Instant::now(); let connection = client.connect(url("moqz://localhost/")); @@ -2744,7 +2752,7 @@ mod tests { async fn an_invalid_tls_server_name_ends_unlimited_reconnects() { let mut config = crate::connect::Config::default(); config.tls.insecure = Some(true); - config.backoff.timeout = Some(Duration::ZERO); + config.backoff.timeout = Some((Duration::ZERO).into()); let client = config.init(Default::default()).expect("build client"); for target in [ "https://a!b:1/", @@ -2786,15 +2794,15 @@ mod tests { .to_string(); let mut config = crate::connect::Config::default(); - config.backoff.initial = Some(Duration::from_millis(50)); - config.backoff.max = Some(Duration::from_millis(50)); - config.backoff.timeout = Some(Duration::from_millis(100)); + config.backoff.initial = Some((Duration::from_millis(50)).into()); + config.backoff.max = Some((Duration::from_millis(50)).into()); + config.backoff.timeout = Some((Duration::from_millis(100)).into()); // No timer may be pending while a dial is in flight, since the paused clock // jumps to it whenever the runtime idles. Linux and macOS refuse a loopback // connect inside the call, but Windows reports it later, after retrying the // SYN, so the connect deadline or the Happy Eyeballs stagger would fire first. - config.timeout = Some(Duration::ZERO); - config.race = Some(Duration::ZERO); + config.timeout = Some((Duration::ZERO).into()); + config.race = Some((Duration::ZERO).into()); let client = config.init(Default::default()).expect("build client"); let started = tokio::time::Instant::now(); @@ -2825,9 +2833,9 @@ mod tests { crate::resolve::hosts::point(HOST, failing); let mut config = crate::connect::Config::default(); - config.backoff.initial = Some(MIN_BACKOFF); - config.backoff.max = Some(MIN_BACKOFF); - config.backoff.timeout = Some(Duration::ZERO); + config.backoff.initial = Some((FAST).into()); + config.backoff.max = Some((FAST).into()); + config.backoff.timeout = Some((Duration::ZERO).into()); let client = config.init(Default::default()).unwrap(); let mut connection = client.connect(url(&format!("tcp://{HOST}:1/"))); let monitor = connection.monitor(); @@ -2867,9 +2875,9 @@ mod tests { // A held port, not a released one: another test's server could claim a // released port, and the dial would connect instead of failing. let mut config = crate::connect::Config::default(); - config.backoff.initial = Some(MIN_BACKOFF); - config.backoff.max = Some(MIN_BACKOFF); - config.backoff.timeout = Some(Duration::ZERO); + config.backoff.initial = Some((FAST).into()); + config.backoff.max = Some((FAST).into()); + config.backoff.timeout = Some((Duration::ZERO).into()); let client = config.init(Default::default()).unwrap(); let connection = client.connect(dead().await); let mut monitor = connection.monitor(); @@ -2908,8 +2916,8 @@ mod tests { version: vec![version], ..Default::default() }; - config.backoff.initial = Some(MIN_BACKOFF); - config.backoff.max = Some(MIN_BACKOFF); + config.backoff.initial = Some((FAST).into()); + config.backoff.max = Some((FAST).into()); let connection = config .init(Default::default()) .unwrap() @@ -2928,7 +2936,7 @@ mod tests { for round in 0..2 { let mut request = tokio::time::timeout(WAIT, listener.accept()).await.unwrap().unwrap(); let mut requests = request.requests(); - let (session, _task) = request.ok().await.unwrap(); + let session = request.ok().await.unwrap(); loop { let request = tokio::time::timeout(WAIT, requests.next()).await.unwrap().unwrap(); if request.token().is_empty() { @@ -2964,8 +2972,8 @@ mod tests { let (addr, mut accepted, _task) = serve(crate::origin::spawn()).await; let mut config = crate::connect::Config::default(); - config.backoff.initial = Some(MIN_BACKOFF); - config.backoff.max = Some(MIN_BACKOFF); + config.backoff.initial = Some((FAST).into()); + config.backoff.max = Some((FAST).into()); let connection = config .init(Default::default()) .unwrap() @@ -3154,7 +3162,7 @@ mod tests { tokio::spawn(async move { while let Some(request) = server.accept().await { let transport = request.transport(); - if let Ok((session, _driver)) = request.ok().await { + if let Ok(session) = request.ok().await { let _ = accepted.send((transport, session)); } } @@ -3187,7 +3195,7 @@ mod tests { let mut config = crate::connect::Config::default(); config.tls.insecure = Some(true); - config.websocket.delay = Some(Duration::ZERO); + config.websocket.delay = Some((Duration::ZERO).into()); let client = config.init(Default::default()).unwrap(); let url = url(&format!("http://127.0.0.1:{port}/")); let connection = tokio::time::timeout(WAIT, client.connect(url).established()) @@ -3228,7 +3236,7 @@ mod tests { let (accepted, sessions) = tokio::sync::mpsc::unbounded_channel(); let task = tokio::spawn(async move { while let Some(request) = server.accept().await { - if let Ok((session, _driver)) = request.with_publisher(&origin).ok().await { + if let Ok(session) = request.with_publisher(&origin).ok().await { let _ = accepted.send(session); } } @@ -3262,10 +3270,11 @@ mod tests { let subscriber = crate::origin::spawn(); let mut config = crate::connect::Config::default(); - config.goaway.handover = Some(HANDOVER); - // A session younger than the initial delay counts as redirected immediately and + config.goaway.handover = Some((HANDOVER).into()); + // A session younger than the healthy bar counts as redirected immediately and // waits out a backoff, which this test is not about. - config.backoff.initial = Some(MIN_BACKOFF); + config.backoff.initial = Some((FAST).into()); + config.backoff.healthy = Some((FAST).into()); let client = config .init(Default::default()) .unwrap() @@ -3342,7 +3351,8 @@ mod tests { let subscriber = crate::origin::spawn(); let mut config = crate::connect::Config::default(); - config.backoff.initial = Some(MIN_BACKOFF); + config.backoff.initial = Some((FAST).into()); + config.backoff.healthy = Some((FAST).into()); let client = config .init(Default::default()) .unwrap() @@ -3384,6 +3394,241 @@ mod tests { .unwrap(); } + /// What a flapping peer does with each session the moment it is up. + #[cfg(feature = "tcp")] + #[derive(Clone)] + enum Flap { + /// Drop it, closing the session. + Close, + /// Send a GOAWAY naming this URI, empty for a restart, and keep serving. + Goaway(String), + } + + /// A client with the given pacing whose dials reach in-memory peers instead of a + /// transport, so the paused clock only moves once every task waits on a timer. + /// + /// The peer at each URL ends every session as its [`Flap`] says, and reports the + /// URL and instant each session came up on the returned channel. + #[cfg(feature = "tcp")] + fn flapping( + initial: Duration, + max: Duration, + timeout: Duration, + peers: Vec<(Url, Flap)>, + ) -> ( + Client, + tokio::sync::mpsc::UnboundedReceiver<(Url, tokio::time::Instant)>, + ) { + let mut config = crate::connect::Config::default(); + config.backoff.initial = Some(initial.into()); + config.backoff.max = Some(max.into()); + config.backoff.timeout = Some(timeout.into()); + let mut client = config.init(Default::default()).expect("build client"); + + let (accepted, accepts) = tokio::sync::mpsc::unbounded_channel(); + client.dialer = Some(std::sync::Arc::new(move |addr: crate::connect::Addr| { + let url = addr.url().clone(); + let (_, flap) = peers + .iter() + .find(|(peer, _)| *peer == url) + .expect("an unknown peer") + .clone(); + let accepted = accepted.clone(); + Box::pin(async move { + let session = flap_once(flap).await; + let _ = accepted.send((url, tokio::time::Instant::now())); + Ok(crate::client::Dialed { + session, + transport: crate::Transport::Tcp, + }) + }) + })); + (client, accepts) + } + + /// Run one in-memory MoQ handshake whose server side then ends the session as `flap` says. + #[cfg(feature = "tcp")] + async fn flap_once(flap: Flap) -> moq_net::Session { + let versions = moq_net::Versions::from("moq-lite-07-wip".parse::().unwrap()); + let config = || { + let mut config = qmux::Config::new(qmux::Version::QMux01); + config.protocol = qmux::Protocol::Negotiate(versions.alpns().iter().map(|alpn| alpn.to_string()).collect()); + config + }; + let stream = |io| qmux::transport::Stream::new(io); + let (client, server) = tokio::io::duplex(64 * 1024); + let (client, server) = tokio::try_join!( + qmux::Session::connect(stream(client), config()), + qmux::Session::accept(stream(server), config()) + ) + .unwrap(); + let now = moq_net::time::Instant::now(); + let client = async { + let (session, driver) = moq_net::Client::new() + .with_versions(versions.clone()) + .connect(now, client) + .await + .unwrap(); + tokio::spawn(moq_net::time::run(driver)); + session + }; + let server = async { + let request = moq_net::Server::new() + .with_versions(versions.clone()) + .accept_request(now, server) + .await + .unwrap(); + let (session, driver) = request.ok().await.unwrap(); + tokio::spawn(moq_net::time::run(driver)); + session + }; + let (client, server) = tokio::join!(client, server); + match flap { + Flap::Close => drop(server), + Flap::Goaway(uri) => { + let goaway = match uri.is_empty() { + true => moq_net::goaway::Goaway::new(), + false => moq_net::goaway::Goaway::redirect(uri), + }; + server.drain().send(goaway).unwrap(); + tokio::spawn(async move { + server.closed().await; + }); + } + } + client + } + + /// Check each gap between consecutive sessions against the jittered delay it should + /// have waited: between half of `delay` and all of it, starting at `initial` and + /// doubling up to `max`. + #[cfg(feature = "tcp")] + fn assert_paced(accepts: &[(Url, tokio::time::Instant)], initial: Duration, max: Duration) { + let mut delay = initial; + for (index, pair) in accepts.windows(2).enumerate() { + let gap = pair[1].1 - pair[0].1; + assert!( + gap >= delay / 2 && gap <= delay, + "retry {index} waited {gap:?}, outside the jittered {delay:?}" + ); + delay = (delay * 2).min(max); + } + } + + #[cfg(feature = "tcp")] + const INITIAL: Duration = Duration::from_millis(100); + #[cfg(feature = "tcp")] + const MAX: Duration = Duration::from_millis(400); + + /// A peer that accepts and then closes every session at once never earns the healthy + /// reset: the delay escalates to the cap, and the timeout ends the loop. + #[cfg(feature = "tcp")] + #[tokio::test(start_paused = true)] + async fn an_immediate_close_loop_escalates_until_the_timeout() { + const TIMEOUT: Duration = Duration::from_secs(2); + let peer = url("tcp://peer:1/"); + let (client, mut accepted) = flapping(INITIAL, MAX, TIMEOUT, vec![(peer.clone(), Flap::Close)]); + + let started = tokio::time::Instant::now(); + let err = client + .connect(peer) + .closed() + .await + .expect_err("a flapping peer kept the connection"); + assert!(matches!(err, Error::Reconnect(_)), "{err}"); + assert_eq!(started.elapsed(), TIMEOUT, "the timeout did not end the loop"); + + let mut accepts = Vec::new(); + while let Ok(accept) = accepted.try_recv() { + accepts.push(accept); + } + assert!(accepts.len() >= 6, "only {} sessions", accepts.len()); + assert_paced(&accepts, INITIAL, MAX); + } + + /// A peer restarting with an empty-URI GOAWAY on every session paces the redials like + /// failures, yet never spends the timeout: each drained session still served. + #[cfg(feature = "tcp")] + #[tokio::test(start_paused = true)] + async fn an_immediate_restart_loop_escalates_without_giving_up() { + const TIMEOUT: Duration = Duration::from_millis(500); + let peer = url("tcp://peer:1/"); + let (client, mut accepted) = flapping(INITIAL, MAX, TIMEOUT, vec![(peer.clone(), Flap::Goaway(String::new()))]); + let connection = client.connect(peer); + + let mut accepts = Vec::new(); + for _ in 0..10 { + accepts.push(accepted.recv().await.expect("the dialer dropped")); + } + assert!( + accepts[9].1 - accepts[0].1 > TIMEOUT * 2, + "the redials outran the backoff" + ); + assert!( + futures::FutureExt::now_or_never(connection.closed()).is_none(), + "a restarting peer ended the connection" + ); + assert_paced(&accepts, INITIAL, MAX); + } + + /// Two peers that redirect to each other the moment a session is up are failed attempts: + /// the delay escalates across both, and the timeout ends the loop. + #[cfg(feature = "tcp")] + #[tokio::test(start_paused = true)] + async fn an_immediate_redirect_loop_escalates_until_the_timeout() { + const TIMEOUT: Duration = Duration::from_secs(2); + let a = url("tcp://peer:1/"); + let b = url("tcp://peer:2/"); + let peers = vec![ + (a.clone(), Flap::Goaway(b.to_string())), + (b.clone(), Flap::Goaway(a.to_string())), + ]; + let (client, mut accepted) = flapping(INITIAL, MAX, TIMEOUT, peers); + + let started = tokio::time::Instant::now(); + let err = client + .connect(a.clone()) + .closed() + .await + .expect_err("a redirect loop kept the connection"); + assert!( + matches!(&err, Error::Reconnect(reason) if reason.contains("redirect")), + "{err}" + ); + assert_eq!(started.elapsed(), TIMEOUT, "the timeout did not end the loop"); + + let mut accepts = Vec::new(); + while let Ok(accept) = accepted.try_recv() { + accepts.push(accept); + } + assert!(accepts.len() >= 6, "only {} sessions", accepts.len()); + for (index, (peer, _)) in accepts.iter().enumerate() { + assert_eq!( + *peer, + if index % 2 == 0 { a.clone() } else { b.clone() }, + "session {index}" + ); + } + assert_paced(&accepts, INITIAL, MAX); + } + + /// A zero `initial` turns retry waiting off: every redial follows the close at once. + #[cfg(feature = "tcp")] + #[tokio::test(start_paused = true)] + async fn a_zero_initial_redials_at_once() { + const TIMEOUT: Duration = Duration::from_secs(1); + let peer = url("tcp://peer:1/"); + let (client, mut accepted) = flapping(Duration::ZERO, MAX, TIMEOUT, vec![(peer.clone(), Flap::Close)]); + let connection = client.connect(peer); + + let first = accepted.recv().await.expect("the dialer dropped").1; + for _ in 0..10 { + let at = accepted.recv().await.expect("the dialer dropped").1; + assert_eq!(at, first, "a zero initial waited before a retry"); + } + drop(connection); + } + #[test] fn redirect_loop_detection_catches_any_cycle_in_constant_memory() { for tail in 0..4 { @@ -3442,23 +3687,31 @@ mod duration_tests { Backoff, initial, "--connect-backoff-initial", - |c: &Backoff| Pacing::new(c).initial, - |d: Duration| d.max(MIN_BACKOFF) + |c: &Backoff| c.initial(), + |d| d ); crate::cli::duration::test_config_duration!( max_set_in_code_beats_parser_default, Backoff, max, "--connect-backoff-max", - |c: &Backoff| Pacing::new(c).max, - |d: Duration| d.max(DEFAULT_INITIAL) + |c: &Backoff| c.max(), + |d| d + ); + crate::cli::duration::test_config_duration!( + healthy_set_in_code_beats_parser_default, + Backoff, + healthy, + "--connect-backoff-healthy", + |c: &Backoff| c.healthy(), + |d| d ); crate::cli::duration::test_config_duration!( timeout_set_in_code_beats_parser_default, Backoff, timeout, "--connect-backoff-timeout", - |c: &Backoff| c.window(), + |c: &Backoff| c.timeout(), |d| d ); crate::cli::duration::test_config_duration!( diff --git a/rs/moq-tokio/src/error.rs b/rs/moq-tokio/src/error.rs index 00d63dc10e2..04b5acc37e8 100644 --- a/rs/moq-tokio/src/error.rs +++ b/rs/moq-tokio/src/error.rs @@ -101,10 +101,14 @@ pub enum Error { #[error("qlog capture requires the 'qlog' feature")] QlogUnsupported, - /// Key logging was configured, but `SSLKEYLOGFILE` names no file to write to. - #[error("quic.keylog requires SSLKEYLOGFILE to name a file")] + /// Key logging was enabled without a destination, including a boolean binding's missing env path. + #[error("quic.keylog needs a nonempty path; boolean keylog settings require SSLKEYLOGFILE")] KeylogUnset, + /// A zero `connect.backoff.healthy`, which would count every session as healthy. + #[error("connect.backoff.healthy must be non-zero")] + BackoffHealthy, + /// The config was parsed from released spellings that no longer work. The /// payload is the migration to print. #[error("{0}")] @@ -283,8 +287,9 @@ impl Error { | Self::WindowRange(_) | Self::ReceiveWindowTooSmall(_) | Self::StreamsRange - | Self::QlogUnsupported | Self::KeylogUnset + | Self::BackoffHealthy + | Self::QlogUnsupported | Self::Connect(_) => true, #[cfg(feature = "quic")] Self::Quic(err) => err.is_terminal(), diff --git a/rs/moq-tokio/src/iroh.rs b/rs/moq-tokio/src/iroh.rs index 0f0ca3c9268..340681b9dbb 100644 --- a/rs/moq-tokio/src/iroh.rs +++ b/rs/moq-tokio/src/iroh.rs @@ -262,6 +262,16 @@ impl Config { quic.validate().map_err(Error::Config)?; let quic = quic.resolve(); + if quic.keep_alive.is_none() { + return Err(Error::Config(crate::Error::NoBackend( + "Iroh cannot disable keep-alive; omit quic.keep_alive or set a positive interval", + ))); + } + if quic.keylog.is_some() { + return Err(Error::Config(crate::Error::NoBackend( + "Iroh does not support an explicit quic.keylog path", + ))); + } if quic.gso_disabled() { return Err(Error::GsoUnsupported); } @@ -294,7 +304,6 @@ impl Config { Endpoint::builder(iroh::endpoint::presets::N0) } .secret_key(secret_key) - .keylog(quic.keylog) .transport_config(transport.build()); if let Some(addr) = self.bind_v4 { builder = builder.bind_addr(addr)?; @@ -313,9 +322,9 @@ impl Config { /// The per-connection transport config, shared by both roles. /// /// `keep_alive` is the value [`crate::quic::Config::resolve`] already produced: -/// `Some` is the interval, and `None` (`0s`) means disabled. iroh 1.2's setter +/// `Some` is the interval, and `None` (`0s`) means disabled. iroh 1.4's setter /// only stores `Some`, and the builder starts at its own 5s heartbeat, so a -/// disabled interval cannot be written. A zero duration would rearm the timer +/// disabled interval is refused at bind. A zero duration would rearm the timer /// for immediately and spin, so it is not passed. iroh's separate per-path /// heartbeat stays at the 5s it ships with: that setter ignores anything longer /// and cannot clear it either. @@ -508,6 +517,34 @@ fn url_set_scheme(url: Url, scheme: &str) -> Result { mod tests { use super::*; + #[tokio::test] + async fn zero_keep_alive_is_refused_only_when_iroh_is_enabled() { + let mut config = Config::default(); + let quic = crate::quic::Config { + keep_alive: Some(std::time::Duration::ZERO.into()), + ..Default::default() + }; + assert!(config.clone().bind(&quic).await.unwrap().is_none()); + config.enabled = Some(true); + let err = config.bind(&quic).await.unwrap_err(); + assert!( + matches!(err, Error::Config(crate::Error::NoBackend(reason)) if reason.contains("cannot disable keep-alive")) + ); + } + + #[tokio::test] + async fn explicit_keylog_path_is_refused_only_when_iroh_is_enabled() { + let mut config = Config::default(); + let quic = crate::quic::Config { + keylog: Some("keys.log".into()), + ..Default::default() + }; + assert!(config.clone().bind(&quic).await.unwrap().is_none()); + config.enabled = Some(true); + let err = config.bind(&quic).await.unwrap_err(); + assert!(matches!(err, Error::Config(crate::Error::NoBackend(reason)) if reason.contains("keylog path"))); + } + /// A public builder taking a config this crate did not check, so it refuses /// before it touches the key file or a socket. /// diff --git a/rs/moq-tokio/src/listen.rs b/rs/moq-tokio/src/listen.rs index 38de4057d56..f1fb920d62d 100644 --- a/rs/moq-tokio/src/listen.rs +++ b/rs/moq-tokio/src/listen.rs @@ -81,7 +81,7 @@ pub(crate) const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::fro /// /// Derives [`usage_rs::Args`], so flatten it into a binary's own parser with /// `#[usage(flatten)]`. The dial side is [`crate::connect::Config`]. -#[derive(usage_rs::Args, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[derive(usage_rs::Args, Clone, Debug, Default, serde::Serialize, serde::Deserialize)] #[usage(unknown_flags = "error", args_override_self = false)] #[serde(deny_unknown_fields, default)] #[non_exhaustive] @@ -164,27 +164,21 @@ pub struct Config { /// Maximum time for one accepted connection to finish its handshake: the /// QUIC, WebTransport, WebSocket, or qmux one, then the MoQ SETUP, whatever the - /// server runs under [`crate::server::Request::within`] (admission, say), through - /// [`crate::server::Request::ok`]. Defaults to 10 seconds; set to 0 to wait forever. + /// server runs under (admission, say), until the session is accepted. Defaults to + /// 10 seconds; set to 0 to wait forever. /// /// A peer that connects and then never speaks would otherwise hold its /// connection open indefinitely, since keep-alives count as activity. The - /// connection is closed with a timeout code once this passes. See - /// [`resolved_timeout`](Self::resolved_timeout) for the value in effect. - /// `None` uses the default; an explicit command-line flag or environment variable wins. - #[usage(skip)] - #[serde(default, with = "humantime_serde", skip_serializing_if = "Option::is_none")] - pub timeout: Option, - - /// Maximum time for an accepted connection to finish its handshake. Defaults to 10s; 0s waits forever. + /// connection is closed with a timeout code once this passes. + /// `None` uses the default; zero disables this duration. #[usage( name = "listen-timeout", long = "listen-timeout", env = "MOQ_LISTEN_TIMEOUT", setting = "listen.timeout" )] - #[serde(default, rename = "__cli_timeout", skip_serializing_if = "Option::is_none")] - pub(crate) timeout_arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout: Option, /// The certificates to serve and the roots that authenticate mTLS clients /// (`--listen-tls-*`). @@ -219,43 +213,10 @@ pub struct Config { #[serde(default, skip_serializing_if = "Option::is_none")] pub preferred_v6: Option, - /// Server ID to embed in connection IDs for QUIC-LB compatibility. - /// If set, connection IDs will be derived semi-deterministically. - #[usage( - name = "listen-quic-lb-id", - long = "listen-quic-lb-id", - env = "MOQ_LISTEN_QUIC_LB_ID", - setting = "listen.lb_id" - )] - #[serde( - default, - rename = "__cli_lb_id", - alias = "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. - #[usage( - name = "listen-quic-lb-nonce", - long = "listen-quic-lb-nonce", - env = "MOQ_LISTEN_QUIC_LB_NONCE", - setting = "listen.lb_nonce", - requires = "--listen-quic-lb-id" - )] - #[serde( - default, - rename = "__cli_lb_nonce", - alias = "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 load_balancer: Option, + /// QUIC-LB connection-ID encoding (`[listen.lb]`). + #[usage(flatten)] + #[serde(default)] + pub lb: LoadBalancer, /// QUIC stateless resets (`--listen-reset-*`). #[usage(flatten)] @@ -277,34 +238,7 @@ pub struct Config { /// migrate to. #[usage(skip)] #[serde(default, skip_serializing_if = "Option::is_none")] - pub quic: Option, -} - -impl Default for Config { - fn default() -> Self { - Self { - bind: None, - listen: None, - #[cfg(feature = "tcp")] - tcp: Default::default(), - #[cfg(all(feature = "uds", unix))] - unix: Default::default(), - version: Vec::new(), - extensions: Default::default(), - implementation: None, - timeout: Some(DEFAULT_TIMEOUT), - timeout_arg: None, - tls: Default::default(), - preferred_v4: None, - preferred_v6: None, - lb_id: None, - lb_nonce: None, - load_balancer: None, - reset: Default::default(), - legacy: Default::default(), - quic: None, - } - } + pub(crate) quic: Option, } /// QUIC stateless reset settings (`[listen.reset]`, `--listen-reset-*`). @@ -491,14 +425,7 @@ impl Config { "--listen-unix-allow-* requires --listen-unix-bind", )); } - // A nonce with no server id used to be dropped, and `lb_id` used to hide - // `load_balancer`. Either one is a setting the process would not run. - if self.load_balancer.is_some() && (self.lb_id.is_some() || self.lb_nonce.is_some()) { - return Err(crate::Error::NoBackend( - "load_balancer cannot be combined with lb_id or lb_nonce", - )); - } - if self.lb_nonce.is_some() && self.lb_id.is_none() { + if self.lb.nonce.is_some() && self.lb.id.is_none() { return Err(crate::Error::NoBackend( "--listen-quic-lb-nonce requires --listen-quic-lb-id", )); @@ -544,8 +471,8 @@ impl Config { "--listen-preferred-v6 needs a QUIC listener (--listen)", ), ( - self.lb_id.is_some() || self.load_balancer.is_some(), - "--listen-quic-lb-id (load_balancer) needs a QUIC listener (--listen)", + self.lb.id.is_some(), + "--listen-quic-lb-id needs a QUIC listener (--listen)", ), ( self.reset.secret.is_some(), @@ -561,24 +488,17 @@ impl Config { /// How long an accepted connection has to finish its handshake, or `None` to wait /// forever: for zero, and for a timeout too long for the clock to reach. pub fn resolved_timeout(&self) -> Option { - let timeout = self - .timeout_arg - .map(Into::into) - .or(self.timeout) - .unwrap_or(DEFAULT_TIMEOUT); + let timeout = self.timeout.map(Into::into).unwrap_or(DEFAULT_TIMEOUT); (!timeout.is_zero() && moq_clock::now().checked_add(timeout).is_some()).then_some(timeout) } #[cfg(feature = "quic")] /// Return the effective QUIC-LB connection-ID encoding. - pub 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()) + pub(crate) fn load_balancer(&self) -> Option { + self.lb.id.clone().map(|id| crate::quic::LoadBalancer { + id, + nonce: self.lb.nonce.unwrap_or(8), + }) } } @@ -647,7 +567,7 @@ mod tests { assert_eq!(config.bind, None); assert!(config.version.is_empty()); assert_eq!(config.preferred_v4, None); - assert!(config.lb_id.is_none()); + assert!(config.lb.id.is_none()); assert!(config.tls.cert.is_empty()); assert!(config.tls.root.is_empty()); @@ -723,48 +643,24 @@ mod tests { ); } - /// A nonce with no server id is refused at startup, from either TOML spelling, - /// instead of being dropped. #[test] fn quic_lb_nonce_without_id_refuses_to_start() { - for toml in ["lb_nonce = 8", "__cli_lb_nonce = 8"] { - let config: Config = toml::from_str(toml).unwrap_or_else(|err| panic!("{toml}: {err}")); - match config.init(Default::default()) { - Err(crate::Error::NoBackend(reason)) => { - assert!(reason.contains("requires --listen-quic-lb-id"), "{toml}: {reason}") - } - Err(err) => panic!("{toml}: {err}"), - Ok(_) => panic!("{toml} was ignored"), - } - } + let config: Config = toml::from_str("[lb]\nnonce = 8").unwrap(); + assert!( + matches!(config.validate(), Err(crate::Error::NoBackend(reason)) if reason.contains("requires --listen-quic-lb-id")) + ); } - /// `lb_id` must not hide a `load_balancer` value. One source, or startup stops. #[test] - fn quic_lb_id_and_load_balancer_cannot_both_be_set() { - let both = |toml: &str| { - let config: Config = toml::from_str(toml).unwrap_or_else(|err| panic!("{toml}: {err}")); - match config.init(Default::default()) { - Err(crate::Error::NoBackend(reason)) => { - assert!(reason.contains("cannot be combined"), "{toml}: {reason}") - } - Err(err) => panic!("{toml}: {err}"), - Ok(_) => panic!("{toml} let lb_id win"), - } - }; - both( - r#" -lb_id = "ab" -load_balancer = { id = "cd", nonce = 4 } -"#, - ); - both( - r#" -__cli_lb_id = "ab" -lb_nonce = 9 -load_balancer = { id = "cd", nonce = 4 } -"#, - ); + fn removed_load_balancer_paths_are_refused() { + for body in [ + "lb_id = \"ab\"", + "lb_nonce = 8", + "__cli_lb_id = \"ab\"", + "load_balancer = { id = \"ab\", nonce = 8 }", + ] { + assert!(toml::from_str::(body).is_err(), "{body}"); + } } /// Programmatic configuration keeps the QUIC-LB id and nonce paired. @@ -775,7 +671,9 @@ load_balancer = { id = "cd", nonce = 4 } let config: Config = toml::from_str( r#" -load_balancer = { id = "ab", nonce = 8 } +[lb] +id = "ab" +nonce = 8 "#, ) .unwrap(); @@ -814,7 +712,7 @@ load_balancer = { id = "ab", nonce = 8 } ..Default::default() }; type Set = fn(&mut Config); - let cases: [(&str, Set); 8] = [ + let cases: [(&str, Set); 7] = [ ("--listen-tls-cert", |c| c.tls.cert = vec!["cert.pem".into()]), ("--listen-tls-key", |c| c.tls.key = vec!["key.pem".into()]), ("--listen-tls-generate", |c| c.tls.generate = vec!["localhost".into()]), @@ -824,13 +722,7 @@ load_balancer = { id = "ab", nonce = 8 } ("--listen-preferred-v6", |c| { c.preferred_v6 = Some("[2001:db8::1]:443".parse().unwrap()) }), - ("--listen-quic-lb-id", |c| c.lb_id = Some("ab".parse().unwrap())), - ("--listen-quic-lb-id (load_balancer)", |c| { - c.load_balancer = Some(crate::quic::LoadBalancer { - id: "ab".parse().unwrap(), - nonce: 8, - }) - }), + ("--listen-quic-lb-id", |c| c.lb.id = Some("ab".parse().unwrap())), ("--listen-reset-secret", |c| c.reset.secret = Some("reset.key".into())), ]; for (name, set) in cases { @@ -897,7 +789,7 @@ load_balancer = { id = "ab", nonce = 8 } // Past the clock's range is forever, not a panic on the first accept. let config = Config { - timeout: Some(std::time::Duration::MAX), + timeout: Some((std::time::Duration::MAX).into()), ..Default::default() }; assert_eq!(config.resolved_timeout(), None); @@ -916,3 +808,31 @@ mod duration_tests { |d: std::time::Duration| (!d.is_zero()).then_some(d) ); } + +/// QUIC-LB settings for a listener, unset until a server ID is supplied. +#[derive(Clone, Debug, Default, usage_rs::Args, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LoadBalancer { + /// Server ID to embed in connection IDs for QUIC-LB compatibility. + /// If set, connection IDs will be derived semi-deterministically. + #[usage( + name = "listen-quic-lb-id", + long = "listen-quic-lb-id", + env = "MOQ_LISTEN_QUIC_LB_ID", + setting = "listen.lb.id" + )] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub 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. + #[usage( + name = "listen-quic-lb-nonce", + long = "listen-quic-lb-nonce", + env = "MOQ_LISTEN_QUIC_LB_NONCE", + setting = "listen.lb.nonce", + requires = "--listen-quic-lb-id" + )] + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nonce: Option, +} diff --git a/rs/moq-tokio/src/quic.rs b/rs/moq-tokio/src/quic.rs index eb292046502..03f4c72389b 100644 --- a/rs/moq-tokio/src/quic.rs +++ b/rs/moq-tokio/src/quic.rs @@ -17,9 +17,9 @@ use crate::cli::Duration as CliDuration; #[cfg(feature = "quic")] mod backend; #[cfg(feature = "quic")] -pub(crate) use backend::{Client, Closer, Options, Server, accept}; +pub use backend::Error; #[cfg(feature = "quic")] -pub use backend::{Connection, Error}; +pub(crate) use backend::{Client, Closer, Options, Server, accept}; /// The flow-control limits a live QUIC connection can change; see [`driver::Connection::set_limits`]. #[cfg(feature = "driver")] pub use moq_quic::{Limits, LimitsError}; @@ -66,10 +66,10 @@ 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 { +/// Resolved QUIC-LB connection-ID encoding. +#[cfg(feature = "quic")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct LoadBalancer { /// The routable server identifier encoded into each connection ID. pub id: ServerId, /// Number of random nonce bytes appended to each connection ID. @@ -144,7 +144,7 @@ pub(crate) const DEFAULT_IDLE_TIMEOUT: Duration = Duration::from_secs(10); pub(crate) const DEFAULT_KEEP_ALIVE: Duration = Duration::from_secs(3); /// The `--quic-*` transport section, applied to dialed and accepted connections alike. -#[derive(Clone, Debug, usage_rs::Args, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, Default, usage_rs::Args, serde::Serialize, serde::Deserialize)] #[usage(unknown_flags = "error", args_override_self = false)] #[serde(deny_unknown_fields, default)] #[non_exhaustive] @@ -181,40 +181,31 @@ pub struct Config { /// Idle timeout before an inactive connection is dropped. Defaults to 10s. /// /// QUIC uses the smaller of both ends' timeouts, so either side can shorten it. - /// `None` uses the default; an explicit command-line flag or environment variable wins. - #[usage(skip)] - #[serde(default, with = "humantime_serde", skip_serializing_if = "Option::is_none")] - pub idle_timeout: Option, - - /// Idle timeout before an inactive connection is dropped. Defaults to 10s. + /// `None` uses the default; zero disables this duration. #[usage( name = "quic-idle-timeout", long = "quic-idle-timeout", env = "MOQ_QUIC_IDLE_TIMEOUT", setting = "quic.idle_timeout" )] - #[serde(default, rename = "__cli_idle_timeout", skip_serializing_if = "Option::is_none")] - idle_timeout_arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idle_timeout: Option, /// Keep-alive ping interval. Defaults to 3s; set `0s` to disable. /// /// The iroh backend applies this to its connection keep-alive. It also keeps /// a 5s per-path heartbeat that its builder will not raise or clear, so any - /// interval above 5s still sends those path pings, and `0s` leaves both at 5s. - /// `None` uses the default; an explicit command-line flag or environment variable wins. - #[usage(skip)] - #[serde(default, with = "humantime_serde", skip_serializing_if = "Option::is_none")] - pub keep_alive: Option, - - /// Keep-alive ping interval. Defaults to 3s; 0s disables it. + /// interval above 5s still sends those path pings. Explicit `0s` is refused + /// by Iroh because its builder cannot disable either heartbeat. + /// `None` uses the default; zero disables this duration. #[usage( name = "quic-keep-alive", long = "quic-keep-alive", env = "MOQ_QUIC_KEEP_ALIVE", setting = "quic.keep_alive" )] - #[serde(default, rename = "__cli_keep_alive", skip_serializing_if = "Option::is_none")] - keep_alive_arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keep_alive: Option, /// Enable path MTU discovery. Defaults to off. #[serde(skip_serializing_if = "Option::is_none")] @@ -296,21 +287,20 @@ pub struct Config { #[usage(value_hint = usage_rs::ValueHint::DirPath)] pub qlog: Option, - /// Danger: write TLS session secrets to the file `SSLKEYLOGFILE` names. Defaults to off. + /// Danger: append TLS session secrets to this private file, for debugging. /// - /// Anyone holding that file can decrypt captured traffic, so keep it to debugging. - /// Setting it without `SSLKEYLOGFILE` errors at init. + /// Anyone holding this file can decrypt captured traffic. The native QUIC backend + /// opens it at initialization; Iroh refuses this setting because its API only supports + /// process-wide environment-based logging. #[serde(skip_serializing_if = "Option::is_none")] #[usage( name = "quic-keylog", long = "quic-keylog", env = "MOQ_QUIC_KEYLOG", - setting = "quic.keylog", - default_missing = "true", - num_args = 0..=1, - require_equals = true, + setting = "quic.keylog" )] - pub keylog: Option, + #[usage(value_hint = usage_rs::ValueHint::FilePath)] + pub keylog: Option, /// The old role-prefixed spellings, kept parsing but hidden. Never read as /// settings: [`Config::deprecated`] names what replaced each one so a process @@ -321,27 +311,6 @@ pub struct Config { pub(crate) legacy: Legacy, } -impl Default for Config { - fn default() -> Self { - Self { - max_streams: None, - gso: None, - idle_timeout: Some(DEFAULT_IDLE_TIMEOUT), - idle_timeout_arg: None, - keep_alive: Some(DEFAULT_KEEP_ALIVE), - keep_alive_arg: None, - mtu_discovery: None, - congestion_control: None, - receive_window: None, - stream_receive_window: None, - send_window: None, - qlog: None, - keylog: None, - legacy: Default::default(), - } - } -} - /// The hidden `--client-quic-*` / `--server-quic-*` spellings (and their env /// vars), from when the endpoint config was split by role. #[derive(Clone, Debug, Default, usage_rs::Args)] @@ -568,13 +537,15 @@ impl Config { /// advertise. Called when the endpoint is built. #[cfg(feature = "_transport")] pub fn validate(&self) -> crate::Result<()> { + if self.keylog.as_ref().is_some_and(|path| path.as_os_str().is_empty()) { + return Err(crate::Error::KeylogUnset); + } // Erroring beats silently ignoring the flag: the operator asked for traces and // would otherwise go looking for files that were never going to appear. match self.qlog.as_ref() { Some(_) if cfg!(not(feature = "qlog")) => Err(crate::Error::QlogUnsupported), _ => Ok(()), }?; - validate_keylog(self.keylog, std::env::var_os("SSLKEYLOGFILE").as_deref())?; validate_idle_timeout(Some(self.resolve().idle_timeout))?; validate_streams(self.max_streams)?; validate_windows(self) @@ -588,16 +559,8 @@ impl Config { pub fn resolve(&self) -> Resolved { // A zero keep-alive means "disabled"; anything else (including unset) keeps // the connection warm, defaulting to 3s. - let idle_timeout = self - .idle_timeout_arg - .map(Into::into) - .or(self.idle_timeout) - .unwrap_or(DEFAULT_IDLE_TIMEOUT); - let keep_alive = self - .keep_alive_arg - .map(Into::into) - .or(self.keep_alive) - .unwrap_or(DEFAULT_KEEP_ALIVE); + let idle_timeout = self.idle_timeout.map(Into::into).unwrap_or(DEFAULT_IDLE_TIMEOUT); + let keep_alive = self.keep_alive.map(Into::into).unwrap_or(DEFAULT_KEEP_ALIVE); let keep_alive = (!keep_alive.is_zero()).then_some(keep_alive); Resolved { @@ -611,23 +574,11 @@ impl Config { stream_receive_window: self.stream_receive_window.unwrap_or(DEFAULT_STREAM_RECEIVE_WINDOW), send_window: self.send_window.unwrap_or(DEFAULT_SEND_WINDOW), qlog: self.qlog.clone(), - keylog: self.keylog.unwrap_or(false), + keylog: self.keylog.clone(), } } } -/// Reject a key log with no file to write to, given the file `SSLKEYLOGFILE` names. -/// -/// rustls logs nothing without a path, which would hide the mistake the way an -/// ignored qlog directory would. -#[cfg(feature = "_transport")] -fn validate_keylog(keylog: Option, file: Option<&std::ffi::OsStr>) -> crate::Result<()> { - if keylog == Some(true) && file.is_none_or(std::ffi::OsStr::is_empty) { - return Err(crate::Error::KeylogUnset); - } - Ok(()) -} - /// QUIC carries `max_idle_timeout` as a varint of milliseconds, so anything past this /// can't go on the wire. #[cfg(feature = "_transport")] @@ -737,8 +688,8 @@ pub struct Resolved { pub send_window: u64, /// Directory to write qlog traces into, or `None` to not capture them. pub qlog: Option, - /// Whether to write TLS session secrets to `SSLKEYLOGFILE`. - pub keylog: bool, + /// The explicit file to append TLS session secrets to, or `None` to disable logging. + pub keylog: Option, } impl Resolved { @@ -796,6 +747,8 @@ mod tests { #[test] fn defaults_apply_when_unset() { + assert!(Config::default().idle_timeout.is_none()); + assert!(Config::default().keep_alive.is_none()); let quic = Config::default().resolve(); assert_eq!(quic.max_streams, DEFAULT_MAX_STREAMS); assert_eq!(quic.idle_timeout, DEFAULT_IDLE_TIMEOUT); @@ -803,19 +756,19 @@ mod tests { assert!(!quic.mtu_discovery); assert_eq!(quic.gso, None); assert!(!quic.gso_disabled()); - assert!(!quic.keylog); + assert!(quic.keylog.is_none()); } #[test] fn zero_keep_alive_disables_it() { let disabled = Config { - keep_alive: Some(Duration::ZERO), + keep_alive: Some((Duration::ZERO).into()), ..Default::default() }; assert_eq!(disabled.resolve().keep_alive, None); let explicit = Config { - keep_alive: Some(Duration::from_secs(2)), + keep_alive: Some((Duration::from_secs(2)).into()), ..Default::default() }; assert_eq!(explicit.resolve().keep_alive, Some(Duration::from_secs(2))); @@ -846,12 +799,13 @@ mod tests { "--quic-qlog", "/tmp/qlog", "--quic-keylog", + "/tmp/keys.log", ]); assert_eq!(quic.max_streams, Some(5000)); assert_eq!(quic.gso, Some(false)); assert_eq!(quic.congestion_control, Some(CongestionControl::Delay)); assert_eq!(quic.qlog.as_deref(), Some(std::path::Path::new("/tmp/qlog"))); - assert_eq!(quic.keylog, Some(true)); + assert_eq!(quic.keylog.as_deref(), Some(std::path::Path::new("/tmp/keys.log"))); } /// `none` is spelled the same on the CLI, in TOML, and through `FromStr`. @@ -963,43 +917,25 @@ mod tests { } } - /// Asking for a key log nobody can read back fails instead of logging nothing. - #[cfg(feature = "_transport")] - #[test] - fn keylog_requires_a_path() { - let file = Some(std::ffi::OsStr::new("/tmp/keys.log")); - let empty = Some(std::ffi::OsStr::new("")); - for keylog in [None, Some(false)] { - assert!(validate_keylog(keylog, None).is_ok()); - } - for unset in [None, empty] { - assert!(matches!( - validate_keylog(Some(true), unset), - Err(crate::Error::KeylogUnset) - )); - } - assert!(validate_keylog(Some(true), file).is_ok()); - } - /// The backends convert this duration with an infallible-by-panic `expect`, and the /// value arrives from a TOML file or a C caller, so validation has to catch it here. #[cfg(feature = "_transport")] #[test] fn idle_timeout_beyond_the_varint_is_rejected() { let over = Config { - idle_timeout: Some(MAX_IDLE_TIMEOUT + Duration::from_millis(1)), + idle_timeout: Some((MAX_IDLE_TIMEOUT + Duration::from_millis(1)).into()), ..Default::default() }; assert!(matches!(over.validate(), Err(crate::Error::IdleTimeoutRange))); let saturated = Config { - idle_timeout: Some(Duration::from_millis(u64::MAX)), + idle_timeout: Some((Duration::from_millis(u64::MAX)).into()), ..Default::default() }; assert!(matches!(saturated.validate(), Err(crate::Error::IdleTimeoutRange))); let at_limit = Config { - idle_timeout: Some(MAX_IDLE_TIMEOUT), + idle_timeout: Some((MAX_IDLE_TIMEOUT).into()), ..Default::default() }; assert!(at_limit.validate().is_ok()); diff --git a/rs/moq-tokio/src/quic/backend.rs b/rs/moq-tokio/src/quic/backend.rs index 6f5356ae217..b4c06c95194 100644 --- a/rs/moq-tokio/src/quic/backend.rs +++ b/rs/moq-tokio/src/quic/backend.rs @@ -92,22 +92,6 @@ fn congestion_factory(family: CongestionControl) -> Arc Self { - Self(conn) - } - - /// Change the flow-control limits `limits` sets; a raise reaches the peer on the next packet. - /// - /// Errors, changing nothing, on limits [`Limits::validate`](crate::quic::Limits::validate) refuses. - pub fn set_limits(&self, limits: crate::quic::Limits) -> Result<()> { - Ok(self.0.set_limits(limits)?) - } -} - /// Errors specific to the QUIC backend. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -331,8 +315,8 @@ pub(crate) struct Client { /// whether an IPv4 destination is reachable at all. Captured here because the /// endpoint owns the socket from here on and `local_addr` can't tell us. dual_stack: bool, - /// Whether to write session secrets to `SSLKEYLOGFILE` (see [`crate::quic::Config::keylog`]). - keylog: bool, + /// A private log opened before this client can start a handshake. + keylog: Option>, } impl Client { @@ -346,7 +330,12 @@ impl Client { apply_transport(&mut transport, &quic); apply_qlog(&mut transport, &quic, "client")?; let transport = Arc::new(transport); - let keylog = quic.keylog; + let keylog = quic + .keylog + .as_deref() + .map(crate::tls::KeyLog::open) + .transpose()? + .map(Arc::new); // There's a bit more boilerplate to make a generic endpoint. // The driver spawns its tasks on the current tokio runtime. @@ -455,8 +444,8 @@ impl Client { }; config.alpn_protocols = alpns; - if self.keylog { - config.key_log = Arc::new(rustls::KeyLogFile::new()); + if let Some(keylog) = &self.keylog { + config.key_log = keylog.clone(); } let config: driver::crypto::rustls::QuicClientConfig = config.try_into()?; @@ -776,7 +765,12 @@ impl Server { let quic = quic.resolve(); apply_transport(&mut transport, &quic); apply_qlog(&mut transport, &quic, "server")?; - let keylog = quic.keylog; + let keylog = quic + .keylog + .as_deref() + .map(crate::tls::KeyLog::open) + .transpose()? + .map(Arc::new); let transport = Arc::new(transport); // H3 is last because it requires WebTransport framing which not all H3 endpoints support. @@ -789,8 +783,8 @@ impl Server { alpns.push(web::ALPN.as_bytes().to_vec()); let mut tls = (*served.config(alpns)?).clone(); - if keylog { - tls.key_log = Arc::new(rustls::KeyLogFile::new()); + if let Some(keylog) = keylog { + tls.key_log = keylog; } let tls: driver::crypto::rustls::QuicServerConfig = tls.try_into()?; diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index 0175f30eb24..a358afd1195 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -741,7 +741,7 @@ impl Server { let Accepted { session, url, identity, authority, mut link } = within(deadline, crate::quic::accept(_conn, alpns)).await?; link.local = local; let binding = match url { Some(_) => moq_net::transport::Binding::Uri, None => moq_net::transport::Binding::InBand }; - let quic = Some(crate::quic::Connection::new((*session).clone())); + let quic = Some((*session).clone()); let session = crate::transport::Session::new(session, binding); let (request, deadline) = accept_request(&setup.server, session, deadline).await?; // Only WebTransport carries a request URL; raw QUIC puts the path in the SETUP. @@ -1008,8 +1008,7 @@ impl Drop for Listener { /// Complete one accepted [`Request`] and wait for the session to close. async fn serve_session(request: Request) -> crate::Result<()> { - let (session, driver) = request.ok().await?; - driver.await.map_err(|_| Error::Panic)??; + let session = request.ok().await?; Err(session.closed().await.into()) } @@ -1642,7 +1641,7 @@ pub struct Request { handshake: Box, /// The connection under a QUIC or WebTransport request, for [`Request::quic`]. #[cfg(feature = "quic")] - quic: Option, + quic: Option, /// What remains of the handshake deadline, which also bounds [`Request::ok`]. deadline: Option, } @@ -1707,7 +1706,7 @@ impl Request { /// The QUIC connection under this request, to change its limits before answering /// SETUP. `None` on a transport moq-quic does not drive (qmux streams, iroh). #[cfg(feature = "quic")] - pub fn quic(&self) -> Option<&crate::quic::Connection> { + pub fn quic(&self) -> Option<&crate::quic::driver::Connection> { self.quic.as_ref() } @@ -1722,14 +1721,14 @@ impl Request { Deadline::bound(self.deadline.as_ref(), async { Ok(work.await) }).await } - /// Accept the session and return its supervised driver task. + /// Accept the session, driving it until it closes. /// - /// Await the driver before classifying the session close so a panic cannot look like cancellation. + /// A driver panic is reported through [`Session::closed`]. /// /// Bounded by what remains of the handshake deadline: answering SETUP can wait /// on a peer that withholds flow-control credit, which keep-alives would /// otherwise hold open. - pub async fn ok(self) -> crate::Result<(Session, tokio::task::JoinHandle>)> { + pub async fn ok(self) -> crate::Result { let Request { handshake, deadline, @@ -1743,7 +1742,7 @@ impl Request { } let (session, driver) = handshake.ok().await?; use tracing::Instrument; - let driver = tokio::spawn( + tokio::spawn( async move { crate::panic::isolate(async move { let _active = active; @@ -1755,7 +1754,7 @@ impl Request { } .instrument(tracing::Span::current()), ); - Ok((session, driver)) + Ok(session) } /// Returns the network transport carrying this session. @@ -2823,7 +2822,7 @@ mod tests { let connection = client.with_reconnect(false).connect(url); let request = listener.accept().await.expect("no incoming connection"); - let (session, _driver) = request.with_publisher(&origin).ok().await.expect("server handshake"); + let session = request.with_publisher(&origin).ok().await.expect("server handshake"); listener.close().await; std::net::UdpSocket::bind(addr).expect("close must release the QUIC socket"); diff --git a/rs/moq-tokio/src/settings.rs b/rs/moq-tokio/src/settings.rs index 616ccf0744e..2c5f2d59de2 100644 --- a/rs/moq-tokio/src/settings.rs +++ b/rs/moq-tokio/src/settings.rs @@ -32,11 +32,8 @@ pub struct Listen { #[usage(env = "MOQ_LISTEN_PREFERRED_V6", cli("--listen-preferred-v6"))] preferred_v6: Option, - #[usage(env = "MOQ_LISTEN_QUIC_LB_ID", cli("--listen-quic-lb-id"))] - lb_id: Option, - - #[usage(env = "MOQ_LISTEN_QUIC_LB_NONCE", cli("--listen-quic-lb-nonce"))] - lb_nonce: Option, + #[usage(flatten)] + lb: ListenLb, #[usage(flatten)] reset: ListenReset, @@ -156,9 +153,6 @@ pub struct Connect { #[usage(env = "MOQ_CONNECT_VERSION", cli("--connect-version"), parse = "list_by_comma")] version: Option>, - #[usage(env = "MOQ_CONNECT_ONCE", cli("--connect-once"))] - once: Option, - #[usage(flatten)] tls: ConnectTls, @@ -216,6 +210,9 @@ struct ConnectBackoff { #[usage(env = "MOQ_CONNECT_BACKOFF_MAX", cli("--connect-backoff-max"))] max: Option, + #[usage(env = "MOQ_CONNECT_BACKOFF_HEALTHY", cli("--connect-backoff-healthy"))] + healthy: Option, + #[usage(env = "MOQ_CONNECT_BACKOFF_TIMEOUT", cli("--connect-backoff-timeout"))] timeout: Option, } @@ -279,7 +276,7 @@ pub struct Quic { qlog: Option, #[usage(env = "MOQ_QUIC_KEYLOG", cli("--quic-keylog"))] - keylog: Option, + keylog: Option, } /// Log settings (`[log]`, `--log-level`). @@ -312,3 +309,13 @@ pub struct Iroh { #[usage(env = "MOQ_IROH_DISABLE_RELAY", cli("--iroh-disable-relay"))] disable_relay: Option, } + +#[derive(usage_rs::Config)] +#[usage(prefix = "listen.lb")] +struct ListenLb { + #[usage(env = "MOQ_LISTEN_QUIC_LB_ID", cli("--listen-quic-lb-id"))] + id: Option, + + #[usage(env = "MOQ_LISTEN_QUIC_LB_NONCE", cli("--listen-quic-lb-nonce"))] + nonce: Option, +} diff --git a/rs/moq-tokio/src/tcp.rs b/rs/moq-tokio/src/tcp.rs index 92c15552915..355f74960a9 100644 --- a/rs/moq-tokio/src/tcp.rs +++ b/rs/moq-tokio/src/tcp.rs @@ -406,15 +406,12 @@ mod tests { assert_eq!(request.transport(), crate::Transport::Tcp); assert_eq!(request.path(), "/room"); assert_eq!(request.query(), Some("jwt=credential")); - let (session, _driver) = request.ok().await.unwrap(); + let session = request.ok().await.unwrap(); (server, session) }); let dial = |insecure: bool| { - let mut config = crate::connect::Config { - once: Some(true), - ..Default::default() - }; + let mut config = crate::connect::Config::default(); config.tls.insecure = Some(insecure); config .init(Default::default()) diff --git a/rs/moq-tokio/src/tls.rs b/rs/moq-tokio/src/tls.rs index 89b2b9f3633..395e27bd4ab 100644 --- a/rs/moq-tokio/src/tls.rs +++ b/rs/moq-tokio/src/tls.rs @@ -125,7 +125,7 @@ pub enum Error { #[error("must provide at least one cert/key pair or generate entry")] NoCertSource, - /// A [`Listen::resolver`] was combined with another certificate source, which it would hide. + /// An external resolver was combined with another certificate source, which it would hide. #[error("a certificate resolver cannot be combined with cert, key, generate, or identity")] ConflictingResolver, @@ -142,6 +142,10 @@ pub enum Error { source: Box, }, + /// The session secret log could not be opened as a private regular file. + #[error("failed to open private TLS key log")] + KeyLog(#[source] std::io::Error), + /// A rustls error with no more specific context, e.g. building a config. #[error(transparent)] Rustls(#[from] rustls::Error), @@ -1257,18 +1261,6 @@ pub struct Listen { #[serde(skip)] pub identity: Option, - /// Pick the served certificate per handshake with this instead of any other source. - /// - /// For a certificate that changes on its own schedule, such as one an ACME client - /// renews in the background: every listener sharing the identity serves the - /// resolver's current answer on its next handshake. Combining it with `cert`, - /// `key`, `generate`, or `identity` is an error. Not settable from the CLI or a - /// config file, and reported with no fingerprints by [`Serve::certificates`]. - #[cfg(feature = "_certs")] - #[usage(skip)] - #[serde(skip)] - pub resolver: Option>, - /// Require a client certificate, pinned to these fingerprints (mTLS without a CA). /// /// The peer-mesh counterpart to `root`: membership is a set of fingerprints @@ -1369,16 +1361,13 @@ impl Listen { self.refuse_deprecated()?; let provider = crypto::provider(); let certs = Arc::new(ServeCerts::new(provider.clone())); - let resolver = self.external_resolver()?; - if resolver.is_none() { - certs.load_certs(self)?; - } + certs.load_certs(self)?; let verifier = self.client_auth(provider)?; #[cfg(feature = "watch")] let reload = Reload::spawn(certs.clone(), self.clone())?; Ok(Serve(Arc::new(Serving { certs, - resolver, + resolver: None, verifier, #[cfg(feature = "watch")] _reload: reload, @@ -1392,7 +1381,6 @@ impl Listen { || !self.generate.is_empty() || !self.root.is_empty() || self.peers.is_some() - || self.resolver.is_some() || { #[cfg(any(feature = "aws-lc-rs", feature = "ring"))] { @@ -1461,22 +1449,6 @@ impl Listen { } } - /// The configured [`resolver`](Self::resolver), refused alongside any other certificate source. - #[cfg(feature = "_certs")] - fn external_resolver(&self) -> Result>> { - let Some(resolver) = &self.resolver else { - return Ok(None); - }; - #[cfg(any(feature = "aws-lc-rs", feature = "ring"))] - let in_memory = self.identity.is_some(); - #[cfg(not(any(feature = "aws-lc-rs", feature = "ring")))] - let in_memory = false; - if !self.cert.is_empty() || !self.key.is_empty() || !self.generate.is_empty() || in_memory { - return Err(Error::ConflictingResolver); - } - Ok(Some(resolver.clone())) - } - /// Refuse a section parsed from released spellings, naming what replaced each. fn refuse_deprecated(&self) -> Result<()> { let deprecated = self.deprecated(); @@ -1570,14 +1542,9 @@ impl Listen { self.refuse_deprecated()?; let provider = crypto::provider(); - let certs: Arc = match self.external_resolver()? { - Some(resolver) => resolver, - None => { - let certs = ServeCerts::new(provider.clone()); - certs.load_certs(self)?; - Arc::new(certs) - } - }; + let certs = ServeCerts::new(provider.clone()); + certs.load_certs(self)?; + let certs = Arc::new(certs); // TCP can negotiate TLS 1.2 as well as 1.3, unlike QUIC which is 1.3-only. let builder = @@ -1626,6 +1593,33 @@ impl rustls::server::ResolvesServerCert for Serving { #[cfg(feature = "_certs")] impl Serve { + /// Serve this resolver's current certificate with the listen policy's client authentication. + /// + /// Certificate sources in `policy` are refused. Roots and pinned peers retain their + /// usual verification and reload behavior. External certificates have no reported fingerprints. + pub fn resolver(resolver: Arc, policy: &Listen) -> Result { + policy.refuse_deprecated()?; + #[cfg(any(feature = "aws-lc-rs", feature = "ring"))] + let in_memory = policy.identity.is_some(); + #[cfg(not(any(feature = "aws-lc-rs", feature = "ring")))] + let in_memory = false; + if !policy.cert.is_empty() || !policy.key.is_empty() || !policy.generate.is_empty() || in_memory { + return Err(Error::ConflictingResolver); + } + let provider = crypto::provider(); + let certs = Arc::new(ServeCerts::new(provider.clone())); + let verifier = policy.client_auth(provider)?; + #[cfg(feature = "watch")] + let reload = Reload::spawn(certs.clone(), policy.clone())?; + Ok(Self(Arc::new(Serving { + certs, + resolver: Some(resolver), + verifier, + #[cfg(feature = "watch")] + _reload: reload, + }))) + } + /// The current certificate fingerprints, from the resolver every listener uses. pub fn certificates(&self) -> Certificates { Certificates::new(self.0.certs.info.clone()) @@ -2657,12 +2651,7 @@ mod tests { let identity = Identity::generate(["localhost"]).unwrap(); let resolver = Arc::new(Counting(identity.certified().rustls.clone(), Default::default())); - let serve = Listen { - resolver: Some(resolver.clone()), - ..Default::default() - } - .load() - .unwrap(); + let serve = Serve::resolver(resolver.clone(), &Listen::default()).unwrap(); assert!(serve.certificates().fingerprints().is_empty()); let client = Arc::new( Connect { @@ -2677,6 +2666,90 @@ mod tests { assert_eq!(resolver.1.load(std::sync::atomic::Ordering::Relaxed), 2); } + /// The external certificate source must retain the listener's live peer allowlist. + #[cfg(all(feature = "watch", feature = "quic"))] + #[test] + fn external_resolver_retains_live_client_auth() { + #[derive(Debug)] + struct Fixed(Arc); + impl rustls::server::ResolvesServerCert for Fixed { + fn resolve(&self, _: rustls::server::ClientHello<'_>) -> Option> { + Some(self.0.clone()) + } + } + let server = Identity::generate(["localhost"]).unwrap(); + let client = Identity::generate(["client.invalid"]).unwrap(); + let peers = Peers::new(); + let policy = Listen { + peers: Some(peers.clone()), + ..Default::default() + }; + let serve = Serve::resolver(Arc::new(Fixed(server.certified().rustls.clone())), &policy).unwrap(); + let client_config = Arc::new( + Connect { + identity: Some(client.clone()), + fingerprint: vec![server.fingerprint().to_string()], + ..Default::default() + } + .build() + .unwrap(), + ); + let tls = serve.config(Vec::new()).unwrap(); + assert!(handshake_kinds(client_config.clone(), tls.clone()).is_err()); + peers.insert(client.fingerprint()).unwrap(); + assert_eq!( + handshake_kinds(client_config.clone(), tls.clone()).unwrap().0, + rustls::HandshakeKind::Full + ); + peers.remove(client.fingerprint()).unwrap(); + assert!( + handshake_kinds(client_config, tls).is_err(), + "a removed peer must not resume" + ); + } + + /// An ACME-style resolver retains CA verification of presented client certificates. + #[cfg(all(feature = "watch", feature = "quic"))] + #[tokio::test] + async fn external_resolver_retains_client_roots() { + let (ca, server_cert, server_key, client_cert, client_key) = signed_certificates(); + let (_, _, _, other_cert, other_key) = signed_certificates(); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("root.pem"); + std::fs::write(&root, ca).unwrap(); + let provider = crypto::provider(); + let key = rustls::sign::CertifiedKey::from_der(vec![server_cert], server_key, &provider).unwrap(); + #[derive(Debug)] + struct Fixed(Arc); + impl rustls::server::ResolvesServerCert for Fixed { + fn resolve(&self, _: rustls::server::ClientHello<'_>) -> Option> { + Some(self.0.clone()) + } + } + let serve = Serve::resolver( + Arc::new(Fixed(Arc::new(key))), + &Listen { + root: vec![root], + ..Default::default() + }, + ) + .unwrap(); + let client = |cert, key| { + Arc::new( + rustls::ClientConfig::builder_with_provider(provider.clone()) + .with_safe_default_protocol_versions() + .unwrap() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoCertificateVerification(provider.clone()))) + .with_client_auth_cert(vec![cert], key) + .unwrap(), + ) + }; + let tls = serve.config(Vec::new()).unwrap(); + assert!(handshake_kinds(client(client_cert, client_key), tls.clone()).is_ok()); + assert!(handshake_kinds(client(other_cert, other_key), tls).is_err()); + } + /// A resolver would hide any other source, so combining them is refused. #[test] fn external_resolver_refuses_other_sources() { @@ -2690,12 +2763,11 @@ mod tests { } let config = Listen { generate: vec!["localhost".into()], - resolver: Some(Arc::new(Fixed(identity.certified().rustls.clone()))), ..Default::default() }; - assert!(matches!(config.load(), Err(Error::ConflictingResolver))); + let resolver = Arc::new(Fixed(identity.certified().rustls.clone())); assert!(matches!( - config.server_config(Vec::new()), + Serve::resolver(resolver, &config), Err(Error::ConflictingResolver) )); } @@ -3496,3 +3568,62 @@ mod legacy_tests { } } } + +/// An explicit TLS session secret log shared by an endpoint's handshakes. +/// +/// New files are private on Unix. Existing files accessible to group or other users +/// are refused; their permissions are never changed. Open failures stop initialization. +#[derive(Debug)] +pub struct KeyLog(std::sync::Mutex); + +impl KeyLog { + /// Open a private regular file for appending NSS-format session secrets. + pub fn open(path: &std::path::Path) -> Result { + // Refuse an existing FIFO or device before an append open could block. + if fs::metadata(path).is_ok_and(|metadata| !metadata.is_file()) { + return Err(Error::KeyLog(io::Error::new( + io::ErrorKind::InvalidInput, + "key log must be a regular file", + ))); + } + let mut options = std::fs::OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(path).map_err(Error::KeyLog)?; + let metadata = file.metadata().map_err(Error::KeyLog)?; + if !metadata.is_file() { + return Err(Error::KeyLog(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "key log must be a regular file", + ))); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + return Err(Error::KeyLog(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "key log is accessible to other users", + ))); + } + } + Ok(Self(std::sync::Mutex::new(file))) + } +} + +impl rustls::KeyLog for KeyLog { + fn log(&self, label: &str, client_random: &[u8], secret: &[u8]) { + use std::io::Write; + // Separate endpoints can open the same append path. Emit one complete record + // rather than letting formatting interleave fragments through independent handles. + let line = format!("{label} {} {}\n", hex::encode(client_random), hex::encode(secret)); + let mut file = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Err(error) = file.write_all(line.as_bytes()) { + tracing::error!(%error, "failed to write TLS key log"); + } + } +} diff --git a/rs/moq-tokio/src/webrtc/tests.rs b/rs/moq-tokio/src/webrtc/tests.rs index 6a0a041f82a..5048f97f10c 100644 --- a/rs/moq-tokio/src/webrtc/tests.rs +++ b/rs/moq-tokio/src/webrtc/tests.rs @@ -53,7 +53,7 @@ async fn transfer(offerer: &Offerer, answerer: &Answerer, payload: &[u8]) { .init() .unwrap() .with_subscriber(subscribed); - let ((_server, _driver), _client) = tokio::time::timeout(TIMEOUT, async { + let (_server, _client) = tokio::time::timeout(TIMEOUT, async { tokio::join!(server, async { client.connect_webrtc(offerer).await.unwrap() }) }) .await diff --git a/rs/moq-tokio/src/websocket.rs b/rs/moq-tokio/src/websocket.rs index 652ec06a81e..fcce634fdfe 100644 --- a/rs/moq-tokio/src/websocket.rs +++ b/rs/moq-tokio/src/websocket.rs @@ -121,7 +121,7 @@ pub struct Options { pub identity: Option, /// The socket measurements captured before the HTTP server erased its descriptor. pub socket_stats: Option, - /// The SETUP deadline, including its reply; defaults to ten seconds. + /// The SETUP deadline, including its reply; unset uses ten seconds, zero disables it. pub timeout: Option, } @@ -135,7 +135,7 @@ impl Options { link: Default::default(), identity: None, socket_stats: None, - timeout: Some(crate::listen::DEFAULT_TIMEOUT), + timeout: None, } } } @@ -166,9 +166,10 @@ where + Unpin + 'static, { - let deadline = options - .timeout - .and_then(|timeout| moq_clock::now().checked_add(timeout)); + let timeout = options.timeout.unwrap_or(crate::listen::DEFAULT_TIMEOUT); + let deadline = (!timeout.is_zero()) + .then(|| moq_clock::now().checked_add(timeout)) + .flatten(); // A vanished peer sends no FIN. Ping and timeout bound the same stale-session // lifetime that QUIC's idle timeout bounds on the native path. // The subprotocol names the QMux version; an upgrade without one has no wire format. @@ -221,7 +222,7 @@ pub(crate) fn won(url: &Url) -> bool { } /// WebSocket configuration for the client. -#[derive(Clone, Debug, usage_rs::Args, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, Default, usage_rs::Args, serde::Serialize, serde::Deserialize)] #[usage(unknown_flags = "error", args_override_self = false)] #[serde(default, deny_unknown_fields)] #[non_exhaustive] @@ -243,20 +244,15 @@ 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. - /// `None` uses the default; an explicit command-line flag or environment variable wins. - #[usage(skip)] - #[serde(default, with = "humantime_serde", skip_serializing_if = "Option::is_none")] - pub delay: Option, - - /// Head start given to the QUIC dial before the WebSocket fallback joins the race. Defaults to 200ms. + /// `None` uses the default; zero disables this duration. #[usage( name = "connect-websocket-delay", long = "connect-websocket-delay", env = "MOQ_CONNECT_WEBSOCKET_DELAY", setting = "connect.websocket.delay" )] - #[serde(default, rename = "__cli_delay", skip_serializing_if = "Option::is_none")] - delay_arg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub delay: Option, /// The released `MOQ_CLIENT_WEBSOCKET_*` env vars, named by [`Config::deprecated`]. #[usage(flatten)] @@ -264,17 +260,6 @@ pub struct Config { pub(crate) legacy: Legacy, } -impl Default for Config { - fn default() -> Self { - Self { - enabled: None, - delay: Some(DEFAULT_DELAY), - delay_arg: None, - legacy: Default::default(), - } - } -} - /// The released spellings for this section, which moved to `--connect-websocket-*` /// and `MOQ_CONNECT_WEBSOCKET_*` with the rest of the dial side. /// @@ -332,7 +317,7 @@ impl Config { pub fn resolve(&self) -> Resolved { Resolved { enabled: self.enabled.unwrap_or(true), - delay: self.delay_arg.map(Into::into).or(self.delay).unwrap_or(DEFAULT_DELAY), + delay: self.delay.map(Into::into).unwrap_or(DEFAULT_DELAY), } } } @@ -1069,3 +1054,51 @@ mod duration_tests { |d| d ); } + +#[cfg(test)] +mod timeout_tests { + use super::*; + use futures::FutureExt; + use moq_net::transport::poll::Session as _; + + #[tokio::test(start_paused = true)] + async fn unset_timeout_uses_the_default_and_zero_disables_it() { + const ALPN: &str = "qmux-01.moq-lite-06"; + for (timeout, expires) in [(None, true), (Some(time::Duration::ZERO), false)] { + let activity = moq_net::server::Activity::default(); + let mut options = Options::new( + "ws://localhost/".parse().unwrap(), + Default::default(), + activity.enter().unwrap(), + ); + options.link.alpn = Some(ALPN.into()); + options.timeout = timeout; + // Complete QMux over an in-memory WebSocket, then withhold MoQ SETUP. + // A silent transport would expire QMux's own parameter deadline first. + let (server, peer) = tokio::io::duplex(4096); + let server = + tokio_tungstenite::WebSocketStream::from_raw_socket(server, tungstenite::protocol::Role::Server, None) + .await; + let peer = + tokio_tungstenite::WebSocketStream::from_raw_socket(peer, tungstenite::protocol::Role::Client, None) + .await; + let mut peer = qmux::ws::Upgraded::new(peer, ALPN).connect().unwrap(); + let mut accept = Box::pin(accept(server, options)); + assert!(accept.as_mut().now_or_never().is_none()); + let _stream = futures::future::poll_fn(|cx| peer.poll_open_bi(cx, Default::default())) + .await + .unwrap(); + tokio::time::advance(crate::listen::DEFAULT_TIMEOUT).await; + let result = accept.as_mut().now_or_never(); + if expires { + assert!( + matches!(result, Some(Err(crate::Error::MoqNet(moq_net::Error::Timeout)))), + "accept outcome: {:?}", + result.map(|outcome| outcome.map(|_| ())) + ); + } else { + assert!(result.is_none(), "zero must keep waiting for SETUP"); + } + } + } +} diff --git a/rs/moq-tokio/src/worker/mod.rs b/rs/moq-tokio/src/worker/mod.rs index b85962f8ae9..48ec310f7f9 100644 --- a/rs/moq-tokio/src/worker/mod.rs +++ b/rs/moq-tokio/src/worker/mod.rs @@ -50,10 +50,4 @@ impl Config { pub fn new(count: u16) -> Self { Self { count, pin: false } } - - /// Whether to pin each worker to a core. - pub fn with_pin(mut self, pin: bool) -> Self { - self.pin = pin; - self - } } diff --git a/rs/moq-tokio/tests/integration/backend.rs b/rs/moq-tokio/tests/integration/backend.rs index 107756891f5..687b0086966 100644 --- a/rs/moq-tokio/tests/integration/backend.rs +++ b/rs/moq-tokio/tests/integration/backend.rs @@ -168,7 +168,7 @@ async fn connect_test(config: ConnectTest<'_>) { if let Some(expect_authority) = expect_authority { assert_eq!(request.authority(), expect_authority.as_deref()); } - let (session, _driver) = request.with_publisher(&pub_origin).ok().await?; + let session = request.with_publisher(&pub_origin).ok().await?; let _broadcast = broadcast; let _track = track; @@ -461,7 +461,7 @@ async fn mtls_test(scheme: &str, 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 = Some(Duration::from_secs(1)); + quic.keep_alive = Some((Duration::from_secs(1)).into()); let server = server_config.init(quic.clone()).expect("failed to init server"); let mut server = server.listen().await.expect("failed to listen"); @@ -489,7 +489,7 @@ async fn mtls_test(scheme: &str, reject: bool) { request.reject(moq_tokio::server::Reject::Forbidden).await?; return Ok::<_, anyhow::Error>(has_cert); } - let (session, _driver) = request.with_publisher(pub_origin.consume()).ok().await?; + let session = request.with_publisher(pub_origin.consume()).ok().await?; let _ = session.closed().await; Ok::<_, anyhow::Error>(has_cert) }); @@ -682,7 +682,7 @@ async fn iroh_connect_test(version: Option<&str>) { if let Some(version) = version.first() { assert_eq!(request.alpn(), Some(version.alpn())); } - let (session, _driver) = request.with_publisher(&pub_origin).ok().await?; + let session = request.with_publisher(&pub_origin).ok().await?; let _broadcast = broadcast; let _track = track; @@ -746,7 +746,7 @@ async fn iroh_connect_test(version: Option<&str>) { async fn quic_client_close_reaches_server() { // An idle timeout inside TIMEOUT would hide a lost close. let mut quic = moq_tokio::quic::Config::default(); - quic.idle_timeout = Some(TIMEOUT * 3); + quic.idle_timeout = Some((TIMEOUT * 3).into()); let mut server_config = moq_tokio::listen::Config::default(); server_config.bind = Some("127.0.0.1:0".parse().unwrap()); @@ -787,7 +787,7 @@ async fn quic_client_close_reaches_server() { .await .expect("accept timed out") .expect("no incoming connection"); - let (session, _driver) = request.ok().await.expect("server handshake failed"); + let session = request.ok().await.expect("server handshake failed"); accepted.send(()).expect("client waiting for the accepted session"); tokio::task::spawn_blocking(move || client.join()) .await @@ -868,7 +868,7 @@ async fn quic_client_close_drains_finished_track() { let origin = moq_tokio::origin::spawn(); let consumer = origin.consume(); let mut announcements = consumer.announced(); - let (_session, _driver) = request + let _session = request .with_subscriber(origin) .ok() .await @@ -989,7 +989,7 @@ async fn quic_client_close_drains_migrated_predecessor() { let origin = moq_tokio::origin::spawn(); let consumer = origin.consume(); let mut announcements = consumer.announced(); - let (first, _driver) = request + let first = request .with_subscriber(origin) .ok() .await @@ -1097,7 +1097,7 @@ async fn quic_client_close_keeps_predecessor_handover() { config.tls.insecure = Some(true); config.bind = Some("127.0.0.1:0".parse().unwrap()); // Well under the one second close window. - config.goaway.handover = Some(Duration::from_millis(500)); + config.goaway.handover = Some((Duration::from_millis(500)).into()); let client = config .init(quic) .expect("failed to init client") @@ -1126,7 +1126,7 @@ async fn quic_client_close_keeps_predecessor_handover() { let origin = moq_tokio::origin::spawn(); let consumer = origin.consume(); let mut announcements = consumer.announced(); - let (first, _driver) = request + let first = request .with_subscriber(origin) .ok() .await diff --git a/rs/moq-tokio/tests/integration/broadcast.rs b/rs/moq-tokio/tests/integration/broadcast.rs index 22d82e94874..2387a63c146 100644 --- a/rs/moq-tokio/tests/integration/broadcast.rs +++ b/rs/moq-tokio/tests/integration/broadcast.rs @@ -72,7 +72,7 @@ async fn broadcast_test(scheme: &str, client_version: Option<&str>, server_versi // ── run server and client concurrently ────────────────────────── let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - let (session, _driver) = request.with_publisher(&pub_origin).ok().await?; + let session = request.with_publisher(&pub_origin).ok().await?; // Keep producers alive so the subscriber can read data. let _broadcast = broadcast; @@ -187,7 +187,7 @@ async fn lite05_timestamp_roundtrip(scheme: &str) { let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - let (session, _driver) = request.with_publisher(&pub_origin).ok().await?; + let session = request.with_publisher(&pub_origin).ok().await?; let _broadcast = broadcast; let _track = track; let _ = session.closed().await; @@ -301,7 +301,7 @@ async fn lite05_fetch_roundtrip(scheme: &str) { let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - let (session, _driver) = request.with_publisher(&pub_origin).ok().await?; + let session = request.with_publisher(&pub_origin).ok().await?; let _broadcast = broadcast; let _track = track; let _ = session.closed().await; @@ -413,7 +413,7 @@ async fn transport_fetch_roundtrip(version: &str) { let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - let (session, _driver) = request.with_publisher(&pub_origin).ok().await?; + let session = request.with_publisher(&pub_origin).ok().await?; let _broadcast = broadcast; let _track = track; let _ = session.closed().await; @@ -548,7 +548,7 @@ async fn lite05_fetch_during_subscribe(scheme: &str) { let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - let (session, _driver) = request.with_publisher(&pub_origin).ok().await?; + let session = request.with_publisher(&pub_origin).ok().await?; let _broadcast = broadcast; let _track = track; let _ = session.closed().await; @@ -655,7 +655,7 @@ async fn broadcast_moq_lite_05_untimed_track() { let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("accept"); - let (session, _driver) = request.with_publisher(&pub_origin).ok().await?; + let session = request.with_publisher(&pub_origin).ok().await?; let _broadcast = broadcast; let _track = track; let _ = session.closed().await; @@ -755,7 +755,7 @@ async fn broadcast_moq_transport_20_current_group_join() { let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - let (session, _driver) = request.with_publisher(&pub_origin).ok().await?; + let session = request.with_publisher(&pub_origin).ok().await?; let _broadcast = broadcast; let _track = track; let _ = session.closed().await; @@ -871,7 +871,7 @@ async fn announce_lifecycle_test(version: &str) { let server_origin = pub_origin.clone(); let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("accept"); - let (session, _driver) = request.with_publisher(&server_origin).ok().await?; + let session = request.with_publisher(&server_origin).ok().await?; let _ = session.closed().await; Ok::<_, anyhow::Error>(()) }); @@ -1045,7 +1045,7 @@ async fn broadcast_route_migration() { let handle_a = tokio::spawn(async move { let request = server_a.accept().await.expect("accept"); - let (session, _driver) = request.with_publisher(&origin_a).ok().await?; + let session = request.with_publisher(&origin_a).ok().await?; let _broadcast = broadcast_a; let _track = track_a; let _ = session.closed().await; @@ -1053,7 +1053,7 @@ async fn broadcast_route_migration() { }); let handle_b = tokio::spawn(async move { let request = server_b.accept().await.expect("accept"); - let (session, _driver) = request.with_publisher(&origin_b).ok().await?; + let session = request.with_publisher(&origin_b).ok().await?; let _broadcast = broadcast_b; let _track = track_b; let _ = session.closed().await; @@ -1192,7 +1192,7 @@ async fn rejoin_skips_a_stale_warm_cache(version: &str) { let addr = server.local_addr().expect("local addr"); let server = tokio::spawn(async move { let request = server.accept().await.expect("accept"); - let (session, _driver) = request.with_publisher(&pub_origin).ok().await?; + let session = request.with_publisher(&pub_origin).ok().await?; let _broadcast = broadcast; let _track = track; let _ = session.closed().await; @@ -1312,7 +1312,7 @@ async fn rejoin_replays_a_current_warm_cache(version: &str, open: bool) { let addr = server.local_addr().expect("local addr"); let server = tokio::spawn(async move { let request = server.accept().await.expect("accept"); - let (session, _driver) = request.with_publisher(&pub_origin).ok().await?; + let session = request.with_publisher(&pub_origin).ok().await?; let _broadcast = broadcast; let _track = track; let _ = session.closed().await; @@ -1439,7 +1439,7 @@ async fn route_reannounce_test(version: Option<&str>) { let server_producer = producer.clone(); let handle = tokio::spawn(async move { let request = server.accept().await.expect("accept"); - let (session, _driver) = request.with_publisher(&origin).ok().await?; + let session = request.with_publisher(&origin).ok().await?; let _producer = server_producer; let _ = session.closed().await; Ok::<_, anyhow::Error>(()) @@ -1744,7 +1744,7 @@ async fn max_age_test(version: &str, published: Option) -> Option(()) }); @@ -2660,7 +2660,7 @@ async fn idle_subscription_releases_the_viewer_count() { let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("accept"); - let (session, _driver) = request.with_publisher(&pub_origin).with_stats(stats).ok().await?; + let session = request.with_publisher(&pub_origin).with_stats(stats).ok().await?; let _broadcast = broadcast; let _track = track; let _ = session.closed().await; @@ -2745,7 +2745,7 @@ async fn websocket_unauthorized_handshake_is_explicit() { }); let mut client_config = moq_tokio::connect::Config::default(); - client_config.websocket.delay = Some(Duration::ZERO); + client_config.websocket.delay = Some((Duration::ZERO).into()); let client = client_config.init(Default::default()).expect("failed to init client"); let url: url::Url = format!("ws://{addr}").parse().unwrap(); @@ -2782,7 +2782,7 @@ async fn reconnect_stops_on_websocket_unauthorized() { }); let mut client_config = moq_tokio::connect::Config::default(); - client_config.websocket.delay = Some(Duration::ZERO); + client_config.websocket.delay = Some((Duration::ZERO).into()); let client = client_config.init(Default::default()).expect("failed to init client"); let url: url::Url = format!("ws://{addr}").parse().unwrap(); @@ -2817,7 +2817,7 @@ async fn one_shot_goaway_ends_the_connection() { let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - let (session, _driver) = request.ok().await?; + let session = request.ok().await?; // No deadline, so nothing on the peer's side ever force-closes us. session @@ -2862,7 +2862,7 @@ async fn one_shot_connect_surfaces_the_session_close() { let server_handle = tokio::spawn(async move { while let Some(request) = server.accept().await { server_accepts.fetch_add(1, Ordering::SeqCst); - let (session, _driver) = request.with_publisher(&pub_origin).ok().await?; + let session = request.with_publisher(&pub_origin).ok().await?; session.abort(moq_net::Error::Cancel); } Ok::<_, anyhow::Error>(()) @@ -2870,10 +2870,12 @@ async fn one_shot_connect_surfaces_the_session_close() { let mut client_config = moq_tokio::connect::Config::default(); 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 = Some(Duration::from_millis(10)); - let client = client_config.init(Default::default()).expect("failed to init client"); + client_config.backoff.initial = Some((Duration::from_millis(10)).into()); + let client = client_config + .init(Default::default()) + .expect("failed to init client") + .with_reconnect(false); let connection = tokio::time::timeout(TIMEOUT, client.connect(url).established()) .await @@ -2921,7 +2923,7 @@ async fn a_dead_session_unannounces_while_the_reconnect_retries() { let (session_tx, session_rx) = tokio::sync::oneshot::channel(); let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("accept"); - let (session, _driver) = request.with_publisher(&server_pub).ok().await?; + let session = request.with_publisher(&server_pub).ok().await?; let _ = session_tx.send(session); std::future::pending::<()>().await; Ok::<_, anyhow::Error>(()) @@ -2934,8 +2936,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 = Some(Duration::from_millis(10)); - client_config.backoff.timeout = Some(Duration::ZERO); + client_config.backoff.initial = Some((Duration::from_millis(10)).into()); + client_config.backoff.timeout = Some((Duration::ZERO).into()); 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()) @@ -3016,7 +3018,7 @@ async fn announce_interest_unauthorized_keeps_session_alive() { let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - let (session, _driver) = request.with_publisher(publish).ok().await?; + let session = request.with_publisher(publish).ok().await?; let _broadcast = broadcast; let _track = track; let _ = session.closed().await; @@ -3108,7 +3110,7 @@ async fn wildcard_scope_test(version: &str, server_scope: &str) { let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - let (session, _driver) = request.with_publisher(publish).ok().await?; + let session = request.with_publisher(publish).ok().await?; let _broadcasts = broadcasts; let _ = session.closed().await; Ok::<_, anyhow::Error>(()) @@ -3223,7 +3225,7 @@ async fn publish_only_client_to_subscribe_only_server() { let url: url::Url = format!("https://localhost:{}", addr.port()).parse().unwrap(); let server_handle = tokio::spawn(async move { - let (session, _driver) = server + let session = server .accept() .await .expect("no incoming connection") @@ -3395,7 +3397,7 @@ async fn goaway_test(scheme: &str, version: &str, expect_wire_timeout: bool) { let (start_drain_tx, start_drain_rx) = tokio::sync::oneshot::channel::<()>(); let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - let (session, _driver) = request.with_publisher(&pub_origin).ok().await?; + let session = request.with_publisher(&pub_origin).ok().await?; start_drain_rx.await.expect("drain signal"); session @@ -3529,7 +3531,7 @@ async fn goaway_timeout_force_close_moq_transport_19_quic() { let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - let (session, _driver) = request.with_publisher(&pub_origin).ok().await?; + let session = request.with_publisher(&pub_origin).ok().await?; session .drain() @@ -3602,7 +3604,7 @@ async fn zero_initial_backoff_still_gives_up_on_a_flapping_peer() { let server_handle = tokio::spawn(async move { // Accept and immediately sever, over and over. while let Some(request) = server.accept().await { - let (session, _driver) = request.with_publisher(&pub_origin).ok().await?; + let session = request.with_publisher(&pub_origin).ok().await?; session.abort(moq_net::Error::Cancel); } Ok::<_, anyhow::Error>(()) @@ -3610,8 +3612,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 = Some(Duration::ZERO); - client_config.backoff.timeout = Some(Duration::from_millis(500)); + client_config.backoff.initial = Some((Duration::ZERO).into()); + client_config.backoff.timeout = Some((Duration::from_millis(500)).into()); let client = client_config.init(Default::default()).expect("failed to init client"); let connection = client.connect(url); @@ -3724,7 +3726,7 @@ async fn abort_carries_its_code_to_the_peer() { let (reason_tx, reason_rx) = tokio::sync::oneshot::channel(); let server_handle = tokio::spawn(async move { let request = server.accept().await.expect("no incoming connection"); - let (session, _driver) = request.ok().await?; + let session = request.ok().await?; let _ = reason_tx.send(session.closed().await); Ok::<_, anyhow::Error>(()) }); diff --git a/rs/moq-tokio/tests/integration/close_code.rs b/rs/moq-tokio/tests/integration/close_code.rs index dcb62c99c92..81a573550a1 100644 --- a/rs/moq-tokio/tests/integration/close_code.rs +++ b/rs/moq-tokio/tests/integration/close_code.rs @@ -55,7 +55,7 @@ async fn serve(case: Case) -> Server { request.reject(moq_tokio::server::Reject::App(CODE)).await.ok(); } Case::Abort | Case::AbortThenDrop => { - let Ok((session, _driver)) = request.ok().await else { + let Ok(session) = request.ok().await else { return; }; session.abort(moq_net::Error::App(CODE)); diff --git a/rs/moq-tokio/tests/integration/keylog.rs b/rs/moq-tokio/tests/integration/keylog.rs index 4b31eef1580..b9ff0c33ccd 100644 --- a/rs/moq-tokio/tests/integration/keylog.rs +++ b/rs/moq-tokio/tests/integration/keylog.rs @@ -1,26 +1,14 @@ -//! TLS session keys reach `SSLKEYLOGFILE` only when `quic.keylog` asks for them. -//! -//! rustls reads the variable itself, so each test runs in a child process started -//! with it naming a file in a fresh directory. - -/// Run the calling test in a child whose `SSLKEYLOGFILE` names a file in `dir`, -/// returning whether this process is that child. -fn isolate(dir: &tempfile::TempDir) -> bool { - test_env::Env::default() - .set("SSLKEYLOGFILE", dir.path().join("keys.log")) - .isolate() -} +//! Native TLS session keys go only to the explicitly configured destination. /// Complete a raw QUIC handshake with the given key logging on each side, and /// return the key log lines both sides wrote. async fn handshake(server_keylog: bool, client_keylog: bool) -> Vec { - // The child's own temporary directory is not the one its parent named. - let path = std::env::var_os("SSLKEYLOGFILE").expect("isolate sets SSLKEYLOGFILE"); - + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("keys.log"); let mut server_quic = moq_tokio::quic::Config::default(); - server_quic.keylog = Some(server_keylog); + server_quic.keylog = server_keylog.then(|| path.clone()); let mut client_quic = moq_tokio::quic::Config::default(); - client_quic.keylog = Some(client_keylog); + client_quic.keylog = client_keylog.then(|| path.clone()); let mut server_config = moq_tokio::listen::Config::default(); server_config.bind = Some("[::]:0".parse().unwrap()); @@ -55,10 +43,6 @@ async fn handshake(server_keylog: bool, client_keylog: bool) -> Vec { #[tokio::test] async fn keys_are_not_logged_by_default() { - let dir = tempfile::tempdir().unwrap(); - if !isolate(&dir) { - return; - } let lines = handshake(false, false).await; assert!(lines.is_empty(), "wrote session keys nobody asked for: {lines:?}"); } @@ -66,10 +50,6 @@ async fn keys_are_not_logged_by_default() { /// One side logging writes each secret once; both sides would write it twice. #[tokio::test] async fn server_keylog_logs_only_the_server() { - let dir = tempfile::tempdir().unwrap(); - if !isolate(&dir) { - return; - } let lines = handshake(true, false).await; assert!(!lines.is_empty(), "quic.keylog wrote no session keys"); let unique: std::collections::HashSet<_> = lines.iter().collect(); @@ -78,12 +58,81 @@ async fn server_keylog_logs_only_the_server() { #[tokio::test] async fn client_keylog_logs_only_the_client() { - let dir = tempfile::tempdir().unwrap(); - if !isolate(&dir) { - return; - } let lines = handshake(false, true).await; assert!(!lines.is_empty(), "quic.keylog wrote no session keys"); let unique: std::collections::HashSet<_> = lines.iter().collect(); assert_eq!(unique.len(), lines.len(), "the server logged keys too: {lines:?}"); } + +/// A path that cannot be opened fails construction, before a peer can dial. +#[tokio::test] +async fn keylog_open_failure_stops_initialization() { + let dir = tempfile::tempdir().unwrap(); + let mut quic = moq_tokio::quic::Config::default(); + quic.keylog = Some(dir.path().join("missing/keys.log")); + let result = moq_tokio::connect::Config::default().init(quic); + assert!(result.is_err(), "an unreadable log must fail initialization"); +} + +#[cfg(unix)] +#[test] +fn keylog_files_are_private_and_existing_permissions_are_not_changed() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("keys.log"); + let _log = moq_tokio::tls::KeyLog::open(&path).unwrap(); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(matches!( + moq_tokio::tls::KeyLog::open(&path), + Err(moq_tokio::tls::Error::KeyLog(_)) + )); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o644); +} + +/// Independently configured endpoints can append to the same destination without splitting lines. +#[test] +fn independent_keylog_writers_append_whole_records() { + use moq_tokio::rustls::KeyLog as _; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("keys.log"); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(8)); + std::thread::scope(|scope| { + for writer in 0..8 { + let log = moq_tokio::tls::KeyLog::open(&path).unwrap(); + let barrier = barrier.clone(); + scope.spawn(move || { + barrier.wait(); + for record in 0..32 { + log.log("CLIENT_TRAFFIC_SECRET_0", &[writer; 32], &[record; 48]); + } + }); + } + }); + let actual: std::collections::BTreeSet<_> = std::fs::read_to_string(&path) + .unwrap() + .lines() + .map(str::to_owned) + .collect(); + let expected: std::collections::BTreeSet<_> = (0..8) + .flat_map(|writer| { + (0..32).map(move |record| { + format!( + "CLIENT_TRAFFIC_SECRET_0 {} {}", + hex::encode([writer; 32]), + hex::encode([record; 48]) + ) + }) + }) + .collect(); + assert_eq!( + actual.len(), + expected.len(), + "independent append handles must preserve every record" + ); + assert!( + expected.is_subset(&actual), + "missing record: {:?}", + expected.difference(&actual).next() + ); +} diff --git a/rs/moq-tokio/tests/integration/max_age.rs b/rs/moq-tokio/tests/integration/max_age.rs index 8d80fe0069d..fe1233b0aac 100644 --- a/rs/moq-tokio/tests/integration/max_age.rs +++ b/rs/moq-tokio/tests/integration/max_age.rs @@ -30,8 +30,7 @@ async fn relay(version: moq_net::Version, javascript: Option) -> anyhow::R .await?, ); } - for (session, driver) in sessions { - driver.await??; + for session in sessions { let _ = session.closed().await; } Ok::<_, anyhow::Error>(()) diff --git a/rs/moq-tokio/tests/integration/receive_credit.rs b/rs/moq-tokio/tests/integration/receive_credit.rs index 2a058a4a0ac..22a413e7fbd 100644 --- a/rs/moq-tokio/tests/integration/receive_credit.rs +++ b/rs/moq-tokio/tests/integration/receive_credit.rs @@ -109,7 +109,7 @@ async fn setup_through( let dial = shaper.as_ref().map_or(server_addr, |shaper| shaper.addr()); tokio::spawn(async move { let request = server.accept().await.expect("accept"); - let (session, _driver) = request.with_publisher(&publisher).ok().await.expect("session"); + let session = request.with_publisher(&publisher).ok().await.expect("session"); let _ = session.closed().await; }); diff --git a/rs/moq-tokio/tests/integration/reconnect.rs b/rs/moq-tokio/tests/integration/reconnect.rs index 86ddb905e76..9502c9d0f52 100644 --- a/rs/moq-tokio/tests/integration/reconnect.rs +++ b/rs/moq-tokio/tests/integration/reconnect.rs @@ -22,9 +22,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 = Some(Duration::from_millis(20)); - backoff.max = Some(Duration::from_millis(40)); - backoff.timeout = Some(Duration::from_millis(200)); + backoff.initial = Some((Duration::from_millis(20)).into()); + backoff.max = Some((Duration::from_millis(40)).into()); + backoff.timeout = Some((Duration::from_millis(200)).into()); // 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"); @@ -78,7 +78,7 @@ async fn a_handshake_slower_than_the_retry_window_still_lands() { let _server = tokio::spawn(async move { let origin = moq_tokio::origin::spawn(); while let Some(request) = server.accept().await { - if let Ok((session, _driver)) = request.with_publisher(&origin).ok().await { + if let Ok(session) = request.with_publisher(&origin).ok().await { let _ = accepted.send(session); } } @@ -103,7 +103,7 @@ async fn a_handshake_slower_than_the_retry_window_still_lands() { let mut config = moq_tokio::connect::Config::default(); config.tls.insecure = Some(true); - config.backoff.timeout = Some(WINDOW); + config.backoff.timeout = Some((WINDOW).into()); let client = config.init(Default::default()).expect("failed to init client"); let url: url::Url = format!("tls://127.0.0.1:{port}/").parse().expect("parse url"); let connection = client.connect(url); @@ -187,7 +187,7 @@ async fn spawn_server() -> ( while let Some(request) = server.accept().await { let origin = moq_tokio::origin::spawn(); match request.with_publisher(&origin).ok().await { - Ok((session, _driver)) => { + Ok(session) => { let _ = accepted.send(session); } Err(err) => tracing::warn!(%err, "accept failed"), @@ -201,9 +201,9 @@ async fn spawn_server() -> ( /// A client that redials fast, so a migration lands 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 = Some(Duration::from_millis(20)); - config.backoff.max = Some(Duration::from_millis(40)); - config.backoff.timeout = Some(Duration::ZERO); + config.backoff.initial = Some((Duration::from_millis(20)).into()); + config.backoff.max = Some((Duration::from_millis(40)).into()); + config.backoff.timeout = Some((Duration::ZERO).into()); config.goaway.redirect = redirect; config.init(Default::default()).expect("failed to init client") } @@ -362,9 +362,9 @@ async fn a_draining_peer_does_not_exhaust_the_retry_window() { // more than twice the window. A GOAWAY arriving inside the 200ms health bar is what makes a // session count as drained at once rather than as a healthy session that later migrated. let mut backoff = moq_tokio::Backoff::default(); - backoff.initial = Some(Duration::from_millis(200)); - backoff.max = Some(Duration::from_millis(200)); - backoff.timeout = Some(Duration::from_millis(500)); + backoff.initial = Some((Duration::from_millis(200)).into()); + backoff.max = Some((Duration::from_millis(200)).into()); + backoff.timeout = Some((Duration::from_millis(500)).into()); rides_out_a_drain(backoff, 12).await; } @@ -374,9 +374,9 @@ async fn a_draining_peer_does_not_exhaust_the_retry_window() { async fn a_drain_backoff_longer_than_the_retry_window_still_redials() { // From the second drained session on, the delay is 400ms against a 100ms window. let mut backoff = moq_tokio::Backoff::default(); - backoff.initial = Some(Duration::from_millis(200)); - backoff.max = Some(Duration::from_millis(400)); - backoff.timeout = Some(Duration::from_millis(100)); + backoff.initial = Some((Duration::from_millis(200)).into()); + backoff.max = Some((Duration::from_millis(400)).into()); + backoff.timeout = Some((Duration::from_millis(100)).into()); rides_out_a_drain(backoff, 4).await; } @@ -401,7 +401,7 @@ async fn rides_out_a_drain(backoff: moq_tokio::Backoff, drained: usize) { // Held, as a draining server does: each one closes when the client leaves it. let mut held = Vec::new(); while let Some(request) = server.accept().await { - let Ok((session, _driver)) = request.with_publisher(&origin).ok().await else { + let Ok(session) = request.with_publisher(&origin).ok().await else { continue; }; if held.len() < drained { @@ -429,7 +429,7 @@ async fn rides_out_a_drain(backoff: moq_tokio::Backoff, drained: usize) { } /// How long each session on a bouncing fleet lives before its relay sends the GOAWAY. Twice -/// the 50ms initial backoff these tests use, so every session counts as healthy. +/// the 50ms healthy bar these tests use, so every session counts as healthy. const LIFE: Duration = Duration::from_millis(100); /// Send every session `sessions` yields the GOAWAY `uri(n)` once it has lived [`LIFE`], `n` @@ -483,13 +483,15 @@ async fn ping_pong( (url, accepts, tasks) } -/// A backoff starting at 50ms that doubles up to `max`, giving up after `timeout`. +/// A backoff starting at 50ms that doubles up to `max`, giving up after `timeout`, and that +/// counts a session as healthy once it outlives the initial delay. fn bounced_backoff(max: Duration, timeout: Duration) -> moq_tokio::Backoff { let mut backoff = moq_tokio::Backoff::default(); - backoff.initial = Some(Duration::from_millis(50)); + backoff.initial = Some((Duration::from_millis(50)).into()); + backoff.healthy = Some((Duration::from_millis(50)).into()); backoff.multiplier = 2; - backoff.max = Some(max); - backoff.timeout = Some(timeout); + backoff.max = Some((max).into()); + backoff.timeout = Some((timeout).into()); backoff } diff --git a/rs/moq-tokio/tests/integration/subscription_end_integrity.rs b/rs/moq-tokio/tests/integration/subscription_end_integrity.rs index 761f60cafc2..e6fb3675db0 100644 --- a/rs/moq-tokio/tests/integration/subscription_end_integrity.rs +++ b/rs/moq-tokio/tests/integration/subscription_end_integrity.rs @@ -120,7 +120,7 @@ async fn round(drop_session: bool) -> (usize, Option) { .await .expect("accept timeout") .expect("no incoming connection"); - let (session, _driver) = request + let session = request .with_publisher(&pub_origin) .ok() .await diff --git a/rs/moq-tokio/tests/integration/worker.rs b/rs/moq-tokio/tests/integration/worker.rs index ba5df60c6ff..d87d6345845 100644 --- a/rs/moq-tokio/tests/integration/worker.rs +++ b/rs/moq-tokio/tests/integration/worker.rs @@ -106,7 +106,7 @@ fn bind_workers( /// 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 { - worker::Config::new(count).with_pin(false) + worker::Config::new(count) } /// Workers that outlive their owner are a leak with teeth: the threads keep @@ -156,7 +156,7 @@ async fn stopping_the_group_closes_its_sessions() { let mut listener = server.listen().await.unwrap(); while let Some(request) = listener.accept().await { tokio::spawn(async move { - let (_session, _driver) = request.ok().await.unwrap(); + let _session = request.ok().await.unwrap(); std::future::pending::<()>().await; }); } @@ -165,16 +165,15 @@ async fn stopping_the_group_closes_its_sessions() { let mut connect = moq_tokio::connect::Config::default(); connect.tls.fingerprint = fingerprints; - connect.once = Some(true); #[cfg(feature = "websocket")] { connect.websocket.enabled = Some(false); } // Far past the wait below, so only a close on the wire can end the session. let mut quic = moq_tokio::quic::Config::default(); - quic.idle_timeout = Some(std::time::Duration::from_secs(600)); + quic.idle_timeout = Some((std::time::Duration::from_secs(600)).into()); quic.keep_alive = None; - let client = connect.init(quic).unwrap(); + let client = connect.init(quic).unwrap().with_reconnect(false); let url: url::Url = format!("https://{addr}/").parse().unwrap(); let session = client.connect(url).established().await.expect("connect"); @@ -787,7 +786,7 @@ async fn rotated_identity_reaches_every_worker() { let send = send.clone(); let certificates = certificates.clone(); tokio::spawn(async move { - let (session, _driver) = request.ok().await.unwrap(); + let session = request.ok().await.unwrap(); send.send((index, certificates.fingerprints())).unwrap(); let _ = session.closed().await; }); @@ -817,12 +816,11 @@ async fn rotated_identity_reaches_every_worker() { // Fresh client endpoints make independent handshakes, without session resumption. let mut config = moq_tokio::connect::Config::default(); config.tls.fingerprint = expected.clone(); - config.once = Some(true); #[cfg(feature = "websocket")] { config.websocket.enabled = Some(false); } - let client = config.init(Default::default()).unwrap(); + let client = config.init(Default::default()).unwrap().with_reconnect(false); let url: url::Url = format!("https://{addr}/").parse().unwrap(); let session = client .connect(url) diff --git a/rs/moq-uring/Cargo.toml b/rs/moq-uring/Cargo.toml index 3f4ee15df04..092b17e6058 100644 --- a/rs/moq-uring/Cargo.toml +++ b/rs/moq-uring/Cargo.toml @@ -22,7 +22,7 @@ quic = ["dep:moq-congestion", "dep:moq-quic", "dep:rustls", "dep:rustls-platform qlog = ["moq-quic?/qlog"] # Workers created inside a tokio runtime with a `moq_clock::Manual` clock installed # run on that clock, so a test decides when their time passes. For test builds. -manual = ["moq-clock/manual"] +_manual = ["moq-clock/manual"] [target.'cfg(target_os = "linux")'.dependencies] bytes = "1" @@ -34,7 +34,7 @@ libc = { workspace = true } moq-clock = { workspace = true } moq-congestion = { workspace = true, optional = true } moq-net = { workspace = true } -moq-quic = { workspace = true, features = ["bloom", "rustls-aws-lc-rs"], optional = true } +moq-quic = { workspace = true, features = ["rustls-aws-lc-rs"], optional = true } moq-sock = { workspace = true } rustc-hash = { workspace = true } rustls = { version = "0.23", optional = true } @@ -64,7 +64,7 @@ io-uring = { workspace = true } libc = { workspace = true } moq-tokio = { path = "../moq-tokio", default-features = false, features = ["quic", "aws-lc-rs"] } # Tests run workers on a manual clock. -moq-uring = { path = ".", features = ["manual"] } +moq-uring = { path = ".", features = ["_manual"] } nix = { workspace = true } rcgen = { workspace = true, features = ["pem", "aws_lc_rs"] } rustls = "0.23" diff --git a/rs/moq-uring/src/clock.rs b/rs/moq-uring/src/clock.rs index eab511d512b..3712bfd7250 100644 --- a/rs/moq-uring/src/clock.rs +++ b/rs/moq-uring/src/clock.rs @@ -20,7 +20,7 @@ pub(crate) enum Clock { /// /// The host clock alongside bounds teardown, which must end however long /// the test leaves its clock alone. - #[cfg(feature = "manual")] + #[cfg(feature = "_manual")] Installed(moq_clock::Installed, Host), } @@ -28,8 +28,8 @@ pub(crate) enum Clock { pub(crate) enum Wait { /// The deadline already passed: run another turn instead of parking. #[cfg_attr( - not(feature = "manual"), - allow(dead_code, reason = "only the `manual` feature uses it") + not(feature = "_manual"), + allow(dead_code, reason = "only the `_manual` feature uses it") )] Elapsed, /// Park until a completion or a wake, with no timeout. @@ -41,7 +41,7 @@ pub(crate) enum Wait { impl Clock { /// The clock installed on the current tokio runtime, or else the host's. pub fn new() -> Self { - #[cfg(feature = "manual")] + #[cfg(feature = "_manual")] if let Some(clock) = moq_clock::Installed::current() { return Self::Installed(clock, Host::calibrate()); } @@ -52,7 +52,7 @@ impl Clock { pub fn now(&self) -> Instant { match self { Self::Host(host) => host.now(), - #[cfg(feature = "manual")] + #[cfg(feature = "_manual")] Self::Installed(clock, _) => clock.now(), } } @@ -62,7 +62,7 @@ impl Clock { pub fn host_now(&self) -> Instant { match self { Self::Host(host) => host.now(), - #[cfg(feature = "manual")] + #[cfg(feature = "_manual")] Self::Installed(_, host) => host.now(), } } @@ -75,7 +75,7 @@ impl Clock { let _ = (alarm, cx); deadline.map_or(Wait::Forever, |at| Wait::Until(host.monotonic(at))) } - #[cfg(feature = "manual")] + #[cfg(feature = "_manual")] Self::Installed(clock, _) => { use std::future::Future; @@ -162,7 +162,7 @@ mod tests { /// The host's time keeps passing while a manual clock stands still, so the /// teardown bound it measures expires. - #[cfg(feature = "manual")] + #[cfg(feature = "_manual")] #[test] fn host_time_passes_on_a_standing_manual_clock() { let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap(); diff --git a/test/interop/clients/rust/control.rs b/test/interop/clients/rust/control.rs index 762fee757ed..f56217d3279 100644 --- a/test/interop/clients/rust/control.rs +++ b/test/interop/clients/rust/control.rs @@ -54,7 +54,7 @@ async fn main() -> anyhow::Result<()> { .stdin(Stdio::piped()) .kill_on_drop(true) .spawn()?; - let (session, driver) = listener.accept().await.context("accept")?.ok().await?; + let session = listener.accept().await.context("accept")?.ok().await?; ensure!(session.version() == version, "wrong negotiated version"); if args[1] == "credit" { // The JS peer has one usable track slot beyond any namespace discovery. @@ -87,7 +87,7 @@ async fn main() -> anyhow::Result<()> { } ensure!(child.wait().await?.success(), "JS peer failed"); session.abort(moq_net::Error::Cancel); - let _ = driver.await?; + let _ = session.closed().await; eprintln!("{} interop passed {version}", args[1]); Ok(()) } diff --git a/test/interop/clients/rust/goaway.rs b/test/interop/clients/rust/goaway.rs index daa5ee74306..ac2c9e49d67 100644 --- a/test/interop/clients/rust/goaway.rs +++ b/test/interop/clients/rust/goaway.rs @@ -53,7 +53,7 @@ async fn main() -> anyhow::Result<()> { .kill_on_drop(true) .spawn()?; let mut output = tokio::io::BufReader::new(child.stdout.take().context("child stdout")?).lines(); - let (session, driver) = first.accept().await.context("first accept")?.ok().await?; + let session = first.accept().await.context("first accept")?.ok().await?; ensure!(session.version() == version, "wrong first negotiated version"); let mut demand = track.demand(); while demand.wanted_changed().await?.is_none() {} @@ -64,7 +64,7 @@ async fn main() -> anyhow::Result<()> { "JS did not read first group" ); session.drain().send(moq_net::goaway::Goaway::redirect(&second_url))?; - let (replacement, replacement_driver) = second.accept().await.context("replacement accept")?.ok().await?; + let replacement = second.accept().await.context("replacement accept")?.ok().await?; ensure!(replacement.version() == version, "wrong replacement negotiated version"); // First exercise the replacement while the drained route still serves. A JS // acknowledgement of its namespace proves more than the SETUP handshake. @@ -75,7 +75,7 @@ async fn main() -> anyhow::Result<()> { ); // Only the replacement can deliver the next group after this transport is cut. session.abort(moq_net::Error::Cancel); - let _ = driver.await?; + let _ = session.closed().await; if legacy { ensure!( output.next_line().await?.as_deref() == Some("resubscribed"), @@ -89,7 +89,7 @@ async fn main() -> anyhow::Result<()> { ); ensure!(child.wait().await?.success(), "JS client failed"); replacement.abort(moq_net::Error::Cancel); - let _ = replacement_driver.await?; + let _ = replacement.closed().await; eprintln!("goaway redirect resumed {version}"); Ok(()) }