From 95cb5d0065f30b85af09b2b10dc3c4469a99fea9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 00:49:20 -0700 Subject: [PATCH 01/12] chore(quest): claim quest/m1/api-audit/transport-config.md Quest-Claim: f72ceaef1f77719e From 5368ca56f073cc76fb839ea09b8ee1d03d4b8e0b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 02:30:42 -0700 Subject: [PATCH 02/12] Consolidate native transport configuration Preserve the unresolved pacing and TLS policy choices on the quest draft, with validated consumers and measured driver panic supervision. Co-Authored-By: GPT-6 --- Cargo.lock | 19 -- bench/ffi-gen/src/client.rs | 5 +- cpp/moq/MIGRATE.md | 5 + cpp/obs/MIGRATE.md | 5 + dart/moq/MIGRATE.md | 5 + dart/moq_ffi/MIGRATE.md | 7 + deny.toml | 6 +- doc/bin/gstreamer.md | 2 +- doc/bin/obs.md | 2 +- doc/bin/relay/config.md | 18 +- go/ffi/MIGRATE.md | 7 + go/wrapper/MIGRATE.md | 5 + kt/moq-ffi/MIGRATE.md | 7 + kt/moq/MIGRATE.md | 5 + py/moq-ffi/MIGRATE.md | 7 + py/moq-rs/MIGRATE.md | 5 + quest/m1/api-audit/transport-config.md | 102 +++++--- rs/kio/Cargo.toml | 2 +- rs/kio/MIGRATE.md | 5 + rs/kio/src/consumer.rs | 4 +- rs/kio/src/coop.rs | 16 +- rs/kio/src/producer.rs | 10 +- rs/kio/src/queue.rs | 4 +- rs/kio/src/shared.rs | 2 +- rs/kio/src/task.rs | 4 +- rs/kio/src/waiter.rs | 2 +- rs/kio/src/weak.rs | 10 +- rs/moq-auth/Cargo.toml | 6 +- rs/moq-auth/MIGRATE.md | 7 + rs/moq-auth/src/key.rs | 2 +- rs/moq-auth/src/lease.rs | 43 ++-- rs/moq-c/MIGRATE.md | 8 + rs/moq-c/src/api.rs | 14 +- rs/moq-c/src/client.rs | 22 +- rs/moq-c/src/session.rs | 15 +- rs/moq-c/src/test.rs | 48 ++-- rs/moq-cli/src/main.rs | 9 +- rs/moq-cli/tests/integration/export.rs | 2 +- rs/moq-clock/Cargo.toml | 2 +- rs/moq-ffi/MIGRATE.md | 8 + rs/moq-ffi/src/server.rs | 4 +- rs/moq-ffi/src/session.rs | 66 +++-- rs/moq-ffi/src/test.rs | 9 +- rs/moq-gst/MIGRATE.md | 8 + rs/moq-gst/src/sink/imp.rs | 10 +- rs/moq-gst/src/sink/session.rs | 10 +- rs/moq-gst/src/source/imp.rs | 14 +- rs/moq-hls/src/server/routes.rs | 3 +- rs/moq-net/benches/transport-config/README.md | 21 ++ .../benches/transport-config/driver-cpu.csv | 73 ++++++ rs/moq-net/src/client.rs | 51 +++- rs/moq-net/src/driver.rs | 21 +- rs/moq-net/src/session.rs | 13 +- rs/moq-quic/Cargo.toml | 33 +-- rs/moq-quic/MIGRATE.md | 11 + rs/moq-quic/src/config/mod.rs | 37 +-- rs/moq-quic/src/connection/mod.rs | 2 +- rs/moq-quic/src/crypto.rs | 2 +- rs/moq-quic/src/crypto/rustls.rs | 28 +-- rs/moq-quic/src/entropy.rs | 2 +- rs/moq-quic/src/lib.rs | 8 +- rs/moq-quic/src/packet.rs | 6 +- rs/moq-quic/src/tests/mod.rs | 4 +- rs/moq-quic/src/tests/util.rs | 7 +- rs/moq-relay/Cargo.toml | 2 +- rs/moq-relay/MIGRATE.md | 24 +- rs/moq-relay/benches/auth.rs | 6 +- rs/moq-relay/src/acme.rs | 12 +- rs/moq-relay/src/acme/tests.rs | 11 +- rs/moq-relay/src/cache.rs | 15 +- rs/moq-relay/src/cluster.rs | 18 +- rs/moq-relay/src/config.rs | 99 ++------ rs/moq-relay/src/duration.rs | 91 ------- rs/moq-relay/src/incoming.rs | 12 +- rs/moq-relay/src/lib.rs | 1 - rs/moq-relay/src/relay.rs | 8 +- rs/moq-relay/src/runtime.rs | 4 +- rs/moq-relay/src/shutdown.rs | 6 +- rs/moq-relay/src/stats.rs | 11 +- rs/moq-relay/src/test_support.rs | 2 +- rs/moq-relay/src/uring.rs | 29 +-- rs/moq-relay/src/web.rs | 9 +- rs/moq-relay/tests/drills.rs | 12 +- .../tests/integration/auth_lifetime.rs | 25 +- .../tests/integration/cluster_close.rs | 2 +- .../tests/integration/cluster_idle.rs | 6 +- .../tests/integration/cluster_unknown.rs | 2 +- rs/moq-relay/tests/integration/embed.rs | 10 +- .../tests/integration/goaway_cluster.rs | 26 +- .../tests/integration/hidden_cluster.rs | 2 +- .../tests/integration/public_rules.rs | 8 +- rs/moq-relay/tests/integration/refusals.rs | 8 +- .../tests/integration/runtime_uring.rs | 33 ++- .../tests/integration/runtime_workers.rs | 6 +- .../tests/integration/session_revalidate.rs | 8 +- .../tests/integration/shutdown_signal.rs | 10 +- rs/moq-relay/tests/integration/smoke.rs | 8 +- rs/moq-relay/tests/integration/stack.rs | 2 +- rs/moq-relay/tests/integration/wire_stats.rs | 2 +- rs/moq-rtc/src/server/whep.rs | 3 +- rs/moq-tokio/Cargo.toml | 4 +- rs/moq-tokio/MIGRATE.md | 79 ++++-- rs/moq-tokio/src/cli.rs | 4 +- rs/moq-tokio/src/cli/duration.rs | 42 +--- rs/moq-tokio/src/client.rs | 65 ++--- rs/moq-tokio/src/connect.rs | 125 ++-------- rs/moq-tokio/src/connection.rs | 183 +++++--------- rs/moq-tokio/src/error.rs | 6 +- rs/moq-tokio/src/iroh.rs | 43 +++- rs/moq-tokio/src/listen.rs | 214 +++++----------- rs/moq-tokio/src/quic.rs | 144 +++-------- rs/moq-tokio/src/quic/backend.rs | 42 ++-- rs/moq-tokio/src/server.rs | 21 +- rs/moq-tokio/src/settings.rs | 22 +- rs/moq-tokio/src/tcp.rs | 7 +- rs/moq-tokio/src/tls.rs | 235 ++++++++++++++---- rs/moq-tokio/src/webrtc/tests.rs | 2 +- rs/moq-tokio/src/websocket.rs | 88 ++++--- rs/moq-tokio/src/worker/mod.rs | 6 - rs/moq-tokio/tests/integration/backend.rs | 20 +- rs/moq-tokio/tests/integration/broadcast.rs | 86 +++---- rs/moq-tokio/tests/integration/close_code.rs | 2 +- rs/moq-tokio/tests/integration/keylog.rs | 85 ++++++- rs/moq-tokio/tests/integration/max_age.rs | 3 +- .../tests/integration/receive_credit.rs | 2 +- rs/moq-tokio/tests/integration/reconnect.rs | 38 +-- .../integration/subscription_end_integrity.rs | 2 +- rs/moq-tokio/tests/integration/worker.rs | 14 +- rs/moq-uring/Cargo.toml | 6 +- rs/moq-uring/MIGRATE.md | 5 + rs/moq-uring/src/clock.rs | 14 +- swift/MIGRATE.md | 5 + swift/ffi/MIGRATE.md | 7 + 133 files changed, 1578 insertions(+), 1476 deletions(-) create mode 100644 rs/moq-net/benches/transport-config/README.md create mode 100644 rs/moq-net/benches/transport-config/driver-cpu.csv delete mode 100644 rs/moq-relay/src/duration.rs diff --git a/Cargo.lock b/Cargo.lock index b8cb020c2fd..51eaaefebf5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -632,28 +632,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", @@ -855,8 +839,6 @@ dependencies = [ "cexpr", "clang-sys", "itertools 0.13.0", - "log", - "prettyplease", "proc-macro2", "quote", "regex", @@ -5065,7 +5047,6 @@ dependencies = [ "rustc-hash 2.1.3", "rustls", "rustls-pki-types", - "rustls-platform-verifier", "slab", "stats_alloc", "thiserror 2.0.21", 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/cpp/moq/MIGRATE.md b/cpp/moq/MIGRATE.md index f7e71829805..e8cf6b985b1 100644 --- a/cpp/moq/MIGRATE.md +++ b/cpp/moq/MIGRATE.md @@ -97,6 +97,11 @@ auto stream = ok(moq::MediaContainerProducer::init(broadcast, {moq::ContainerFor `SSLKEYLOGFILE` alone no longer writes TLS session secrets. Set `QuicConfig::keylog` to `true` as well to capture them; without `SSLKEYLOGFILE` the client or server refuses to build. +The boolean opt-in remains unchanged. Native QUIC captures `SSLKEYLOGFILE` when +configuration is adapted, creates new Unix logs with mode 0600, and refuses an +existing group/other-accessible file (for example mode 0644) without changing it. +Make an existing log private before enabling key logging. + ### Subscriptions carry group ranges (#5732) `Subscription::group_start` and `group_end` are replaced by `ranges`, a list of `Range` whose ends are an `Anchor` (an absolute group and frame, or a count of groups before the latest), and an optional `order`. diff --git a/cpp/obs/MIGRATE.md b/cpp/obs/MIGRATE.md index 398682fc872..828e18dcea3 100644 --- a/cpp/obs/MIGRATE.md +++ b/cpp/obs/MIGRATE.md @@ -9,6 +9,11 @@ How to update your OBS setup for each breaking change, newest first. `SSLKEYLOGFILE` alone no longer writes TLS session secrets. Turn on **TLS key log** in the output's advanced settings or the MoQ Source's properties as well to capture them; without `SSLKEYLOGFILE` the stream stops or the source does not connect. +The boolean opt-in remains unchanged. Native QUIC captures `SSLKEYLOGFILE` when +configuration is adapted, creates new Unix logs with mode 0600, and refuses an +existing group/other-accessible file (for example mode 0644) without changing it. +Make an existing log private before enabling key logging. + ### The video bitrate follows the bandwidth estimate (#5845) The configured video bitrate is now a ceiling: the encoder drops below it when the uplink narrows, with no opt-out. diff --git a/dart/moq/MIGRATE.md b/dart/moq/MIGRATE.md index 2421cf9d9c8..871c5186099 100644 --- a/dart/moq/MIGRATE.md +++ b/dart/moq/MIGRATE.md @@ -94,6 +94,11 @@ An exhaustive `switch` needs one more case. `SSLKEYLOGFILE` alone no longer writes TLS session secrets. Set `ConnectOptions.keylog` or `ListenOptions.keylog` as well to capture them; without `SSLKEYLOGFILE` the connect or listen fails. +The boolean opt-in remains unchanged. Native QUIC captures `SSLKEYLOGFILE` when +configuration is adapted, creates new Unix logs with mode 0600, and refuses an +existing group/other-accessible file (for example mode 0644) without changing it. +Make an existing log private before enabling key logging. + ### Subscriptions carry group ranges (#5732) `Subscription.groupStart` and `groupEnd` are replaced by `ranges`, a list of `Range` whose ends are an `Anchor` (an absolute group and frame, or a count of groups before the latest), and an optional `order`. diff --git a/dart/moq_ffi/MIGRATE.md b/dart/moq_ffi/MIGRATE.md index 81942441036..204961ca78a 100644 --- a/dart/moq_ffi/MIGRATE.md +++ b/dart/moq_ffi/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Keylog files must be private ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +The boolean opt-in remains unchanged. Native QUIC captures `SSLKEYLOGFILE` when +configuration is adapted, creates new Unix logs with mode 0600, and refuses an +existing group/other-accessible file (for example mode 0644) without changing it. +Make an existing log private before enabling key logging. + ### A broadcast reference waits for its sibling's announcement (#6254) Resolving a rendition's `broadcast` reference, through `MoqBroadcastConsumer.resolve` and the rendition-based subscribes and fetches, now waits for a sibling nothing announces yet instead of failing as unroutable, so a late sibling plays once it is announced. 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 3b053b30a2d..82e26ad1aa5 100644 --- a/doc/bin/gstreamer.md +++ b/doc/bin/gstreamer.md @@ -150,6 +150,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 b76ef9e601c..0e05cc8e42e 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -54,15 +54,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] @@ -74,21 +75,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/go/ffi/MIGRATE.md b/go/ffi/MIGRATE.md index 8b8121ffdd1..23aa2fb3b86 100644 --- a/go/ffi/MIGRATE.md +++ b/go/ffi/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Keylog files must be private ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +The boolean opt-in remains unchanged. Native QUIC captures `SSLKEYLOGFILE` when +configuration is adapted, creates new Unix logs with mode 0600, and refuses an +existing group/other-accessible file (for example mode 0644) without changing it. +Make an existing log private before enabling key logging. + ### A broadcast reference waits for its sibling's announcement (#6254) Resolving a rendition's `broadcast` reference, through `MoqBroadcastConsumer.Resolve` and the rendition-based subscribes and fetches, now waits for a sibling nothing announces yet instead of failing as unroutable, so a late sibling plays once it is announced. diff --git a/go/wrapper/MIGRATE.md b/go/wrapper/MIGRATE.md index b76850ea132..282a4d7ae53 100644 --- a/go/wrapper/MIGRATE.md +++ b/go/wrapper/MIGRATE.md @@ -99,6 +99,11 @@ A `switch` that handles every value needs one more case. `SSLKEYLOGFILE` alone no longer writes TLS session secrets. Pass `WithQUICKeylog(true)` to `Dial` or `WithServerQUICKeylog(true)` to `Listen` as well to capture them; without `SSLKEYLOGFILE` the dial or listen fails. +The boolean opt-in remains unchanged. Native QUIC captures `SSLKEYLOGFILE` when +configuration is adapted, creates new Unix logs with mode 0600, and refuses an +existing group/other-accessible file (for example mode 0644) without changing it. +Make an existing log private before enabling key logging. + ### Subscriptions carry group ranges (#5732) `Subscription.GroupStart` and `GroupEnd` are replaced by `Ranges`, a slice of `Range` whose ends are `AnchorAbsolute` or `AnchorRelative` (an absolute group and frame, or a count of groups before the latest), and an optional `Order`. diff --git a/kt/moq-ffi/MIGRATE.md b/kt/moq-ffi/MIGRATE.md index 26889124bb9..1c48912e1ea 100644 --- a/kt/moq-ffi/MIGRATE.md +++ b/kt/moq-ffi/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Keylog files must be private ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +The boolean opt-in remains unchanged. Native QUIC captures `SSLKEYLOGFILE` when +configuration is adapted, creates new Unix logs with mode 0600, and refuses an +existing group/other-accessible file (for example mode 0644) without changing it. +Make an existing log private before enabling key logging. + ### A broadcast reference waits for its sibling's announcement (#6254) Resolving a rendition's `broadcast` reference, through `MoqBroadcastConsumer.resolve` and the rendition-based subscribes and fetches, now waits for a sibling nothing announces yet instead of failing as unroutable, so a late sibling plays once it is announced. diff --git a/kt/moq/MIGRATE.md b/kt/moq/MIGRATE.md index 995d262f220..91b72e66439 100644 --- a/kt/moq/MIGRATE.md +++ b/kt/moq/MIGRATE.md @@ -94,6 +94,11 @@ An exhaustive `when` needs one more branch. `SSLKEYLOGFILE` alone no longer writes TLS session secrets. Set `QuicConfig.keylog` to `true` as well to capture them; without `SSLKEYLOGFILE` the client or server refuses to build. +The boolean opt-in remains unchanged. Native QUIC captures `SSLKEYLOGFILE` when +configuration is adapted, creates new Unix logs with mode 0600, and refuses an +existing group/other-accessible file (for example mode 0644) without changing it. +Make an existing log private before enabling key logging. + ### Subscriptions carry group ranges (#5732) `Subscription.groupStart` and `groupEnd` are replaced by `ranges`, a list of `Range` whose ends are `AnchorAbsolute` or `AnchorRelative` (an absolute group and frame, or a count of groups before the latest), and an optional `order`. diff --git a/py/moq-ffi/MIGRATE.md b/py/moq-ffi/MIGRATE.md index 44d3a96c07a..409a88631ba 100644 --- a/py/moq-ffi/MIGRATE.md +++ b/py/moq-ffi/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Keylog files must be private ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +The boolean opt-in remains unchanged. Native QUIC captures `SSLKEYLOGFILE` when +configuration is adapted, creates new Unix logs with mode 0600, and refuses an +existing group/other-accessible file (for example mode 0644) without changing it. +Make an existing log private before enabling key logging. + ### A broadcast reference waits for its sibling's announcement (#6254) Resolving a rendition's `broadcast` reference, through `MoqBroadcastConsumer.resolve()` and the rendition-based subscribes and fetches, now waits for a sibling nothing announces yet instead of failing as unroutable, so a late sibling plays once it is announced. diff --git a/py/moq-rs/MIGRATE.md b/py/moq-rs/MIGRATE.md index b8ec8a192fa..78aabc3d87c 100644 --- a/py/moq-rs/MIGRATE.md +++ b/py/moq-rs/MIGRATE.md @@ -93,6 +93,11 @@ Code that handles every value needs one more case. `SSLKEYLOGFILE` alone no longer writes TLS session secrets. Pass `keylog=True` to `Client`, `connect`, or `Server` as well to capture them; without `SSLKEYLOGFILE` the client refuses to build and the server refuses to listen. +The boolean opt-in remains unchanged. Native QUIC captures `SSLKEYLOGFILE` when +configuration is adapted, creates new Unix logs with mode 0600, and refuses an +existing group/other-accessible file (for example mode 0644) without changing it. +Make an existing log private before enabling key logging. + ### Subscriptions carry group ranges (#5732) `Subscription.group_start` and `group_end` are replaced by `ranges`, a list of `Range` whose ends are an `Anchor` (an absolute group and frame, or a count of groups before the latest), and an optional `order`. diff --git a/quest/m1/api-audit/transport-config.md b/quest/m1/api-audit/transport-config.md index 6c00cc90a4f..e54caec6c09 100644 --- a/quest/m1/api-audit/transport-config.md +++ b/quest/m1/api-audit/transport-config.md @@ -8,37 +8,71 @@ 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: drop the unused features (`rustls-ring`, `tracing-log`, `bloom`, - `platform-verifier`, fips, log) and the "backwards compat" `rustls` alias - in a new crate; gate `pub use rustls` on `any(ring, aws-lc-rs)` so it - exists under `rustls-aws-lc-rs`; point crate docs at - `moq_tokio::quic::driver` instead of quinn. -- Public only for tests: `lease::Producer::{due, failed}` and `Due` go - `pub(crate)` (drop the `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-quic breaking. Wire: none. +### Checkpoint + +The original API-audit recommendations were approved 2026-10-22. The current +implementation has one human-readable duration field per setting and `None` +defaults; native keylog paths; internally supervised accepted sessions with +panic diagnostics published by the driver before unwind; the direct QUIC +connection; one `[listen.lb]` path; private test hooks; and consolidated QUIC +features. `Client::with_reconnect` remains the sole native reconnect policy, +including the adapter for the unchanged foreign `once` field. + +Real consumers are Tokio, relay Tokio/io_uring and ACME/HTTPS, C, UniFFI, +GStreamer, and the existing integration/benchmark clients. The foreign boolean +keylog settings still read `SSLKEYLOGFILE` at their adapter boundary. No process +environment is changed. Shared TLS policy versus inline `listen.tls` already +refused competing settings on the checked-out main, so that refusal is retained. + +The driver poll guard has deterministic panic/cancellation coverage and an +existing 72-case publisher × viewer CPU sweep, including an unchanged-guard +repeat. Raw measurements are in +[`rs/moq-net/benches/transport-config`](/rs/moq-net/benches/transport-config/README.md). +They are noisy and establish no speedup. + +### Remaining work + +Keep the original Goal and resolve these contract boundaries before completing +this quest: + +- **Zero means off:** the existing reconnect implementation deliberately floors + retry pacing at 50ms. Its `initial` is also the healthy-session classification + threshold, and `max` is clamped to at least `initial`. For example, + `initial=0,max=0` still waits 50ms; `initial=1s,max=0` waits at least 1s. + Zero `timeout` already disables the retry deadline. Do not silently describe + this as the approved “zero always off” behavior. Decide separately whether + zero initial disables retry waiting while preserving an independent healthy + threshold, and whether zero max disables the cap or all retry waiting. + Recommend preserving bounded anti-flap classification and defining these + three meanings explicitly, then add deterministic immediate-close and GOAWAY + loop tests. No new floor or pacing policy is approved by this checkpoint. Iroh 1.4.1 + cannot clear its default 5s connection/path heartbeat: explicit zero + `keep_alive` is refused only at an enabled Iroh bind; other backends retain + their supported zero behavior. +- **Iroh keylog capability:** pinned Iroh 1.4.1 offers only `Builder::keylog(bool)` + reading a process-global environment path. Its public crypto-provider and + external-CA APIs do not install a key logger for MoQ sessions. The checkpoint + refuses an explicit path only when Iroh is enabled, preserving ordinary Iroh + and native/io_uring logging. Decide whether to accept this documented + capability loss or wait for an upstream explicit-path hook; recommend the + hook if preserving opt-in Iroh logging is required. Do not mutate the process + environment or introduce another boolean shim. +- **Resolver policy shape:** review the concrete + `tls::Serve::resolver(resolver, &tls::Listen)` constructor. The policy argument + preserves the real ACME consumers' CA roots and live pinned peers; a one-arg + constructor would discard mTLS. Certificate/key/generate/identity sources + alongside it are refused. Tests cover CA verification and allowlist removal + without TLS resumption. Recommend the tested two-argument shape. +- **Shared logger shape and existing-file behavior:** review `tls::KeyLog::open` + used by native QUIC and relay io_uring. New Unix files are mode 0600; an + existing mode-0644 file is refused without altering permissions. This tightens + the previous environment logger's behavior. Recommend the shared helper and + explicit refusal, retaining the tested initialization failure and real + handshake coverage. + +After those choices, complete any resulting source/consumer changes, run the +required checks, update migration guidance to the final release-to-main +contract, and remove this quest and its references only when the Goal holds. + +Public API: moq-tokio, moq-quic, moq-relay, moq-auth, kio and moq-uring breaking; +foreign records, C ABI and GStreamer property names remain unchanged. Wire: none. diff --git a/rs/kio/Cargo.toml b/rs/kio/Cargo.toml index 4acfc6e12fc..7e0f0bb1ba6 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/MIGRATE.md b/rs/kio/MIGRATE.md index 2810c099b36..ef3674f7f67 100644 --- a/rs/kio/MIGRATE.md +++ b/rs/kio/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Simulator instrumentation is private ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +The test-only `sim` feature is `_sim`. Production consumers should remove it; +moq-clock's simulator enables the internal hook itself. + ### `Pollable` merged into `Task`, and unused surface removed (#4156) `Fan`, `Hold`, `WeakLock`, `Pollable`, and the public `Lock::downgrade` are gone. 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 2e8d9ed2f50..b0b4db29aad 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 94776b90c50..ea5ec56b5f6 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 bcc3ec419f8..8272a1d5d8f 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/MIGRATE.md b/rs/moq-auth/MIGRATE.md index e2d2074bd04..358d670d453 100644 --- a/rs/moq-auth/MIGRATE.md +++ b/rs/moq-auth/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Lease scheduling is internal to the client ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +`lease::Due` and `Producer::{due, failed}` are private; drive authorization through +`Client` or update the lease directly as an embedder. The standalone `tokio` feature +is removed. Enable `client` for HTTP lease scheduling or `serve` for the server; +`Key::from_file_async` remains available with either. + ### `Client::connect` has no timeout of its own (#5765) `Client::connect` waits on the auth server for as long as its caller does, and dropping the future drops the request. diff --git a/rs/moq-auth/src/key.rs b/rs/moq-auth/src/key.rs index 207064add40..47002614ff9 100644 --- a/rs/moq-auth/src/key.rs +++ b/rs/moq-auth/src/key.rs @@ -342,7 +342,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 { let contents = tokio::fs::read_to_string(path).await?; Self::from_str(&contents) diff --git a/rs/moq-auth/src/lease.rs b/rs/moq-auth/src/lease.rs index 0727daebf9d..81bf6c34664 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"); @@ -588,7 +589,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(); @@ -613,7 +614,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(); @@ -633,7 +634,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(); @@ -650,7 +651,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(); @@ -661,7 +662,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/MIGRATE.md b/rs/moq-c/MIGRATE.md index 73a236f8faa..40a85846335 100644 --- a/rs/moq-c/MIGRATE.md +++ b/rs/moq-c/MIGRATE.md @@ -4,6 +4,14 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Keylog files must be private ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +The existing boolean keylog setting still opts into `SSLKEYLOGFILE`; no ABI or +property name changes. Its destination is captured when configuration is adapted. +Native QUIC initialization now refuses group/other-accessible existing Unix files +(for example mode 0644), without changing permissions; newly created logs use 0600. +Make an existing log private before enabling key logging. + ### A broadcast reference waits for its sibling's announcement (#6254) `moq_consume_video` and `moq_consume_audio` on a rendition whose catalog `broadcast` field names a sibling now wait for a sibling nothing announces yet instead of failing as unroutable, so a late sibling plays once it is announced. diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index aa7170afaa1..40d4ef06d97 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -1277,16 +1277,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; @@ -1557,7 +1555,9 @@ unsafe fn parse_server(config: &moq_server_config) -> Result) -> Result) -> Result) -> Result) -> Result 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(), })); } // A server-accepted session is a single transport, so it only ever connects once. callback.call(1); - 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 d98cc3fe827..375f0190aec 100644 --- a/rs/moq-c/src/test.rs +++ b/rs/moq-c/src/test.rs @@ -5028,7 +5028,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, @@ -5053,19 +5053,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(); @@ -5093,7 +5090,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 @@ -5111,13 +5110,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] @@ -5245,19 +5253,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 aa28cdd09ce..ecdf582814d 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -232,14 +232,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 } } @@ -1184,7 +1183,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/export.rs b/rs/moq-cli/tests/integration/export.rs index 96e22b0a86f..19dc3eb9e01 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 bcb9d8f629d..21a75606112 100644 --- a/rs/moq-clock/Cargo.toml +++ b/rs/moq-clock/Cargo.toml @@ -24,7 +24,7 @@ ignored = ["moq-clock"] default = ["runtime"] # 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/MIGRATE.md b/rs/moq-ffi/MIGRATE.md index 63a09626841..4b91155c316 100644 --- a/rs/moq-ffi/MIGRATE.md +++ b/rs/moq-ffi/MIGRATE.md @@ -5,6 +5,14 @@ These are the UniFFI names; each language wrapper has its own `MIGRATE.md` in it ## Unreleased +### Keylog files must be private ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +The existing boolean keylog setting still opts into `SSLKEYLOGFILE`; no ABI or +property name changes. Its destination is captured when configuration is adapted. +Native QUIC initialization now refuses group/other-accessible existing Unix files +(for example mode 0644), without changing permissions; newly created logs use 0600. +Make an existing log private before enabling key logging. + ### A broadcast reference waits for its sibling's announcement (#6254) Resolving a rendition's `broadcast` reference, through `MoqBroadcastConsumer::resolve` and the rendition-based subscribes and fetches, now waits for a sibling nothing announces yet instead of failing as unroutable, so a late sibling plays once it is announced. diff --git a/rs/moq-ffi/src/server.rs b/rs/moq-ffi/src/server.rs index 95932702786..fd0c369457f 100644 --- a/rs/moq-ffi/src/server.rs +++ b/rs/moq-ffi/src/server.rs @@ -494,7 +494,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() @@ -502,7 +502,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 660c67ee6e2..d01eec111c2 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -160,13 +160,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 } } @@ -239,10 +246,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() } @@ -319,9 +326,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); @@ -334,32 +343,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, @@ -903,7 +913,6 @@ enum Inner { #[derive(Clone)] struct Accepted { session: moq_net::Session, - driver: crate::ffi::Background, } impl std::ops::Deref for Accepted { @@ -915,17 +924,11 @@ impl std::ops::Deref for Accepted { 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,19 +965,8 @@ impl MoqSession { session: moq_net::Session, publish: crate::origin::Origin, subscribe: crate::origin::Origin, - #[cfg(not(target_arch = "wasm32"))] driver: tokio::task::JoinHandle>, ) -> Self { - #[cfg(not(target_arch = "wasm32"))] - 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), - } - }); - #[cfg(target_arch = "wasm32")] - let driver = crate::ffi::Background::default(); - 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/test.rs b/rs/moq-ffi/src/test.rs index cb5d8815996..3b3eca6d288 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -5475,7 +5475,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); @@ -7413,7 +7416,7 @@ async fn auth_tokens_join_and_leave_the_union() { let serving = tokio::spawn(async move { let request = listener.accept().await.expect("accept"); let mut requests = request.auth().requests().unwrap(); - 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() { @@ -7427,7 +7430,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/MIGRATE.md b/rs/moq-gst/MIGRATE.md index 8da134ac585..54e1317b82a 100644 --- a/rs/moq-gst/MIGRATE.md +++ b/rs/moq-gst/MIGRATE.md @@ -4,6 +4,14 @@ How to update your pipelines for each breaking change, newest first. ## Unreleased +### Keylog files must be private ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +The existing boolean keylog setting still opts into `SSLKEYLOGFILE`; no ABI or +property name changes. Its destination is captured when configuration is adapted. +Native QUIC initialization now refuses group/other-accessible existing Unix files +(for example mode 0644), without changing permissions; newly created logs use 0600. +Make an existing log private before enabling key logging. + ### `moqsrc` audio caps are the standard ones `moqsrc` audio pads now carry `rate` and `channels` as `int`, and Opus pads the mapping fields (`channel-mapping-family`, `stream-count`, `coupled-count`, plus `channel-mapping` beyond stereo) with the OpusHead and an empty OpusTags as `streamheader`, so `decodebin` and `opusdec` accept them and `opusdec` applies the output gain. diff --git a/rs/moq-gst/src/sink/imp.rs b/rs/moq-gst/src/sink/imp.rs index 2ecda4e23c3..0f324e7012c 100644 --- a/rs/moq-gst/src/sink/imp.rs +++ b/rs/moq-gst/src/sink/imp.rs @@ -2290,7 +2290,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); @@ -2299,7 +2299,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 @@ -2312,7 +2312,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/sink/session.rs b/rs/moq-gst/src/sink/session.rs index 7b07596b63c..66cbb01578d 100644 --- a/rs/moq-gst/src/sink/session.rs +++ b/rs/moq-gst/src/sink/session.rs @@ -151,7 +151,7 @@ pub struct ResolvedSettings { pub(super) fn connect_config(settings: &ResolvedSettings) -> moq_tokio::connect::Config { let mut config = moq_tokio::connect::Config::default(); config.tls.insecure = Some(settings.tls_disable_verify); - config.backoff.timeout = Some(std::time::Duration::ZERO); + config.backoff.timeout = Some((std::time::Duration::ZERO).into()); config } @@ -161,12 +161,14 @@ pub(super) fn quic_config(settings: &ResolvedSettings) -> moq_tokio::quic::Confi // The properties are optional and the config fields are not: an unset property // leaves the library default rather than overriding it with one of its own. if let Some(idle_timeout) = settings.quic_idle_timeout { - config.idle_timeout = Some(idle_timeout); + config.idle_timeout = Some((idle_timeout).into()); } if let Some(keep_alive) = settings.quic_keep_alive { - config.keep_alive = Some(keep_alive); + config.keep_alive = Some((keep_alive).into()); } - config.keylog = Some(settings.quic_keylog); + config.keylog = settings + .quic_keylog + .then(|| std::env::var_os("SSLKEYLOGFILE").unwrap_or_default().into()); config } diff --git a/rs/moq-gst/src/source/imp.rs b/rs/moq-gst/src/source/imp.rs index 9316007c684..f83a38bc07e 100644 --- a/rs/moq-gst/src/source/imp.rs +++ b/rs/moq-gst/src/source/imp.rs @@ -221,7 +221,7 @@ fn connect_config(settings: &ResolvedSettings) -> moq_tokio::connect::Config { let mut config = moq_tokio::connect::Config::default(); config.version = settings.versions.clone(); config.tls.insecure = Some(settings.tls_disable_verify); - config.backoff.timeout = Some(std::time::Duration::ZERO); + config.backoff.timeout = Some((std::time::Duration::ZERO).into()); config } @@ -236,7 +236,9 @@ fn connect( let origin = moq_tokio::origin::spawn(); let consumer = origin.consume(); let mut quic = moq_tokio::quic::Config::default(); - quic.keylog = Some(settings.quic_keylog); + quic.keylog = settings + .quic_keylog + .then(|| std::env::var_os("SSLKEYLOGFILE").unwrap_or_default().into()); let client = config.init(quic)?.with_subscriber(origin); let connection = client.connect(settings.url.clone()); Ok((client, connection, consumer)) @@ -2640,7 +2642,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`. @@ -3357,7 +3363,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 19ac4c6e9d7..e67d26dcd01 100644 --- a/rs/moq-hls/src/server/routes.rs +++ b/rs/moq-hls/src/server/routes.rs @@ -645,12 +645,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/benches/transport-config/README.md b/rs/moq-net/benches/transport-config/README.md new file mode 100644 index 00000000000..df52f1d3a8c --- /dev/null +++ b/rs/moq-net/benches/transport-config/README.md @@ -0,0 +1,21 @@ +# Driver panic boundary CPU measurement + +The existing session benchmark sweeps 1/4/16 publishers and 1/4/16 viewers, +with one relay, 64-byte frames, subscribe/fetch, bounded/unbounded stream windows, +and lite06/lite07. Each measured group includes its real protocol driver polls. +The CSV retains all 72 cases in nanoseconds of thread CPU per delivered group. + +```sh +SESSION_CPU=1 cargo bench --locked -p moq-net --bench session -- session_delivery_group_cost_cpu --quick --noplot +``` + +Measured sequentially in the Nix shell on an Intel Core Ultra 7 270K Plus +(24 cores, Linux), before the driver +unwind guard, after it, and once more without changing that guard. Criterion +`--quick` provides only a small sample, and other work shared the host. +Before/after median relative change was -4.09% (case range -44.78% to -0.09%). +The unchanged-guard A/A median was +3.21% (range -0.34% to +72.68%). These runs +show no reliable effect distinguishable from host/order noise and establish no +speedup. They do not measure full media decoding, network impairment, or throughput. +The new guard adds a thread-unwinding check at the actual protocol poll boundary; +there is no extra session task, handle, or per-peer state. diff --git a/rs/moq-net/benches/transport-config/driver-cpu.csv b/rs/moq-net/benches/transport-config/driver-cpu.csv new file mode 100644 index 00000000000..a230ef134f3 --- /dev/null +++ b/rs/moq-net/benches/transport-config/driver-cpu.csv @@ -0,0 +1,73 @@ +case,before_ns,guard_ns,guard_repeat_ns +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64,50183.16015625,37646.213134765625,39710.514892578125 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/fetch,52790.87353515625,41884.928955078125,43312.508544921875 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/window=64,75854.72119140625,73590.6396484375,76080.37744140625 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/window=64/fetch,80760.7109375,59712.51513671875,61698.82763671875 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64,370676.1328125,300851.216796875,377949.44921875 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/fetch,629832.5078125,457140.80078125,470508.98046875 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/window=64,925865.26171875,663753.83984375,683928.90625 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/window=64/fetch,834008.7734375,635755.43359375,643702.0390625 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64,1265333.96875,1242810.296875,1561410.1672363281 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/fetch,2038115.234375,1762444.25,2242228.1875 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/window=64,2952059.0,2692863.4375,4650130.703125 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/window=64/fetch,2990411.71875,2438096.546875,4082154.84375 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64,39001.504150390625,37767.05517578125,39056.813232421875 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/fetch,42956.94580078125,41986.4384765625,43505.26220703125 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/window=64,74151.59033203125,73844.74267578125,74635.11669921875 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/window=64/fetch,60963.77099609375,60573.80712890625,61047.00048828125 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64,745612.75,582156.0,772924.5 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/fetch,1039558.6640625,1011842.1015625,1147566.47265625 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/window=64,1218984.0078125,1166786.6875,1302845.7734375 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/window=64/fetch,1351437.78125,1300887.640625,1404235.890625 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64,2720349.599609375,1502088.921875,1950472.12109375 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/fetch,3961856.5,2320213.125,3168449.375 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/window=64,3695243.03125,3242766.65625,4869371.1875 +session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/window=64/fetch,4725794.25,3114908.0625,4580323.8125 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64,41500.59814453125,40524.227294921875,41363.01318359375 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/fetch,47597.6220703125,46250.9033203125,47347.150390625 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/window=64,79995.001953125,78488.1748046875,80197.43994140625 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/window=64/fetch,74592.8857421875,72502.66162109375,74127.49072265625 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64,319643.818359375,314451.185546875,318074.259765625 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/fetch,519287.46875,502484.55859375,513168.24609375 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/window=64,715518.48046875,704410.85546875,714778.375 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/window=64/fetch,765503.09375,755636.96875,773752.2578125 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64,1391173.65625,1293667.1171875,1408029.390625 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/fetch,2673712.53125,1958655.8125,2386460.78125 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/window=64,3531127.71875,2868261.6875,3415383.9375 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/window=64/fetch,3923790.375,2939503.21875,3720854.5625 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64,41430.951416015625,40018.86767578125,41233.42431640625 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/fetch,47664.82275390625,45804.92431640625,47048.1875 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/window=64,79609.1494140625,78464.44970703125,79709.5810546875 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/window=64/fetch,74536.80419921875,72796.37109375,74044.2099609375 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64,643731.8125,624050.10546875,637781.82421875 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/fetch,1148267.609375,1089726.6328125,1137440.5390625 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/window=64,1338910.40625,1255691.5390625,1334100.0703125 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/window=64/fetch,1601942.234375,1526654.125,1567770.546875 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64,1960019.5625,1636939.5,1733797.390625 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/fetch,3639593.9375,2579613.8125,3020336.75 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/window=64,4355201.03125,3503723.03125,3825162.0 +session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/window=64/fetch,5103170.9375,3754010.09375,5415373.5 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64,39215.77294921875,38416.740478515625,38967.074462890625 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/fetch,93264.8837890625,92716.69189453125,93264.7939453125 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/window=64,69132.3203125,67829.0068359375,69253.79833984375 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/window=64/fetch,118088.3291015625,116315.345703125,117014.3291015625 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64,311477.94921875,300802.6015625,303428.498046875 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/fetch,767826.65625,742164.1875,755281.4921875 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/window=64,619302.72265625,618717.1171875,618986.203125 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/window=64/fetch,941165.1328125,926533.6328125,935997.8359375 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64,1560062.6328125,1256208.359375,1287900.875 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/fetch,3528494.53125,2879482.0625,3074569.9375 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/window=64,3519227.96875,2627837.65625,2618881.0625 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/window=64/fetch,5917362.375,3499539.09375,3883366.5625 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64,39471.305908203125,38000.768310546875,38854.879150390625 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/fetch,92976.12109375,91818.884765625,92261.6767578125 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/window=64,68712.44384765625,67318.54833984375,68569.74560546875 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/window=64/fetch,115987.8935546875,114341.3193359375,116050.564453125 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64,613106.8046875,598102.96484375,607699.6171875 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/fetch,1629111.90625,1594675.609375,1644068.890625 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/window=64,1106498.546875,1087027.890625,1099771.484375 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/window=64/fetch,2159342.5,1938225.234375,2022545.453125 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64,2047484.703125,1581325.34375,1876397.7265625 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/fetch,5095892.25,3807310.6875,4967984.5 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/window=64,3430416.5625,3216688.0,4886305.5771484375 +session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/window=64/fetch,6108782.25,4722654.625,6276647.0 diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 3e31d37bec7..643d47cd07f 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -578,6 +578,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>, @@ -601,6 +602,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(), @@ -708,6 +710,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) } @@ -990,14 +999,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(_) + )); } /// A transport made deliberately `!Send` by an `Rc` marker on the session and diff --git a/rs/moq-net/src/driver.rs b/rs/moq-net/src/driver.rs index d560f799e80..de61ace009e 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`]. Its `Send`-ness /// follows its transport. +/// 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, S: crate::transport::poll::Session>(&'a mut State); + +impl Drop for Polling<'_, S> { + 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. /// /// The lite driver is a named machine, so the machine's `Send`-ness follows @@ -61,9 +79,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 5d217ea7cbc..a6beb98fbe4 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -245,7 +245,7 @@ impl Session { /// [`Error::IdleTimeout`] for an idle timeout, `Error::Session(SessionError::Cancel)` /// for a local close, and [`Error::Transport`] otherwise. See [`Error::from_transport`]. /// If the runtime drops the driver instead of running it to completion, this resolves - /// with [`Error::Cancel`]. + /// with [`Error::Cancel`]. A panic while polling the driver is a transport failure instead. pub async fn closed(&self) -> Error { match self .closed @@ -462,6 +462,17 @@ enum SamplerMode { } impl Supervisor { + pub(crate) fn panicked(&mut self, error: Error) { + if let Ok(mut closed) = self.closed.write() { + *closed = Some(Ended { + err: error.clone(), + drain: Some(Err(error)), + }); + } + self.local_close.store(true, std::sync::atomic::Ordering::Relaxed); + self.session + .close(SessionError::Internal.to_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 478d337de77..e7414779b2b 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 } lru-slab = "0.1.2" # Without the runtime, which is meant for dev-dependencies; a driver brings its own. moq-clock = { version = "0.11.0", path = "../moq-clock", default-features = false } @@ -71,7 +53,6 @@ rand_pcg = "0.10" 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/MIGRATE.md b/rs/moq-quic/MIGRATE.md index fd3da11282d..584718c248a 100644 --- a/rs/moq-quic/MIGRATE.md +++ b/rs/moq-quic/MIGRATE.md @@ -4,6 +4,17 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Crypto and token-log features are consolidated ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +Use `ring` instead of `rustls-ring` or `rustls`. `aws-lc-rs` and `rustls-aws-lc-rs` +select AWS-LC and both providers re-export `rustls`. Remove `tracing-log`, `bloom`, +`platform-verifier`, `rustls-log`, `aws-lc-rs-fips`, and `rustls-aws-lc-rs-fips` from dependencies. +Build a custom rustls client config instead of `ClientConfig::try_with_platform_verifier`. +`BloomTokenLog` is always available and the default sends two validation tokens, +including `default-features = false` builds which previously used `NoneTokenLog` and sent zero. +To preserve that explicit policy use `validation_token.log(Arc::new(NoneTokenLog)).sent(0)`. +Normal defaults and moq-tokio's existing Bloom behavior are unchanged. + Each entry is a change from `quinn-proto`. ### `StreamEvent::Acked` (#5817) diff --git a/rs/moq-quic/src/config/mod.rs b/rs/moq-quic/src/config/mod.rs index 0409b2ffeb0..e9d8b6557fa 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, SystemTime, @@ -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 /// @@ -541,12 +538,8 @@ impl ValidationTokenConfig { #[allow(rustdoc::redundant_explicit_links)] // which links are redundant depends on features /// 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 @@ -556,8 +549,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 @@ -566,14 +558,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, } } } @@ -661,16 +650,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 617a6e3e36e..5aaaed6592a 100644 --- a/rs/moq-quic/src/connection/mod.rs +++ b/rs/moq-quic/src/connection/mod.rs @@ -4564,7 +4564,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 4b2c55ca98b..3de5f82c193 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. @@ -39,7 +39,7 @@ 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")))] #[allow(clippy::disallowed_methods, clippy::disallowed_types)] mod tests; pub mod transport_parameters; @@ -47,9 +47,7 @@ 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; @@ -64,7 +62,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 555acb093d5..0e4cc0e0a36 100644 --- a/rs/moq-quic/src/packet.rs +++ b/rs/moq-quic/src/packet.rs @@ -935,14 +935,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 276bfc08867..22dca8b7937 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 dfb16aa8b78..e98d5a475db 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 f672b08bdda..8b397fa2003 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -117,7 +117,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/MIGRATE.md b/rs/moq-relay/MIGRATE.md index 866a7e1d2d0..dc63bc46ca9 100644 --- a/rs/moq-relay/MIGRATE.md +++ b/rs/moq-relay/MIGRATE.md @@ -5,6 +5,13 @@ Rolling a cluster across a release also follows the [upgrade guide](https://moq. ## Unreleased +### Embedder durations use the shared adapter ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +Set `drain_timeout` with `Some(std_duration.into())`; `None` uses the default. +ACME `timeout`, stats `linger`, and cache `duration` use the same `moq_tokio::cli::Duration` +adapter. TOML and CLI duration strings retain their names. Later Rust assignments +replace parsed values directly; there are no hidden CLI shadow fields. + ### `receive_window` has a floor (#5878) The `[quic]` and `[peer.quic]` `receive_window` must be at least 67108880 bytes (64 MiB plus 16), or the relay refuses to start; the `[quic]` default is now 128 MiB. @@ -58,10 +65,13 @@ A CLI that flattens them depends on `usage-rs = "7"` under its own name: the der Pass `auth::Error::Refused(moq_auth::Refusal::default())` to `Admission::refuse` for an unattributed refusal, or set its `reason`, optional verified `root`, and `tier`. Match `auth::Error::Refused(refusal)` to inspect it; never use the dialed path as refusal attribution. -### Key logging is opt-in (#5907) +### Key logging names its file ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) -`SSLKEYLOGFILE` alone no longer writes TLS session secrets. -Add `--quic-keylog` (`MOQ_QUIC_KEYLOG`, `[quic] keylog = true`) to capture them; without `SSLKEYLOGFILE` it stops startup. +Replace `SSLKEYLOGFILE` and boolean `[quic] keylog` with `keylog = "/private/keys.log"`, +`--quic-keylog /private/keys.log`, or `MOQ_QUIC_KEYLOG=/private/keys.log`. +Tokio and io\_uring share this logger. Initialization refuses an inaccessible path +or a group/other-accessible existing Unix file, such as mode 0644; new files are mode 0600. +Iroh currently refuses an explicit keylog path when enabled. ### Cluster dials build from a template (#5674) @@ -101,9 +111,9 @@ A qlog directory gets one file per endpoint instead of one per connection. ### A QUIC load-balancer nonce needs its server id (#5114) -`listen.lb_nonce` (`--listen-quic-lb-nonce`, `MOQ_LISTEN_QUIC_LB_NONCE`) without `lb_id` stops startup. -Setting `lb_id` or `lb_nonce` together with `load_balancer` also stops; `lb_id` used to win. -Set `load_balancer`, or `lb_id` alone (nonce 8), or `lb_id` with `lb_nonce`. +Move `listen.lb_id` / `listen.lb_nonce` or `listen.load_balancer` into `[listen.lb]` +with `id` and optional `nonce` (default 8). Existing `--listen-quic-lb-*` flags keep +their names and override that same table. A nonce without an ID stops startup. ### Settings no listener reads stop startup (#5006) @@ -165,7 +175,7 @@ Environment variables follow the flag (`MOQ_SERVER_BIND` is `MOQ_LISTEN`), and a | `--client-connect` | `--connect` | | `--client-*` | `--connect-*` | | `--client-failover-delay` | `--connect-race` | -| `--client-reconnect=false` | `--connect-once` | +| `--client-reconnect=false` | Removed; CLI dials reconnect. Native callers use `Client::with_reconnect(false)`. | | `--tls-disable-verify`, `--client-tls-disable-verify` | `--connect-tls-insecure` | | `--server-quic-*`, `--client-quic-*` | `--quic-*`, applied to both directions | | TOML `[server]`, `[client]` | `[listen]`, `[connect]` | diff --git a/rs/moq-relay/benches/auth.rs b/rs/moq-relay/benches/auth.rs index 1037e606bd8..7e868101477 100644 --- a/rs/moq-relay/benches/auth.rs +++ b/rs/moq-relay/benches/auth.rs @@ -70,9 +70,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(); @@ -185,9 +184,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 46a4be65aab..cd1f83d256a 100644 --- a/rs/moq-relay/src/acme.rs +++ b/rs/moq-relay/src/acme.rs @@ -94,20 +94,14 @@ 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 = "crate::duration::serde_option")] - 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, } impl Config { fn timeout(&self) -> Option { - self.timeout_arg - .map(crate::duration::Duration::into_std) - .or(self.timeout) + self.timeout.map(Into::into) } /// Validate against the listeners it must serve through and start the client, or `None` when unconfigured. diff --git a/rs/moq-relay/src/acme/tests.rs b/rs/moq-relay/src/acme/tests.rs index 4358c8aaab0..780aed8e1e5 100644 --- a/rs/moq-relay/src/acme/tests.rs +++ b/rs/moq-relay/src/acme/tests.rs @@ -360,7 +360,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()]; @@ -438,9 +438,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 @@ -550,7 +549,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(); @@ -580,7 +579,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(); @@ -606,7 +605,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 3ab38e254bf..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 = "crate::duration::serde_option")] - 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,10 +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(crate::duration::Duration::into_std) - .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)); @@ -221,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 21486367b99..c363d897478 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -2077,8 +2077,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() @@ -3062,8 +3061,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(); @@ -3515,8 +3516,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())); @@ -3658,7 +3658,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()); @@ -3699,7 +3699,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() @@ -3777,7 +3777,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 5e6a1370668..eed54945cb6 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 caps for ordinary client sessions. #[usage(flatten)] @@ -114,18 +115,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 = "crate::duration::serde_duration")] - 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)] @@ -144,36 +141,6 @@ pub struct Config { pub iroh: moq_tokio::iroh::Config, } -impl Default for Config { - fn default() -> Self { - Self { - session: Default::default(), - peer: Default::default(), - 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(), - 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. // @@ -210,9 +177,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(crate::duration::Duration::into_std) - .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 @@ -376,8 +343,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(()) } } @@ -847,7 +812,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); @@ -1061,7 +1026,7 @@ ws = false env: &[], 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 { @@ -1070,7 +1035,7 @@ ws = false env: &[], 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 { @@ -1311,7 +1276,7 @@ ws = false } /// A QUIC-LB nonce with no server id stops startup from TOML and the - /// environment, and `lb_id` no longer hides `load_balancer`. + /// environment, 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. @@ -1351,41 +1316,31 @@ ws = false refuse( "nonce.toml", - "[listen]\nlb_nonce = 8\n", + "[listen.lb]\nnonce = 8\n", &[], &[], "requires --listen-quic-lb-id", ); - refuse( - "both.toml", - "[listen]\nlb_id = \"ab\"\nload_balancer = { id = \"cd\", nonce = 4 }\n", - &[], - &[], - "cannot be combined", - ); - refuse( - "env-id.toml", - "[listen]\nload_balancer = { id = \"cd\", nonce = 4 }\n", - &[("MOQ_LISTEN_QUIC_LB_ID", "ab")], - &[], - "cannot be combined", - ); - refuse( - "cli-id.toml", - "[listen]\nload_balancer = { id = \"cd\", nonce = 4 }\n", - &[], - &["--listen-quic-lb-id", "ee"], - "cannot be combined", - ); #[cfg(feature = "quic")] { 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)); } } } diff --git a/rs/moq-relay/src/duration.rs b/rs/moq-relay/src/duration.rs deleted file mode 100644 index 10686d89016..00000000000 --- a/rs/moq-relay/src/duration.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! Human-readable duration adapters used only at the CLI and serde boundary. - -use std::fmt; -use std::str::FromStr; - -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub(crate) struct Duration(std::time::Duration); - -impl Duration { - pub(crate) const fn into_std(self) -> std::time::Duration { - self.0 - } -} - -impl FromStr for Duration { - type Err = humantime::DurationError; - - fn from_str(value: &str) -> Result { - humantime::parse_duration(value).map(Self) - } -} - -impl fmt::Display for Duration { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - humantime::format_duration(self.0).fmt(f) - } -} - -impl Serialize for Duration { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.collect_str(self) - } -} - -impl<'de> Deserialize<'de> for Duration { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - value.parse().map_err(serde::de::Error::custom) - } -} - -pub(crate) mod serde_duration { - use serde::{Deserialize, Deserializer, Serializer}; - - pub(crate) fn serialize(value: &std::time::Duration, serializer: S) -> Result - where - S: Serializer, - { - serializer.collect_str(&humantime::format_duration(*value)) - } - - pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - humantime::parse_duration(&value).map_err(serde::de::Error::custom) - } -} - -pub(crate) mod serde_option { - use serde::{Deserialize, Deserializer, Serializer}; - - pub(crate) fn serialize(value: &Option, serializer: S) -> Result - where - S: Serializer, - { - match value { - Some(value) => serializer.collect_str(&humantime::format_duration(*value)), - None => serializer.serialize_none(), - } - } - - pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - let value = Option::::deserialize(deserializer)?; - value - .map(|value| humantime::parse_duration(&value).map_err(serde::de::Error::custom)) - .transpose() - } -} diff --git a/rs/moq-relay/src/incoming.rs b/rs/moq-relay/src/incoming.rs index 86ea3e3d492..6561fe9a166 100644 --- a/rs/moq-relay/src/incoming.rs +++ b/rs/moq-relay/src/incoming.rs @@ -55,7 +55,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 } @@ -76,7 +76,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() @@ -104,7 +104,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) } @@ -134,14 +134,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/lib.rs b/rs/moq-relay/src/lib.rs index b3ed94f3000..6b7f5555f67 100644 --- a/rs/moq-relay/src/lib.rs +++ b/rs/moq-relay/src/lib.rs @@ -20,7 +20,6 @@ pub mod cluster; mod config; mod connection; mod critical; -mod duration; mod fetch; mod http_client; mod incoming; diff --git a/rs/moq-relay/src/relay.rs b/rs/moq-relay/src/relay.rs index 41f0ca1af36..1a5214367bd 100644 --- a/rs/moq-relay/src/relay.rs +++ b/rs/moq-relay/src/relay.rs @@ -129,9 +129,6 @@ impl Relay { config.connect.extensions.ietf = true; // Every TLS listener serves the ACME certificate, so install it before any is built. let acme = config.acme.init(&config.listen.tls, &config.web.https)?; - 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, @@ -191,7 +188,10 @@ 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()?; + let served = match &acme { + Some(acme) => Some(moq_tokio::tls::Serve::resolver(acme.resolver(), &config.listen.tls)?), + 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 865e8454f24..335bddea04b 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 21f5f2fdaf8..354da48a0ee 100644 --- a/rs/moq-relay/src/stats.rs +++ b/rs/moq-relay/src/stats.rs @@ -85,13 +85,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 = "crate::duration::serde_option")] - pub linger: Option, - #[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,7 +99,6 @@ impl Default for Config { node: None, depth: 0, linger: None, - linger_arg: None, } } } @@ -111,7 +106,7 @@ impl Default for Config { impl Config { /// The linger after command-line overrides; `None` keeps the producer's default. pub(crate) fn linger(&self) -> Option { - self.linger_arg.map(crate::duration::Duration::into_std).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 371bea3f427..c061e54d954 100644 --- a/rs/moq-relay/src/uring.rs +++ b/rs/moq-relay/src/uring.rs @@ -139,7 +139,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" ); @@ -153,10 +152,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 @@ -208,9 +207,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)?; @@ -984,13 +983,11 @@ mod tests { /// instead of binding and logging nothing. #[test] #[allow(clippy::field_reassign_with_default)] - fn bind_refuses_a_keylog_without_sslkeylogfile() { - // SAFETY: nextest runs each test in its own process, so nothing else reads the environment. - unsafe { std::env::remove_var("SSLKEYLOGFILE") }; + 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:#}" @@ -1054,7 +1051,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(); @@ -1063,7 +1060,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(); @@ -1108,11 +1105,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(); @@ -1121,7 +1118,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 de58da2f33e..22824ac4498 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -560,7 +560,6 @@ 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![ @@ -568,7 +567,9 @@ fn build_acme_config( b"http/1.1".to_vec(), rustls_acme::acme::ACME_TLS_ALPN_NAME.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 { @@ -2074,10 +2075,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/tests/drills.rs b/rs/moq-relay/tests/drills.rs index f872f32bcef..040ed619162 100644 --- a/rs/moq-relay/tests/drills.rs +++ b/rs/moq-relay/tests/drills.rs @@ -315,8 +315,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 } @@ -337,9 +337,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 } @@ -1011,7 +1011,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 cd5784fdae3..083721e1ca4 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 { @@ -1130,11 +1132,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. @@ -1461,7 +1465,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 }; @@ -1502,8 +1506,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"); let port = relay.tcp_addr().expect("TCP listener bound").port(); let admissions = relay.admissions().expect("an empty [auth] hands over the admissions"); @@ -1636,10 +1640,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 798b8784ad6..7fe9139381d 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_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 581ec2260a2..d4f5c0c9ea2 100644 --- a/rs/moq-relay/tests/integration/cluster_unknown.rs +++ b/rs/moq-relay/tests/integration/cluster_unknown.rs @@ -126,7 +126,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 749c0c5fa0d..4fb68a02bc9 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"); 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"); 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 3206c7f618f..a973905f58e 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}/"))]; @@ -268,7 +268,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"); @@ -302,8 +302,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}/"))]; @@ -426,8 +426,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()]; @@ -509,8 +509,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( @@ -775,8 +775,8 @@ async fn cluster_reconnects_on_empty_uri_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()); // Only lite-07 carries the epoch that lets the redial resume the subscription. client_config.version = vec![LITE_07.parse().unwrap()]; @@ -902,11 +902,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 ede5bec86e0..40ef1f5743a 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 } @@ -102,9 +102,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 b00d28648ce..e2bd1e76679 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 { @@ -208,10 +210,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"); @@ -357,7 +364,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(); @@ -472,9 +479,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); @@ -523,10 +532,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) @@ -567,11 +575,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 @@ -974,7 +983,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"); 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 58c37517d29..1f120b08976 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 e76cc54948e..a5142bd9b8c 100644 --- a/rs/moq-relay/tests/integration/session_revalidate.rs +++ b/rs/moq-relay/tests/integration/session_revalidate.rs @@ -240,10 +240,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 5b7be611e53..8d166b2998a 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 ba2569ade95..c2c84187cd5 100644 --- a/rs/moq-relay/tests/integration/stack.rs +++ b/rs/moq-relay/tests/integration/stack.rs @@ -10,7 +10,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 37b6dda9407..2d43a4a27fb 100644 --- a/rs/moq-relay/tests/integration/wire_stats.rs +++ b/rs/moq-relay/tests/integration/wire_stats.rs @@ -36,7 +36,7 @@ async fn wire_sources(io_uring: bool) { config.stats.enabled = true; config.internal.listen = Some("127.0.0.1:0".parse().unwrap()); config.stats.interval = 3600; - config.drain_timeout = Duration::ZERO; + 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 46006d4cf5a..73ad3a07cde 100644 --- a/rs/moq-rtc/src/server/whep.rs +++ b/rs/moq-rtc/src/server/whep.rs @@ -261,12 +261,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 d8765b8f770..6e164c59cd7 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" @@ -85,7 +85,7 @@ moq-clock = { workspace = 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 diff --git a/rs/moq-tokio/MIGRATE.md b/rs/moq-tokio/MIGRATE.md index 4f4a034a986..95a27634586 100644 --- a/rs/moq-tokio/MIGRATE.md +++ b/rs/moq-tokio/MIGRATE.md @@ -4,6 +4,14 @@ How to update your code for each breaking change, newest first. ## Unreleased +### External TLS resolvers carry an explicit client-auth policy ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +Replace `listen.tls.resolver = Some(resolver)` with +`tls::Serve::resolver(resolver, &listen.tls)?`, then pass the result in `server::Config.tls` +and clear `server.listen.tls`. The constructor retains `root` and `peers` verification +and refuses `cert`, `key`, `generate`, or `identity`, which would hide a certificate source. +A shared `server.tls` and configured `listen.tls` remain mutually exclusive. + ### The connection receive window is 128 MiB, and has a floor (#5878) `quic::DEFAULT_RECEIVE_WINDOW` is 128 MiB instead of 64 MiB, since incomplete messages now hold connection credit until complete. @@ -44,16 +52,21 @@ The `Args` and `Config` derives now implement usage-rs 7 traits, and `cli::usage A CLI that flattens these types depends on `usage-rs = "7"` under its own name: the derives emit `::usage_rs` paths and no longer read `Cargo.toml`. Rename a `usage = { package = "usage-rs" }` alias to `usage-rs`, or add `#[usage(crate = usage)]` to each derive. -### Key logging is opt-in (#5907) +### Key logging takes an explicit path ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) -`SSLKEYLOGFILE` alone no longer writes TLS session secrets. -Set `quic::Config::keylog` (`--quic-keylog`, `MOQ_QUIC_KEYLOG`, `[quic] keylog`) as well; it now covers the iroh backend too. -Setting it without `SSLKEYLOGFILE` fails with `Error::KeylogUnset`. +`SSLKEYLOGFILE` alone no longer writes session secrets. Set `quic.keylog = Some(path)` +or `--quic-keylog /private/keys.log` (`MOQ_QUIC_KEYLOG`, `[quic] keylog = "/private/keys.log"`). +The native QUIC backend opens the file during initialization. New Unix files use mode 0600; +an existing mode-0644 log is refused without changing its permissions. Make the file private explicitly. +Iroh currently refuses an explicit path when enabled because its pinned API supports only process-wide environment logging. +It also refuses explicit zero `keep_alive`: its pinned API cannot clear the default heartbeat. +Omit that setting or use a positive interval for Iroh; other backends retain zero-off support. +`tls::KeyLog::open(path)` provides the same explicit logger for custom rustls configurations. ### QUIC limits are `moq_quic::Limits`, set through `Request::quic` (#5871) `server::Request::set_quic_limits` and `Error::QuicLimits` are gone. -`Request::quic()` returns the `quic::Connection` under a QUIC or WebTransport request, or `None` on other transports, and its `set_limits` takes `quic::Limits`, now `moq_quic::Limits`, where each `None` field keeps that limit. +`Request::quic()` returns the `quic::driver::Connection` under a QUIC or WebTransport request, or `None` on other transports, and its `set_limits` takes `quic::Limits`, now `moq_quic::Limits`, where each `None` field keeps that limit. Call `set_limits` on `request.quic()` instead, starting from `quic::Limits::default()` and setting only the fields you change. `quic::Resolved`'s `receive_window`, `stream_receive_window`, and `send_window` are plain `u64`s with the defaults filled in, and `quic.max_streams = 0` is refused. @@ -77,23 +90,46 @@ A qlog directory gets one file per endpoint instead of one per connection. ### Workers are unpinned by default (#5688) `worker::Config::new(count)` no longer pins worker *i* to core *i*. -Call `with_pin(true)` to keep pinning. +Set the existing `pin` field to keep pinning; `with_pin` is removed. ```rust -let workers = moq_tokio::worker::Config::new(8).with_pin(true); +let mut workers = moq_tokio::worker::Config::new(8); +workers.pin = true; ``` -### Accepted sessions return their driver task (#5562) +### Accepted sessions are supervised internally ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +`server::Request::ok()` returns a `Session`. Read `session.closed()` for its terminal +cause, including a driver panic; there is no task handle to retain or await. +The protocol driver records a panic before unwinding, so an executor that catches +it cannot turn its later drop into ordinary cancellation. + +### Config durations use one human-readable value ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +Set connect `race`, `resolution_delay`, `timeout`; listen `timeout`; QUIC `idle_timeout`, +`keep_alive`; backoff `initial`, `max`, `timeout`; GOAWAY `handover`; and WebSocket `delay` +with `Some(std_duration.into())`, or parse a `moq_tokio::cli::Duration` from `"500ms"`. +`None` uses the runtime default, including values built with `Default::default()`. +TOML keys stay unchanged and take human-readable strings. A later typed assignment +replaces a parsed CLI value instead of losing to a hidden shadow field. +`websocket::Options.timeout = None` uses the ten-second handshake default; +use `Some(std::time::Duration::ZERO)` to wait indefinitely. +`Backoff::initial()`, `max()`, and `timeout()` report the effective retry values, +including the defaults used by foreign settings UIs. Backoff retains its existing +50ms pacing floor and cap-at-least-initial semantics; +zero `timeout` disables the retry deadline. -`server::Request::ok()` returns `(session, driver)`. -Await the driver before classifying the session close, so a driver panic reports `Error::Panic` instead of the cancellation its teardown leaves behind. +`connect::Config.quic` and `listen::Config.quic` are private legacy parsing fields. +Supply shared transport tuning through the `quic` argument to `Config::init` +or `server::Config.quic`. -### Config durations are optional +### Reconnect policy belongs to each client clone ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) -Wrap durations in `Some(...)` for connect `race`, `resolution_delay`, and `timeout`; listen `timeout`; QUIC `idle_timeout` and `keep_alive`; backoff `initial`, `max`, and `timeout`; GOAWAY `handover`; and WebSocket `delay`. -`None` uses the default, and `Some(Duration::ZERO)` keeps each knob's zero behavior. -A value set in code now wins over the parser default unless the flag or environment variable was actually given. -TOML keys are unchanged. +Set `client.with_reconnect(false)` for a one-shot native dial, or `true` to redial. +The duplicate `connect.once` field and `--connect-once` / `MOQ_CONNECT_ONCE` are removed. +Released `--client-reconnect` / `MOQ_CLIENT_RECONNECT` and TOML `reconnect` are refused +with this native API guidance. CLI clients always reconnect and no longer offer +an operator override. Foreign bindings retain their existing `once` setting. ### Rotating TLS identities are a `tls::Serve` (#5312) @@ -105,15 +141,18 @@ A TLS trust-root watcher that fails to register now fails client and server cons The reconnect counter is `connects()`; rename `epoch()` calls. -### A QUIC load-balancer nonce needs its server id (#5114) +### QUIC-LB settings have one path ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) -`listen.lb_nonce` without `lb_id` fails to start, so set `lb_id` next to it. -Setting `lb_id` (or `lb_nonce`) together with `load_balancer` also fails; `lb_id` used to win silently. -Set `load_balancer`, or `lb_id` alone (nonce 8), or `lb_id` with `lb_nonce`. +Move `listen.lb_id` / `listen.lb_nonce` or `listen.load_balancer` to `[listen.lb]` +with `id` and optional `nonce` (default 8). In Rust set `config.lb.id` and `config.lb.nonce`. +The duplicate public `quic::LoadBalancer` runtime record and `Config::load_balancer()` are private; +configure or inspect `listen::LoadBalancer` through `config.lb`. +The `--listen-quic-lb-id` / `--listen-quic-lb-nonce` flags and their environment variables +keep their names and update those same fields. A nonce without an ID is refused. ### Settings no listener reads stop startup (#5006) -A stream-only listener (TCP or Unix, no QUIC bind) refuses the QUIC-only `preferred_v4` / `preferred_v6`, `lb_id`, pinned `tls.peers`, and a TLS cert, key, or `generate` unless `tcp.tls` serves it. +A stream-only listener (TCP or Unix, no QUIC bind) refuses the QUIC-only `preferred_v4` / `preferred_v6`, `lb.id`, pinned `tls.peers`, and a TLS cert, key, or `generate` unless `tcp.tls` serves it. `unix.allow_*` needs `unix.bind`. Each used to be ignored: drop it, or add the listener it configures. diff --git a/rs/moq-tokio/src/cli.rs b/rs/moq-tokio/src/cli.rs index 4d283c48a36..900bcff7289 100644 --- a/rs/moq-tokio/src/cli.rs +++ b/rs/moq-tokio/src/cli.rs @@ -5,7 +5,7 @@ //! `parse()` is expected to take them first. A binary that parses more than once //! never reaches that code, so it has to answer them itself. Both shapes exist here: //! a TOML merge that layers CLI, env, and file with recorded provenance, and -//! moq-cli's repeated `--` stage grammar. A private duration adapter parses the +//! moq-cli's repeated `--` stage grammar. One duration adapter parses the //! human-readable values used by those flags and TOML keys. #[path = "deprecated.rs"] @@ -24,7 +24,7 @@ use usage_rs::config::{ }; pub use deprecated::Deprecated; -pub(crate) use duration::Duration; +pub use duration::Duration; /// Re-exported because [`Merge`] and [`answer`] use Usage types in their APIs. pub use usage_rs; diff --git a/rs/moq-tokio/src/cli/duration.rs b/rs/moq-tokio/src/cli/duration.rs index 8389156a6b6..06971e95ef1 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::ops::Deref; @@ -7,7 +7,7 @@ use std::time::Duration as StdDuration; use ::serde::{Deserialize, Deserializer, Serialize, Serializer}; -/// A duration that parses human-readable command-line values such as `500ms` or `2m`. +/// A configuration duration that parses and serializes human-readable values such as `500ms` or `2m`. #[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] pub struct Duration { value: StdDuration, @@ -79,33 +79,6 @@ impl<'de> Deserialize<'de> for Duration { } } -pub(crate) mod serde_duration { - /// Human-readable serialization for an optional duration. - pub mod option { - use ::serde::{Deserialize, Deserializer, Serializer}; - use std::time::Duration; - - pub fn serialize(value: &Option, serializer: S) -> Result - where - S: Serializer, - { - match value { - Some(value) => serializer.collect_str(&humantime::format_duration(*value)), - None => serializer.serialize_none(), - } - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> - where - D: Deserializer<'de>, - { - Option::::deserialize(deserializer)? - .map(|value| humantime::parse_duration(&value).map_err(::serde::de::Error::custom)) - .transpose() - } - } -} - // Exercise the same precedence contract for each duration config, including its // own interpretation of zero (disabled, unlimited, or a minimum pacing floor). #[cfg(test)] @@ -136,15 +109,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 6b7074796a8..4fb3d5e66cb 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -141,7 +141,7 @@ impl Client { versions, connect: config.url, timeout, - reconnect: !config.once.unwrap_or(false), + reconnect: true, backoff: config.backoff, goaway: config.goaway, pinned: !config.tls.fingerprint.is_empty(), @@ -238,7 +238,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 @@ -250,7 +250,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 @@ -823,13 +823,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(); @@ -1009,7 +1012,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""#, ] { @@ -1069,14 +1071,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 @@ -1098,7 +1099,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); } @@ -1120,19 +1121,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. @@ -1169,18 +1168,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"]); @@ -1264,7 +1251,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, @@ -1341,7 +1328,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(); @@ -1357,7 +1344,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; @@ -1571,10 +1558,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 e1767221ab8..db06c52444f 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] @@ -531,24 +528,15 @@ pub struct Config { /// /// 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 = "crate::cli::duration::serde_duration::option", - 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. + /// `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)] @@ -562,24 +550,15 @@ 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 = "crate::cli::duration::serde_duration::option", - 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 /// handshake. Defaults to 30 seconds; set to 0 to wait forever. @@ -590,24 +569,15 @@ pub struct Config { /// never speaks would hang the whole connect. [`crate::Connection`] only re-arms /// its backoff between attempts, so an attempt that never returns stalls the /// retry loop indefinitely. - /// `None` uses the default; an explicit command-line flag or environment variable wins. - #[usage(skip)] - #[serde( - default, - with = "crate::cli::duration::serde_duration::option", - 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). /// @@ -651,28 +621,10 @@ pub struct Config { #[serde(default)] pub tls: crate::tls::Connect, - /// Dial once instead of redialing (with backoff) whenever the session drops. - /// - /// A [`crate::Connection`] reconnects by default. With this set, the session's - /// close surfaces through [`crate::Connection::closed`] 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. + /// The released reconnect key, parsed only to explain its removal. /// - /// 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. + /// The CLI always reconnects; `--client-reconnect` is retained only for refusal. #[serde(default, skip_serializing)] #[usage(skip)] pub(crate) reconnect: Option, @@ -707,35 +659,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(), - 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 { @@ -755,7 +679,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")); @@ -793,17 +721,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 c80479761e9..15a56624ac9 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -5,8 +5,6 @@ pub mod auth; 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; @@ -73,24 +71,15 @@ pub struct Backoff { /// Doubles as the bar a session must stay up to count as healthy, so it is /// floored at 50ms: at zero every session would look healthy and the retry /// pacing would collapse. - /// `None` uses the default; an explicit command-line flag or environment variable wins. - #[usage(skip)] - #[serde( - default, - with = "crate::cli::duration::serde_duration::option", - skip_serializing_if = "Option::is_none" - )] - pub initial: Option, - - /// Initial delay before the first reconnect attempt. Defaults to 1s, floored at 50ms. + /// `None` uses the default. Zero retains the existing retry pacing floor. #[usage( name = "backoff-initial", long, env = "MOQ_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. #[usage( @@ -103,19 +92,10 @@ 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 = "crate::cli::duration::serde_duration::option", - skip_serializing_if = "Option::is_none" - )] - pub max: Option, - - /// Maximum delay between reconnect attempts. Defaults to 5s. + /// `None` uses the default. The maximum is never below the initial delay. #[usage(name = "backoff-max", long, env = "MOQ_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. /// @@ -126,54 +106,46 @@ pub struct Backoff { /// /// 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 `--backoff-timeout` given there wins over this field. + /// timeout. Set to `Some(Duration::ZERO.into())` for unlimited retries. `None` takes the default. When this config was + /// parsed from the command line, a later assignment replaces its parsed value. /// /// 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 = "crate::cli::duration::serde_duration::option", - 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. #[usage( name = "backoff-timeout", long, env = "MOQ_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, } 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, + timeout: None, } } } impl Backoff { - /// 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) + /// The initial retry delay and healthy-session threshold, including its 50ms floor. + pub fn initial(&self) -> Duration { + self.initial.map(Into::into).unwrap_or(DEFAULT_INITIAL).max(MIN_BACKOFF) + } + + /// The retry-delay cap, never lower than the initial delay. + pub fn max(&self) -> Duration { + self.max.map(Into::into).unwrap_or(DEFAULT_MAX).max(self.initial()) + } + + /// 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) } } @@ -350,32 +322,22 @@ 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 = "crate::cli::duration::serde_duration::option", - 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 = "goaway-handover", long, env = "MOQ_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, } impl Default for Goaway { fn default() -> Self { Self { redirect: Redirect::SameHost, - handover: Some(DEFAULT_HANDOVER), - handover_arg: None, + handover: None, } } } @@ -412,13 +374,8 @@ struct Pacing { 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 initial = backoff.initial(); + let max = backoff.max(); Self { initial, max: max.max(initial), @@ -440,11 +397,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), } } } @@ -679,7 +632,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), @@ -851,7 +804,7 @@ impl Connection { let backoff = client.backoff.clone(); let goaway = client.goaway.resolve(); let pacing = Pacing::new(&backoff); - let timeout = backoff.window(); + let timeout = backoff.timeout(); let initial = pacing.initial; let mut retry = Retry::new(initial); // Sticky across migrations: a redirect is an assignment, not a detour, so a @@ -1678,16 +1631,16 @@ 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.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.timeout(), Duration::ZERO, "0 means retry forever"); // With no flags, the parser defaults resolve to the typed defaults. let parsed = Wrapper::parse_from(&[]).unwrap(); @@ -1695,7 +1648,7 @@ mod tests { 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!(parsed.backoff.timeout(), DEFAULT_TIMEOUT); // And a flag still lands where the merge can see it. let parsed = @@ -1739,7 +1692,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() }; @@ -2015,7 +1968,7 @@ mod tests { // A zero initial would also make every session look healthy, resetting the // give-up window forever. let pacing = Pacing::new(&Backoff { - initial: Some(Duration::ZERO), + initial: Some((Duration::ZERO).into()), ..Default::default() }); assert_eq!(pacing.initial, MIN_BACKOFF); @@ -2023,7 +1976,7 @@ mod tests { // 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); @@ -2038,8 +1991,8 @@ mod tests { // All at once: still paced. let pacing = Pacing::new(&Backoff { - initial: Some(Duration::ZERO), - max: Some(Duration::ZERO), + initial: Some((Duration::ZERO).into()), + max: Some((Duration::ZERO).into()), multiplier: 0, ..Default::default() }); @@ -2054,9 +2007,9 @@ mod tests { #[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() }); @@ -2334,7 +2287,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/")); @@ -2352,7 +2305,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/", @@ -2394,15 +2347,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(); @@ -2433,9 +2386,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((MIN_BACKOFF).into()); + config.backoff.max = Some((MIN_BACKOFF).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(); @@ -2475,9 +2428,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((MIN_BACKOFF).into()); + config.backoff.max = Some((MIN_BACKOFF).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(); @@ -2660,7 +2613,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)); } } @@ -2693,7 +2646,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()) @@ -2734,7 +2687,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); } } @@ -2768,10 +2721,10 @@ mod tests { let subscriber = crate::origin::spawn(); let mut config = crate::connect::Config::default(); - config.goaway.handover = Some(HANDOVER); + config.goaway.handover = Some((HANDOVER).into()); // A session younger than the initial delay 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((MIN_BACKOFF).into()); let client = config .init(Default::default()) .unwrap() @@ -2909,7 +2862,7 @@ mod duration_tests { Backoff, timeout, "--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 708300a393d..4f3ede7b679 100644 --- a/rs/moq-tokio/src/error.rs +++ b/rs/moq-tokio/src/error.rs @@ -93,8 +93,8 @@ 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, /// The config was parsed from released spellings that no longer work. The @@ -275,8 +275,8 @@ impl Error { | Self::WindowRange(_) | Self::ReceiveWindowTooSmall(_) | Self::StreamsRange - | Self::QlogUnsupported | Self::KeylogUnset + | 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 e049c09fc3d..086a09d9737 100644 --- a/rs/moq-tokio/src/iroh.rs +++ b/rs/moq-tokio/src/iroh.rs @@ -257,6 +257,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); } @@ -289,7 +299,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)?; @@ -308,9 +317,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. @@ -499,6 +508,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 3c14ca6fa3e..f5295046e6e 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] @@ -160,24 +160,15 @@ pub struct Config { /// 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 = "crate::cli::duration::serde_duration::option", - 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. + /// `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-*`). @@ -212,43 +203,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)] @@ -270,33 +228,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(), - 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-*`). @@ -483,14 +415,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", )); @@ -536,8 +461,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(), @@ -553,24 +478,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), + }) } } @@ -639,7 +557,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()); @@ -715,48 +633,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. @@ -767,7 +661,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(); @@ -806,7 +702,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()]), @@ -816,13 +712,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 { @@ -889,7 +779,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); @@ -908,3 +798,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 77a024816b9..132227e2215 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,48 +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 = "crate::cli::duration::serde_duration::option", - 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 = "crate::cli::duration::serde_duration::option", - 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")] @@ -304,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 @@ -329,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)] @@ -576,16 +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(()), }?; - // rustls logs nothing without a path, which would hide the mistake the same way. - if self.keylog == Some(true) && std::env::var_os("SSLKEYLOGFILE").is_none_or(|path| path.is_empty()) { - return Err(crate::Error::KeylogUnset); - } validate_idle_timeout(Some(self.resolve().idle_timeout))?; validate_streams(self.max_streams)?; validate_windows(self) @@ -599,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 { @@ -622,7 +574,7 @@ 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(), } } } @@ -736,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 { @@ -792,6 +744,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); @@ -799,19 +753,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))); @@ -842,12 +796,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`. @@ -959,44 +914,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() { - // SAFETY: nextest runs each test in its own process, so nothing else reads the environment. - unsafe { std::env::remove_var("SSLKEYLOGFILE") }; - assert!(Config::default().validate().is_ok()); - - let set = Config { - keylog: Some(true), - ..Default::default() - }; - assert!(matches!(set.validate(), Err(crate::Error::KeylogUnset))); - - // SAFETY: as above. - unsafe { std::env::set_var("SSLKEYLOGFILE", "/tmp/keys.log") }; - assert!(set.validate().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 23dbf457c16..b41b431c167 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 935e76922f3..7a8fb87298f 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -736,7 +736,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. @@ -1003,8 +1003,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()) } @@ -1661,7 +1660,7 @@ pub struct Request { kind: RequestKind, /// 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, } @@ -1783,7 +1782,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() } @@ -1798,14 +1797,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 { kind, deadline, active, .. } = self; @@ -1813,14 +1812,14 @@ impl Request { Ok(request_into!(kind, request => { let (session, driver) = request.ok().await?; use tracing::Instrument; - let driver = tokio::spawn(async move { + tokio::spawn(async move { crate::panic::isolate(async move { let _active = active; moq_net::time::run(driver).await; Ok(()) }).await.unwrap_or(Err(Error::Panic)) }.instrument(tracing::Span::current())); - (session, driver) + session })) }; Deadline::bound(deadline.as_ref(), ok).await @@ -2857,7 +2856,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 017b6c0b1b9..3bda7c172ba 100644 --- a/rs/moq-tokio/src/settings.rs +++ b/rs/moq-tokio/src/settings.rs @@ -26,11 +26,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, @@ -150,9 +147,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, @@ -273,7 +267,7 @@ pub struct Quic { qlog: Option, #[usage(env = "MOQ_QUIC_KEYLOG", cli("--quic-keylog"))] - keylog: Option, + keylog: Option, } /// Log settings (`[log]`, `--log-level`). @@ -306,3 +300,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 0f700f629fa..0337c106749 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 976b53b58e8..3bc8c8fc055 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"))] { @@ -1462,22 +1450,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(); @@ -1571,14 +1543,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 = @@ -1627,6 +1594,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()) @@ -2640,12 +2634,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 { @@ -2660,6 +2649,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() { @@ -2673,12 +2746,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) )); } @@ -3479,3 +3551,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 865bd21af1d..e976ddc219a 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 acafec04438..4af22f0215e 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, } } } @@ -149,9 +149,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. @@ -204,7 +205,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] @@ -226,24 +227,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 = "crate::cli::duration::serde_duration::option", - 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)] @@ -251,17 +243,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. /// @@ -319,7 +300,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), } } } @@ -1033,3 +1014,50 @@ 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() { + 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(qmux::ALPNS[0].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, qmux::ALPNS[0]).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 3fc4a193d38..9abcc3fd43d 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 4480e2330e1..ca272e975da 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 d378f29ad33..b9ff0c33ccd 100644 --- a/rs/moq-tokio/tests/integration/keylog.rs +++ b/rs/moq-tokio/tests/integration/keylog.rs @@ -1,20 +1,14 @@ -//! TLS session keys reach `SSLKEYLOGFILE` only when `quic.keylog` asks for them. -//! -//! Each test points the variable at its own file. nextest runs every test in its -//! own process, so no other test reads the environment while one writes it. +//! 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 { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("keys.log"); - // SAFETY: set before anything spawns a thread, in a process running this test alone. - unsafe { std::env::set_var("SSLKEYLOGFILE", &path) }; - 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()); @@ -69,3 +63,76 @@ async fn client_keylog_logs_only_the_client() { 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 e7e61f2242b..72b260a9db3 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 { @@ -486,10 +486,10 @@ async fn ping_pong( /// A backoff starting at 50ms that doubles up to `max`, giving up after `timeout`. 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.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 9092ae33203..d829b2d2545 100644 --- a/rs/moq-uring/Cargo.toml +++ b/rs/moq-uring/Cargo.toml @@ -22,7 +22,7 @@ quic = ["dep:moq-quic", "dep:rustls", "dep:rustls-platform-verifier"] 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" @@ -33,7 +33,7 @@ libc = { workspace = true } # The worker is a moq-clock backend: its turn's instant is the ambient clock. moq-clock = { workspace = 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 } @@ -63,7 +63,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/MIGRATE.md b/rs/moq-uring/MIGRATE.md index 8ff58a08bce..231aa88921a 100644 --- a/rs/moq-uring/MIGRATE.md +++ b/rs/moq-uring/MIGRATE.md @@ -4,6 +4,11 @@ How to update your code for each breaking change, newest first. ## Unreleased +### The manual-clock hook is private ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +The testing-only `manual` feature is `_manual`. Production consumers should remove it; +tests installing a moq-clock manual clock can enable the internal hook. + ### Timers run on moq-clock The worker is a moq-clock backend, so its deadlines are `moq_clock::Instant`s instead of `std::time::Instant`s. diff --git a/rs/moq-uring/src/clock.rs b/rs/moq-uring/src/clock.rs index 095efd66118..72a904e1fb5 100644 --- a/rs/moq-uring/src/clock.rs +++ b/rs/moq-uring/src/clock.rs @@ -21,14 +21,14 @@ 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), } /// How the worker waits for its next deadline while parked. pub(crate) enum Wait { /// The deadline already passed: run another turn instead of parking. - #[cfg_attr(not(feature = "manual"), allow(dead_code))] + #[cfg_attr(not(feature = "_manual"), allow(dead_code))] Elapsed, /// Park until a completion or a wake, with no timeout. Forever, @@ -39,7 +39,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()); } @@ -50,7 +50,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(), } } @@ -60,7 +60,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(), } } @@ -73,7 +73,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; @@ -160,7 +160,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/swift/MIGRATE.md b/swift/MIGRATE.md index 976a5a725ad..a4407de5e56 100644 --- a/swift/MIGRATE.md +++ b/swift/MIGRATE.md @@ -95,6 +95,11 @@ Spell it out as `CongestionControl.none` when assigning to `QuicConfig.congestio `SSLKEYLOGFILE` alone no longer writes TLS session secrets. Set `QuicConfig.keylog` to `true` as well to capture them; without `SSLKEYLOGFILE` the client or server refuses to build. +The boolean opt-in remains unchanged. Native QUIC captures `SSLKEYLOGFILE` when +configuration is adapted, creates new Unix logs with mode 0600, and refuses an +existing group/other-accessible file (for example mode 0644) without changing it. +Make an existing log private before enabling key logging. + ### Subscriptions carry group ranges (#5732) `Subscription.groupStart` and `groupEnd` are replaced by `ranges`, a list of `Subscription.Range` whose ends are a `Subscription.Anchor` (`.absolute(group:frame:)` or `.relative(groups:)`), and an optional `order`. diff --git a/swift/ffi/MIGRATE.md b/swift/ffi/MIGRATE.md index f27cf7ce292..7b47df3b0bc 100644 --- a/swift/ffi/MIGRATE.md +++ b/swift/ffi/MIGRATE.md @@ -4,6 +4,13 @@ How to update your code for each breaking change, newest first. ## Unreleased +### Keylog files must be private ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +The boolean opt-in remains unchanged. Native QUIC captures `SSLKEYLOGFILE` when +configuration is adapted, creates new Unix logs with mode 0600, and refuses an +existing group/other-accessible file (for example mode 0644) without changing it. +Make an existing log private before enabling key logging. + ### A broadcast reference waits for its sibling's announcement (#6254) Resolving a rendition's `broadcast` reference, through `MoqBroadcastConsumer.resolve(reference:epoch:)` and the rendition-based subscribes and fetches, now waits for a sibling nothing announces yet instead of failing as unroutable, so a late sibling plays once it is announced. From 8dfbedd4f0b6e7848a4c9de70bea6c4a7858fa3b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 10:22:46 -0700 Subject: [PATCH 03/12] Split backoff into wait, cap, and healthy knobs Zero now turns each pacing knob off: `initial` 0 retries at once, `max` 0 leaves the delay uncapped, and a multiplier of 0 or 1 keeps it constant. The healthy-session bar that classifies flapping sessions is its own `healthy` knob (1s default, zero refused), so anti-flap classification survives a zero wait. Mocked-time tests drive immediate-close, restart, and redirect loops over in-memory sessions. Also: stats.interval uses the shared duration adapter, the dead __cli_ overlay cleanup and one-off benchmark results are removed, and MIGRATE entries cover the backoff, keylog, and --connect-once changes. Co-Authored-By: Claude Opus 5.5 --- rs/moq-c/src/api.rs | 7 +- rs/moq-cli/MIGRATE.md | 10 + rs/moq-ffi/src/session.rs | 9 +- rs/moq-net/benches/transport-config/README.md | 21 - .../benches/transport-config/driver-cpu.csv | 73 --- rs/moq-relay/MIGRATE.md | 12 +- rs/moq-relay/src/cluster.rs | 8 +- rs/moq-relay/src/config.rs | 1 - rs/moq-tokio/MIGRATE.md | 118 +++-- rs/moq-tokio/src/cli.rs | 3 - rs/moq-tokio/src/client.rs | 17 + rs/moq-tokio/src/connection.rs | 477 ++++++++++++++---- rs/moq-tokio/src/error.rs | 5 + rs/moq-tokio/src/settings.rs | 3 + 14 files changed, 502 insertions(+), 262 deletions(-) delete mode 100644 rs/moq-net/benches/transport-config/README.md delete mode 100644 rs/moq-net/benches/transport-config/driver-cpu.csv diff --git a/rs/moq-c/src/api.rs b/rs/moq-c/src/api.rs index 4d63468710c..27f3d45c794 100644 --- a/rs/moq-c/src/api.rs +++ b/rs/moq-c/src/api.rs @@ -1182,16 +1182,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, diff --git a/rs/moq-cli/MIGRATE.md b/rs/moq-cli/MIGRATE.md index f015f1cb53b..fda49cb6aff 100644 --- a/rs/moq-cli/MIGRATE.md +++ b/rs/moq-cli/MIGRATE.md @@ -4,6 +4,16 @@ How to update your invocation and config for each breaking change, newest first. ## Unreleased +### Key logging names its file, and `--connect-once` is removed ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +`SSLKEYLOGFILE` alone no longer writes session secrets: pass `--quic-keylog /private/keys.log` +(`MOQ_QUIC_KEYLOG`, `[quic] keylog`). New Unix files are mode 0600, and an existing file +readable by group or others is refused; with Iroh enabled, a keylog path is refused. +`--connect-once` (`MOQ_CONNECT_ONCE`) is removed: `moq` dials always reconnect. +`--backoff-initial 0` now retries at once and `--backoff-max 0` leaves the delay uncapped. +A session counts as healthy once it outlives the new `--backoff-healthy` (1s by default) +instead of `--backoff-initial`. + ### The `nvenc` and `nvdec` Cargo features are removed ([#6483](https://github.com/moq-dev/moq/pull/6483)) Enable `nvidia` instead; the old names were aliases for it. diff --git a/rs/moq-ffi/src/session.rs b/rs/moq-ffi/src/session.rs index d01eec111c2..0b7d019bd2c 100644 --- a/rs/moq-ffi/src/session.rs +++ b/rs/moq-ffi/src/session.rs @@ -195,17 +195,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. diff --git a/rs/moq-net/benches/transport-config/README.md b/rs/moq-net/benches/transport-config/README.md deleted file mode 100644 index df52f1d3a8c..00000000000 --- a/rs/moq-net/benches/transport-config/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Driver panic boundary CPU measurement - -The existing session benchmark sweeps 1/4/16 publishers and 1/4/16 viewers, -with one relay, 64-byte frames, subscribe/fetch, bounded/unbounded stream windows, -and lite06/lite07. Each measured group includes its real protocol driver polls. -The CSV retains all 72 cases in nanoseconds of thread CPU per delivered group. - -```sh -SESSION_CPU=1 cargo bench --locked -p moq-net --bench session -- session_delivery_group_cost_cpu --quick --noplot -``` - -Measured sequentially in the Nix shell on an Intel Core Ultra 7 270K Plus -(24 cores, Linux), before the driver -unwind guard, after it, and once more without changing that guard. Criterion -`--quick` provides only a small sample, and other work shared the host. -Before/after median relative change was -4.09% (case range -44.78% to -0.09%). -The unchanged-guard A/A median was +3.21% (range -0.34% to +72.68%). These runs -show no reliable effect distinguishable from host/order noise and establish no -speedup. They do not measure full media decoding, network impairment, or throughput. -The new guard adds a thread-unwinding check at the actual protocol poll boundary; -there is no extra session task, handle, or per-peer state. diff --git a/rs/moq-net/benches/transport-config/driver-cpu.csv b/rs/moq-net/benches/transport-config/driver-cpu.csv deleted file mode 100644 index a230ef134f3..00000000000 --- a/rs/moq-net/benches/transport-config/driver-cpu.csv +++ /dev/null @@ -1,73 +0,0 @@ -case,before_ns,guard_ns,guard_repeat_ns -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64,50183.16015625,37646.213134765625,39710.514892578125 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/fetch,52790.87353515625,41884.928955078125,43312.508544921875 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/window=64,75854.72119140625,73590.6396484375,76080.37744140625 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/window=64/fetch,80760.7109375,59712.51513671875,61698.82763671875 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64,370676.1328125,300851.216796875,377949.44921875 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/fetch,629832.5078125,457140.80078125,470508.98046875 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/window=64,925865.26171875,663753.83984375,683928.90625 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/window=64/fetch,834008.7734375,635755.43359375,643702.0390625 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64,1265333.96875,1242810.296875,1561410.1672363281 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/fetch,2038115.234375,1762444.25,2242228.1875 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/window=64,2952059.0,2692863.4375,4650130.703125 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/window=64/fetch,2990411.71875,2438096.546875,4082154.84375 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64,39001.504150390625,37767.05517578125,39056.813232421875 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/fetch,42956.94580078125,41986.4384765625,43505.26220703125 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/window=64,74151.59033203125,73844.74267578125,74635.11669921875 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/window=64/fetch,60963.77099609375,60573.80712890625,61047.00048828125 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64,745612.75,582156.0,772924.5 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/fetch,1039558.6640625,1011842.1015625,1147566.47265625 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/window=64,1218984.0078125,1166786.6875,1302845.7734375 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/window=64/fetch,1351437.78125,1300887.640625,1404235.890625 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64,2720349.599609375,1502088.921875,1950472.12109375 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/fetch,3961856.5,2320213.125,3168449.375 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/window=64,3695243.03125,3242766.65625,4869371.1875 -session_delivery_group_cost_cpu/moq-lite-06/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/window=64/fetch,4725794.25,3114908.0625,4580323.8125 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64,41500.59814453125,40524.227294921875,41363.01318359375 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/fetch,47597.6220703125,46250.9033203125,47347.150390625 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/window=64,79995.001953125,78488.1748046875,80197.43994140625 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/window=64/fetch,74592.8857421875,72502.66162109375,74127.49072265625 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64,319643.818359375,314451.185546875,318074.259765625 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/fetch,519287.46875,502484.55859375,513168.24609375 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/window=64,715518.48046875,704410.85546875,714778.375 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/window=64/fetch,765503.09375,755636.96875,773752.2578125 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64,1391173.65625,1293667.1171875,1408029.390625 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/fetch,2673712.53125,1958655.8125,2386460.78125 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/window=64,3531127.71875,2868261.6875,3415383.9375 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/window=64/fetch,3923790.375,2939503.21875,3720854.5625 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64,41430.951416015625,40018.86767578125,41233.42431640625 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/fetch,47664.82275390625,45804.92431640625,47048.1875 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/window=64,79609.1494140625,78464.44970703125,79709.5810546875 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/window=64/fetch,74536.80419921875,72796.37109375,74044.2099609375 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64,643731.8125,624050.10546875,637781.82421875 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/fetch,1148267.609375,1089726.6328125,1137440.5390625 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/window=64,1338910.40625,1255691.5390625,1334100.0703125 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/window=64/fetch,1601942.234375,1526654.125,1567770.546875 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64,1960019.5625,1636939.5,1733797.390625 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/fetch,3639593.9375,2579613.8125,3020336.75 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/window=64,4355201.03125,3503723.03125,3825162.0 -session_delivery_group_cost_cpu/moq-lite-07-wip/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/window=64/fetch,5103170.9375,3754010.09375,5415373.5 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64,39215.77294921875,38416.740478515625,38967.074462890625 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/fetch,93264.8837890625,92716.69189453125,93264.7939453125 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/window=64,69132.3203125,67829.0068359375,69253.79833984375 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=1/watch=1/frame=64/window=64/fetch,118088.3291015625,116315.345703125,117014.3291015625 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64,311477.94921875,300802.6015625,303428.498046875 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/fetch,767826.65625,742164.1875,755281.4921875 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/window=64,619302.72265625,618717.1171875,618986.203125 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=16/watch=1/frame=64/window=64/fetch,941165.1328125,926533.6328125,935997.8359375 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64,1560062.6328125,1256208.359375,1287900.875 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/fetch,3528494.53125,2879482.0625,3074569.9375 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/window=64,3519227.96875,2627837.65625,2618881.0625 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=1/broadcasts=1/viewers=64/watch=1/frame=64/window=64/fetch,5917362.375,3499539.09375,3883366.5625 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64,39471.305908203125,38000.768310546875,38854.879150390625 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/fetch,92976.12109375,91818.884765625,92261.6767578125 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/window=64,68712.44384765625,67318.54833984375,68569.74560546875 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=1/watch=1/frame=64/window=64/fetch,115987.8935546875,114341.3193359375,116050.564453125 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64,613106.8046875,598102.96484375,607699.6171875 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/fetch,1629111.90625,1594675.609375,1644068.890625 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/window=64,1106498.546875,1087027.890625,1099771.484375 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=16/watch=1/frame=64/window=64/fetch,2159342.5,1938225.234375,2022545.453125 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64,2047484.703125,1581325.34375,1876397.7265625 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/fetch,5095892.25,3807310.6875,4967984.5 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/window=64,3430416.5625,3216688.0,4886305.5771484375 -session_delivery_group_cost_cpu/moq-transport-22/relays=1/publishers=16/broadcasts=1/viewers=64/watch=1/frame=64/window=64/fetch,6108782.25,4722654.625,6276647.0 diff --git a/rs/moq-relay/MIGRATE.md b/rs/moq-relay/MIGRATE.md index 71e8f8fbcf3..35cdf82a7e6 100644 --- a/rs/moq-relay/MIGRATE.md +++ b/rs/moq-relay/MIGRATE.md @@ -5,10 +5,18 @@ Rolling a cluster across a release also follows the [upgrade guide](https://moq. ## Unreleased +### Each `[connect.backoff]` knob does one job ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +`initial` is only the first retry delay, where zero now retries at once, and `max` is only +the cap, where zero now leaves the delay uncapped. A session counts as healthy once it +outlives the new `healthy` (`--backoff-healthy`, `MOQ_BACKOFF_HEALTHY`), 1s by default, +instead of `initial`; zero is refused. If you raised `initial` to slow redials after +flapping sessions, set `healthy` to the same value. + ### Embedder durations use the shared adapter ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) Set `drain_timeout` with `Some(std_duration.into())`; `None` uses the default. -ACME `timeout`, stats `linger`, and cache `duration` use the same `moq_tokio::cli::Duration` +ACME `timeout`, stats `interval` and `linger`, and cache `duration` use the same `moq_tokio::cli::Duration` adapter. TOML and CLI duration strings retain their names. Later Rust assignments replace parsed values directly; there are no hidden CLI shadow fields. @@ -16,7 +24,7 @@ replace parsed values directly; there are no hidden CLI shadow fields. `stats.interval`, `--stats-interval`, and `MOQ_STATS_INTERVAL` take a duration such as `"1s"` or `"500ms"` instead of a bare number of seconds, and a zero interval refuses to start. Write `interval = 5` as `interval = "5s"`. -In Rust, `stats::Config::interval` is a `Duration`, and `stats::Config::build` returns a `Result`. +In Rust, `stats::Config::build` returns a `Result`. ### `receive_window` has a floor (#5878) diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index f77f9e5fccc..6de54654b92 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -2036,10 +2036,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 diff --git a/rs/moq-relay/src/config.rs b/rs/moq-relay/src/config.rs index b59ef57a447..316a0a1db10 100644 --- a/rs/moq-relay/src/config.rs +++ b/rs/moq-relay/src/config.rs @@ -1438,5 +1438,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}"); } - } diff --git a/rs/moq-tokio/MIGRATE.md b/rs/moq-tokio/MIGRATE.md index 95a27634586..3682430c5f8 100644 --- a/rs/moq-tokio/MIGRATE.md +++ b/rs/moq-tokio/MIGRATE.md @@ -12,6 +12,70 @@ and clear `server.listen.tls`. The constructor retains `root` and `peers` verifi and refuses `cert`, `key`, `generate`, or `identity`, which would hide a certificate source. A shared `server.tls` and configured `listen.tls` remain mutually exclusive. +### Key logging takes an explicit path ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +`SSLKEYLOGFILE` alone no longer writes session secrets. Set `quic.keylog = Some(path)` +or `--quic-keylog /private/keys.log` (`MOQ_QUIC_KEYLOG`, `[quic] keylog = "/private/keys.log"`). +The native QUIC backend opens the file during initialization. New Unix files use mode 0600; +an existing mode-0644 log is refused without changing its permissions. Make the file private explicitly. +Iroh currently refuses an explicit path when enabled because its pinned API supports only process-wide environment logging. +It also refuses explicit zero `keep_alive`: its pinned API cannot clear the default heartbeat. +Omit that setting or use a positive interval for Iroh; other backends retain zero-off support. +`tls::KeyLog::open(path)` provides the same explicit logger for custom rustls configurations. + +### Accepted sessions are supervised internally ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +`server::Request::ok()` returns a `Session`. Read `session.closed()` for its terminal +cause, including a driver panic; there is no task handle to retain or await. +The protocol driver records a panic before unwinding, so an executor that catches +it cannot turn its later drop into ordinary cancellation. + +### Backoff knobs each do one job, and zero turns them off ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +`backoff.initial` is only the first retry delay: zero now retries at once instead of +waiting 50ms. `backoff.max` is only the cap: zero leaves the delay uncapped, and a cap +below `initial` caps the first delay too, where both used to be raised to `initial`. +A multiplier of 0 or 1 keeps the delay constant. +The healthy-session bar is the new `backoff.healthy` (`--backoff-healthy`, +`MOQ_BACKOFF_HEALTHY`, `[connect.backoff] healthy`), defaulting to 1s; it used to follow +`initial`. A session that ends sooner is a failed attempt, so an immediate close still +spends `backoff.timeout`. Zero is refused with `Error::BackoffHealthy`. +If you raised `initial` to slow redials after flapping sessions, set `healthy` to the same value. + +### Config durations use one human-readable value ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +Set connect `race`, `resolution_delay`, `timeout`; listen `timeout`; QUIC `idle_timeout`, +`keep_alive`; backoff `initial`, `max`, `timeout`; GOAWAY `handover`; and WebSocket `delay` +with `Some(std_duration.into())`, or parse a `moq_tokio::cli::Duration` from `"500ms"`. +`None` uses the runtime default, including values built with `Default::default()`. +TOML keys stay unchanged and take human-readable strings. A later typed assignment +replaces a parsed CLI value instead of losing to a hidden shadow field. +`websocket::Options.timeout = None` uses the ten-second handshake default; +use `Some(std::time::Duration::ZERO)` to wait indefinitely. +`Backoff::initial()`, `max()`, and `timeout()` report the configured or default +retry values, including the defaults used by foreign settings UIs. + +`connect::Config.quic` and `listen::Config.quic` are private legacy parsing fields. +Supply shared transport tuning through the `quic` argument to `Config::init` +or `server::Config.quic`. + +### Reconnect policy belongs to each client clone ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +Set `client.with_reconnect(false)` for a one-shot native dial, or `true` to redial. +The duplicate `connect.once` field and `--connect-once` / `MOQ_CONNECT_ONCE` are removed. +Released `--client-reconnect` / `MOQ_CLIENT_RECONNECT` and TOML `reconnect` are refused +with this native API guidance. CLI clients always reconnect and no longer offer +an operator override. Foreign bindings retain their existing `once` setting. + +### QUIC-LB settings have one path ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) + +Move `listen.lb_id` / `listen.lb_nonce` or `listen.load_balancer` to `[listen.lb]` +with `id` and optional `nonce` (default 8). In Rust set `config.lb.id` and `config.lb.nonce`. +The duplicate public `quic::LoadBalancer` runtime record and `Config::load_balancer()` are private; +configure or inspect `listen::LoadBalancer` through `config.lb`. +The `--listen-quic-lb-id` / `--listen-quic-lb-nonce` flags and their environment variables +keep their names and update those same fields. A nonce without an ID is refused. + ### The connection receive window is 128 MiB, and has a floor (#5878) `quic::DEFAULT_RECEIVE_WINDOW` is 128 MiB instead of 64 MiB, since incomplete messages now hold connection credit until complete. @@ -52,17 +116,6 @@ The `Args` and `Config` derives now implement usage-rs 7 traits, and `cli::usage A CLI that flattens these types depends on `usage-rs = "7"` under its own name: the derives emit `::usage_rs` paths and no longer read `Cargo.toml`. Rename a `usage = { package = "usage-rs" }` alias to `usage-rs`, or add `#[usage(crate = usage)]` to each derive. -### Key logging takes an explicit path ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) - -`SSLKEYLOGFILE` alone no longer writes session secrets. Set `quic.keylog = Some(path)` -or `--quic-keylog /private/keys.log` (`MOQ_QUIC_KEYLOG`, `[quic] keylog = "/private/keys.log"`). -The native QUIC backend opens the file during initialization. New Unix files use mode 0600; -an existing mode-0644 log is refused without changing its permissions. Make the file private explicitly. -Iroh currently refuses an explicit path when enabled because its pinned API supports only process-wide environment logging. -It also refuses explicit zero `keep_alive`: its pinned API cannot clear the default heartbeat. -Omit that setting or use a positive interval for Iroh; other backends retain zero-off support. -`tls::KeyLog::open(path)` provides the same explicit logger for custom rustls configurations. - ### QUIC limits are `moq_quic::Limits`, set through `Request::quic` (#5871) `server::Request::set_quic_limits` and `Error::QuicLimits` are gone. @@ -97,40 +150,6 @@ let mut workers = moq_tokio::worker::Config::new(8); workers.pin = true; ``` -### Accepted sessions are supervised internally ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) - -`server::Request::ok()` returns a `Session`. Read `session.closed()` for its terminal -cause, including a driver panic; there is no task handle to retain or await. -The protocol driver records a panic before unwinding, so an executor that catches -it cannot turn its later drop into ordinary cancellation. - -### Config durations use one human-readable value ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) - -Set connect `race`, `resolution_delay`, `timeout`; listen `timeout`; QUIC `idle_timeout`, -`keep_alive`; backoff `initial`, `max`, `timeout`; GOAWAY `handover`; and WebSocket `delay` -with `Some(std_duration.into())`, or parse a `moq_tokio::cli::Duration` from `"500ms"`. -`None` uses the runtime default, including values built with `Default::default()`. -TOML keys stay unchanged and take human-readable strings. A later typed assignment -replaces a parsed CLI value instead of losing to a hidden shadow field. -`websocket::Options.timeout = None` uses the ten-second handshake default; -use `Some(std::time::Duration::ZERO)` to wait indefinitely. -`Backoff::initial()`, `max()`, and `timeout()` report the effective retry values, -including the defaults used by foreign settings UIs. Backoff retains its existing -50ms pacing floor and cap-at-least-initial semantics; -zero `timeout` disables the retry deadline. - -`connect::Config.quic` and `listen::Config.quic` are private legacy parsing fields. -Supply shared transport tuning through the `quic` argument to `Config::init` -or `server::Config.quic`. - -### Reconnect policy belongs to each client clone ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) - -Set `client.with_reconnect(false)` for a one-shot native dial, or `true` to redial. -The duplicate `connect.once` field and `--connect-once` / `MOQ_CONNECT_ONCE` are removed. -Released `--client-reconnect` / `MOQ_CLIENT_RECONNECT` and TOML `reconnect` are refused -with this native API guidance. CLI clients always reconnect and no longer offer -an operator override. Foreign bindings retain their existing `once` setting. - ### Rotating TLS identities are a `tls::Serve` (#5312) `tls::Listen::server_config_reloading` and `tls::ReloadingServerConfig` are gone; load once with `tls::Listen::load` and read each handshake's config from the returned `tls::Serve` with `config(alpns)`. @@ -141,15 +160,6 @@ A TLS trust-root watcher that fails to register now fails client and server cons The reconnect counter is `connects()`; rename `epoch()` calls. -### QUIC-LB settings have one path ([moq-dev/moq#6529](https://github.com/moq-dev/moq/pull/6529)) - -Move `listen.lb_id` / `listen.lb_nonce` or `listen.load_balancer` to `[listen.lb]` -with `id` and optional `nonce` (default 8). In Rust set `config.lb.id` and `config.lb.nonce`. -The duplicate public `quic::LoadBalancer` runtime record and `Config::load_balancer()` are private; -configure or inspect `listen::LoadBalancer` through `config.lb`. -The `--listen-quic-lb-id` / `--listen-quic-lb-nonce` flags and their environment variables -keep their names and update those same fields. A nonce without an ID is refused. - ### Settings no listener reads stop startup (#5006) A stream-only listener (TCP or Unix, no QUIC bind) refuses the QUIC-only `preferred_v4` / `preferred_v6`, `lb.id`, pinned `tls.peers`, and a TLS cert, key, or `generate` unless `tcp.tls` serves it. diff --git a/rs/moq-tokio/src/cli.rs b/rs/moq-tokio/src/cli.rs index 900bcff7289..8aaa740d171 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/client.rs b/rs/moq-tokio/src/client.rs index ba2279a2b00..df9cdda370f 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. @@ -165,6 +175,8 @@ impl Client { iroh: None, #[cfg(feature = "iroh")] iroh_addrs: Vec::new(), + #[cfg(test)] + dialer: None, }) } @@ -373,6 +385,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)); diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index a96a609cb1a..b004b6e4ee3 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -50,28 +50,27 @@ 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. Zero retains the existing retry pacing floor. + /// 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 = "backoff-initial", long, @@ -81,7 +80,7 @@ pub struct Backoff { #[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 = "backoff-multiplier", long, @@ -91,26 +90,36 @@ pub struct Backoff { )] pub multiplier: u32, - /// Maximum delay between reconnect attempts. Defaults to 5s. - /// `None` uses the default. The maximum is never below the initial delay. + /// 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 = "backoff-max", long, env = "MOQ_BACKOFF_MAX", setting = "connect.backoff.max")] #[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. + /// + /// 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 = "backoff-healthy", + long, + env = "MOQ_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. /// /// 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. /// - /// 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.into())` for unlimited retries. `None` takes the default. When this config was - /// parsed from the command line, a later assignment replaces its parsed value. - /// - /// 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". + /// 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 = "backoff-timeout", long, @@ -127,26 +136,41 @@ impl Default for Backoff { initial: None, multiplier: DEFAULT_MULTIPLIER, max: None, + healthy: None, timeout: None, } } } impl Backoff { - /// The initial retry delay and healthy-session threshold, including its 50ms floor. + /// 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).max(MIN_BACKOFF) + self.initial.map(Into::into).unwrap_or(DEFAULT_INITIAL) } - /// The retry-delay cap, never lower than the initial delay. + /// 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).max(self.initial()) + 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(()) + } } /// A connection lifecycle transition reported by [`Connection::status`]. @@ -362,46 +386,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 max = Some(backoff.max()).filter(|max| !max.is_zero()); let initial = backoff.initial(); - let max = backoff.max(); 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) } } @@ -821,8 +845,7 @@ impl Connection { let goaway = client.goaway.resolve(); let pacing = Pacing::new(&backoff); let timeout = backoff.timeout(); - let initial = pacing.initial; - let mut retry = Retry::new(initial); + 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. @@ -888,7 +911,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, @@ -896,7 +919,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. @@ -906,9 +929,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 @@ -1315,12 +1338,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 } @@ -1664,12 +1686,14 @@ mod tests { parsed.backoff.initial = Some((Duration::from_secs(7)).into()); parsed.backoff.multiplier = 5; 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)).into())); assert_eq!(parsed.backoff.multiplier, 5); 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. @@ -1677,7 +1701,8 @@ mod tests { 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!(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. @@ -1990,47 +2015,58 @@ 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).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).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).into()), - max: Some((Duration::ZERO).into()), - 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. @@ -2044,6 +2080,7 @@ mod tests { }); 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)); @@ -2175,6 +2212,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") } @@ -2416,8 +2456,8 @@ mod tests { crate::resolve::hosts::point(HOST, failing); let mut config = crate::connect::Config::default(); - config.backoff.initial = Some((MIN_BACKOFF).into()); - config.backoff.max = Some((MIN_BACKOFF).into()); + 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/"))); @@ -2458,8 +2498,8 @@ 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).into()); - config.backoff.max = Some((MIN_BACKOFF).into()); + 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); @@ -2752,9 +2792,10 @@ mod tests { let subscriber = crate::origin::spawn(); let mut config = crate::connect::Config::default(); config.goaway.handover = Some((HANDOVER).into()); - // A session younger than the initial delay counts as redirected immediately and + // 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).into()); + config.backoff.initial = Some((FAST).into()); + config.backoff.healthy = Some((FAST).into()); let client = config .init(Default::default()) .unwrap() @@ -2831,7 +2872,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() @@ -2873,6 +2915,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, qmux::Version::QMux01, config().max_record_size); + 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::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 { @@ -2931,16 +3208,24 @@ mod duration_tests { Backoff, initial, "--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, "--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, + "--backoff-healthy", + |c: &Backoff| c.healthy(), + |d| d ); crate::cli::duration::test_config_duration!( timeout_set_in_code_beats_parser_default, diff --git a/rs/moq-tokio/src/error.rs b/rs/moq-tokio/src/error.rs index 4f3ede7b679..3bde492a0aa 100644 --- a/rs/moq-tokio/src/error.rs +++ b/rs/moq-tokio/src/error.rs @@ -97,6 +97,10 @@ pub enum Error { #[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}")] @@ -276,6 +280,7 @@ impl Error { | Self::ReceiveWindowTooSmall(_) | Self::StreamsRange | Self::KeylogUnset + | Self::BackoffHealthy | Self::QlogUnsupported | Self::Connect(_) => true, #[cfg(feature = "quic")] diff --git a/rs/moq-tokio/src/settings.rs b/rs/moq-tokio/src/settings.rs index 3bda7c172ba..e66edd1014b 100644 --- a/rs/moq-tokio/src/settings.rs +++ b/rs/moq-tokio/src/settings.rs @@ -204,6 +204,9 @@ struct ConnectBackoff { #[usage(env = "MOQ_BACKOFF_MAX", cli("--backoff-max"))] max: Option, + #[usage(env = "MOQ_BACKOFF_HEALTHY", cli("--backoff-healthy"))] + healthy: Option, + #[usage(env = "MOQ_BACKOFF_TIMEOUT", cli("--backoff-timeout"))] timeout: Option, } From db396d57498976a4251a10fe78bc2e30c08d8ae5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 13:08:43 -0700 Subject: [PATCH 04/12] Format moq-quic test lint attribute Co-Authored-By: Claude Opus 5.5 --- rs/moq-quic/src/lib.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/rs/moq-quic/src/lib.rs b/rs/moq-quic/src/lib.rs index 86d416f9e5f..b68f07aac6b 100644 --- a/rs/moq-quic/src/lib.rs +++ b/rs/moq-quic/src/lib.rs @@ -38,7 +38,10 @@ mod entropy; pub mod fuzz; mod range_set; #[cfg(all(test, any(feature = "aws-lc-rs", feature = "ring")))] -#[expect(clippy::disallowed_methods, reason = "tests read the host clock directly")] +#[expect( + clippy::disallowed_methods, + reason = "tests read the host clock directly" +)] mod tests; pub mod transport_parameters; mod varint; From d761218c789e8ac2d845ab27a17f6a4b955ad7b0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 19:24:47 -0700 Subject: [PATCH 05/12] Keep a disabled relay WebSocket SETUP timeout disabled The listener stores a disabled timeout as None, which websocket::Options now reads as the ten-second default. Pass zero instead. Co-Authored-By: Claude Opus 5.5 --- rs/moq-relay/src/websocket.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index ac7ffabcde5..6ca0a804152 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -69,7 +69,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. @@ -145,7 +146,7 @@ const QMUX01_ONLY_ALPNS: &[&str] = &["moqt-18", "moqt-19", "moqt-20", "moqt-21", /// `qmux-00.moqt-{18,19,20,21,22}` is excluded: moq-transport-18 and newer require /// qmux-01, so those pairs are illegal. fn supported_subprotocols(alpns: &[&str]) -> Vec { - let mut out = Vec::with_capacity(QMUX_VERSIONS.len() * alpns.len() + qmux::ALPNS.len()); + let mut out = Vec::with_capacity(QMUX_VERSIONS.len() * alpns.len() + qmux::Version::ALL.len()); for &alpn in alpns { for &version in QMUX_VERSIONS { if version == qmux::Version::QMux00 && QMUX01_ONLY_ALPNS.contains(&alpn) { @@ -154,8 +155,8 @@ fn supported_subprotocols(alpns: &[&str]) -> Vec { out.push(format!("{}{alpn}", version.prefix())); } } - for &alpn in qmux::ALPNS { - out.push(alpn.to_string()); + for version in qmux::Version::ALL { + out.push(version.alpn().to_string()); } out } @@ -419,7 +420,8 @@ mod tests { .iter() .rposition(|s| s.contains('.')) .expect("no versioned entries"); - for &bare in qmux::ALPNS { + for version in qmux::Version::ALL { + let bare = version.alpn(); let bare_idx = list .iter() .position(|s| s == bare) @@ -789,7 +791,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, From a5b4438853052d1f73b3c0e62d33df25816417ad Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 19:56:36 -0700 Subject: [PATCH 06/12] Adapt moq-tokio tests to main's qmux framing API Co-Authored-By: Claude Opus 5.5 --- rs/moq-tokio/src/connection.rs | 2 +- rs/moq-tokio/src/websocket.rs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 57843d759ba..e10af0dbe9f 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -2980,7 +2980,7 @@ mod tests { config.protocol = qmux::Protocol::Negotiate(versions.alpns().iter().map(|alpn| alpn.to_string()).collect()); config }; - let stream = |io| qmux::transport::Stream::new(io, qmux::Version::QMux01, config().max_record_size); + 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()), diff --git a/rs/moq-tokio/src/websocket.rs b/rs/moq-tokio/src/websocket.rs index eae517514db..d3cdedec449 100644 --- a/rs/moq-tokio/src/websocket.rs +++ b/rs/moq-tokio/src/websocket.rs @@ -1047,6 +1047,7 @@ mod timeout_tests { #[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( @@ -1054,7 +1055,7 @@ mod timeout_tests { Default::default(), activity.enter().unwrap(), ); - options.link.alpn = Some(qmux::ALPNS[0].into()); + 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. @@ -1065,7 +1066,7 @@ mod timeout_tests { let peer = tokio_tungstenite::WebSocketStream::from_raw_socket(peer, tungstenite::protocol::Role::Client, None) .await; - let mut peer = qmux::ws::Upgraded::new(peer, qmux::ALPNS[0]).connect().unwrap(); + 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())) From b931aa94360362908ef9bed84ca651b7a701d57e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 20:00:58 -0700 Subject: [PATCH 07/12] Give the bouncing-fleet reconnect tests their healthy bar These tests sized their 100ms sessions against initial as the healthy bar. With healthy split out and defaulting to 1s, every hop scored as a failure and the 60ms retry window ended the connection. Co-Authored-By: Claude Opus 5.5 --- rs/moq-tokio/tests/integration/reconnect.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rs/moq-tokio/tests/integration/reconnect.rs b/rs/moq-tokio/tests/integration/reconnect.rs index 72b260a9db3..b3c8ee13920 100644 --- a/rs/moq-tokio/tests/integration/reconnect.rs +++ b/rs/moq-tokio/tests/integration/reconnect.rs @@ -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,10 +483,12 @@ 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)).into()); + backoff.healthy = Some((Duration::from_millis(50)).into()); backoff.multiplier = 2; backoff.max = Some((max).into()); backoff.timeout = Some((timeout).into()); From 3384e0975d9f5d82ec350c68b262fc0e6c3f4fdf Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 23 Oct 2026 20:40:42 -0700 Subject: [PATCH 08/12] Use moq_net::time::Instant::now in the flap test Main renamed the free function in #6510. Co-Authored-By: Claude Opus 5.5 --- rs/moq-tokio/src/connection.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index e10af0dbe9f..eddb60496ad 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -2987,7 +2987,7 @@ mod tests { qmux::Session::accept(stream(server), config()) ) .unwrap(); - let now = moq_net::time::now(); + let now = moq_net::time::Instant::now(); let client = async { let (session, driver) = moq_net::Client::new() .with_versions(versions.clone()) From 30fef1c2f933a9fcdf44cfcc6460067c2503b5e2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 01:21:40 -0700 Subject: [PATCH 09/12] fix(ffi): build the wasm accepted session without a driver handle Main's wasm dial path (moq-dev/moq#6487) still filled the driver field this branch removed. Co-Authored-By: Claude Opus 5.5 --- rs/moq-ffi/src/session/dial.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/rs/moq-ffi/src/session/dial.rs b/rs/moq-ffi/src/session/dial.rs index 1f304165902..538f1b04766 100644 --- a/rs/moq-ffi/src/session/dial.rs +++ b/rs/moq-ffi/src/session/dial.rs @@ -223,7 +223,6 @@ async fn run( }; published.session = Some(Accepted { session: session.clone(), - driver: crate::ffi::Background::default(), }); drop(published); From 2a260c510ea356525d5a87bbafdac49477234701 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 05:56:47 -0700 Subject: [PATCH 10/12] docs(tokio): keep flag help free of rustdoc links The merged duration fields now carry the CLI help that main's plain shadow-field docs used to, so main's flag_help_uses_plain_names test needs plain text there. Co-Authored-By: Claude Opus 5.5 --- rs/moq-tokio/src/connect.rs | 8 ++++---- rs/moq-tokio/src/connection.rs | 5 ++--- rs/moq-tokio/src/listen.rs | 7 +++---- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/rs/moq-tokio/src/connect.rs b/rs/moq-tokio/src/connect.rs index 967c77fd184..cad8c05ad0d 100644 --- a/rs/moq-tokio/src/connect.rs +++ b/rs/moq-tokio/src/connect.rs @@ -526,8 +526,8 @@ 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. + /// 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", @@ -560,13 +560,13 @@ pub struct Config { #[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; zero disables this duration. diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 333ef20d8ca..ac7cd1820fa 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -119,9 +119,8 @@ pub struct Backoff { /// Maximum time to spend retrying before giving up. Defaults to 10s; zero retries forever. /// /// 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. + /// 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. /// /// Resets after a healthy session outside a redirect loop, so a flapping session that /// reconnects then immediately drops still counts toward the timeout. diff --git a/rs/moq-tokio/src/listen.rs b/rs/moq-tokio/src/listen.rs index ff87ed61bfd..2ac1cf4f90c 100644 --- a/rs/moq-tokio/src/listen.rs +++ b/rs/moq-tokio/src/listen.rs @@ -153,13 +153,12 @@ 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. + /// connection is closed with a timeout code once this passes. /// `None` uses the default; zero disables this duration. #[usage( name = "listen-timeout", From 304a27867de948186fcf550f4226a5284e344655 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 15:27:12 -0700 Subject: [PATCH 11/12] fix: adapt to main's session close cause and once removal The panicked supervisor closes through the boxed session's shared Closing (moq-dev/moq#6651), and the io_uring extensions test drops connect::Config.once, which its connect helper already covers with with_reconnect(false). Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/session.rs | 14 +++++++------- rs/moq-relay/tests/integration/runtime_uring.rs | 1 - 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index c32c27a59a8..c7810731ee0 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -533,18 +533,18 @@ enum SamplerMode { impl Supervisor { pub(crate) fn panicked(&mut self, error: Error) { - self.closing.local.store(true, std::sync::atomic::Ordering::Relaxed); - self.closing.record(&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)), + drain: Some(Err(error.clone())), }); } - self.session.close( - self.version.session_code(&SessionError::Internal), - "session driver panicked", - ); + let code = self.version.session_code(&SessionError::Internal); + self.session.abort(&error, code, "session driver panicked"); } const POLL_INTERVAL: Duration = Duration::from_millis(100); diff --git a/rs/moq-relay/tests/integration/runtime_uring.rs b/rs/moq-relay/tests/integration/runtime_uring.rs index 2e291058c62..1d388d7d919 100644 --- a/rs/moq-relay/tests/integration/runtime_uring.rs +++ b/rs/moq-relay/tests/integration/runtime_uring.rs @@ -995,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(); From c16b55ec892f86f3080b9625c11514712a1ac4ca Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 24 Oct 2026 15:48:25 -0700 Subject: [PATCH 12/12] test: adapt main's auth tests to the consolidated config and session The tests moq-dev/moq#6109 added set backoff through the removed MIN_BACKOFF and destructure a driver from a server request's ok(), which now returns the session alone. Co-Authored-By: Claude Opus 5.5 --- rs/moq-ffi/src/auth.rs | 4 ++-- rs/moq-tokio/src/connection.rs | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) 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-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 2f8ed965de3..d21d9554728 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -2916,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() @@ -2936,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() { @@ -2972,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()