From 03c7a93ce9340dd59ad4851a8ff6243cbc6ea3d8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 15:44:45 -0700 Subject: [PATCH 1/3] feat(stats): linger an empty group broadcast before unannouncing it (backport #4871) A grouped stats broadcast (`//node/`) now stays announced for `Config::linger` (default 5 minutes) after its group's last traffic and session entries leave, so viewer churn no longer unannounces and re-announces it across the mesh. While it lingers empty its tracks read `{}`; a returning entry re-arms the linger. The relay exposes it as `stats.linger` / `--stats-linger` / `MOQ_STATS_LINGER`. Cherry-pick of #4871 (a52ee7ea2) onto `release`; the announce test helper uses `release`'s announce update API. Co-Authored-By: Claude Opus 5.5 --- doc/bin/relay/config.md | 1 + doc/concept/stats.md | 8 +- rs/moq-relay/src/config.rs | 12 ++- rs/moq-relay/src/settings.rs | 3 + rs/moq-relay/src/stats.rs | 28 +++++- rs/moq-stats/src/produce.rs | 180 +++++++++++++++++++++++++++++++++-- 6 files changed, 217 insertions(+), 15 deletions(-) diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 841d8bb13b..8282c145b4 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -220,6 +220,7 @@ prefix = ".stats" # Broadcasts appear under /node/, + + #[usage(env = "MOQ_STATS_LINGER", cli("--stats-linger"))] + linger: Option, } #[derive(usage::Config)] diff --git a/rs/moq-relay/src/stats.rs b/rs/moq-relay/src/stats.rs index 6fb8bfbb9a..12fa708423 100644 --- a/rs/moq-relay/src/stats.rs +++ b/rs/moq-relay/src/stats.rs @@ -77,6 +77,19 @@ pub struct Config { setting = "stats.depth" )] pub depth: usize, + + /// How long a group's stats broadcast stays announced after the group's + /// last session and traffic leave, e.g. "5m" or "30s". Defaults to 5 + /// minutes. A group that returns within it keeps its broadcast, so viewer + /// churn doesn't unannounce and re-announce it across the mesh. 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, } impl Default for Config { @@ -87,11 +100,18 @@ impl Default for Config { interval: 1, node: None, depth: 0, + linger: None, + linger_arg: None, } } } 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) + } + /// Build a [`moq_stats::Producer`] from this config, publishing on `origin`. /// /// Returns a no-op producer when [`Self::enabled`] is false, so the relay can @@ -107,13 +127,17 @@ impl Config { let interval = Duration::from_secs(self.interval.max(1)); let node = self.node.clone().map(PathOwned::from); let depth = self.depth; - tracing::info!(prefix, interval_secs = interval.as_secs(), node = ?node, depth, "stats publishing enabled"); - let config = moq_stats::produce::Config::new() + let linger = self.linger(); + tracing::info!(prefix, interval_secs = interval.as_secs(), node = ?node, depth, ?linger, "stats publishing enabled"); + let mut config = moq_stats::produce::Config::new() .with_origin(origin) .with_prefix(prefix) .with_interval(interval) .with_node(node) .with_depth(depth); + if let Some(linger) = linger { + config = config.with_linger(linger); + } moq_stats::Producer::new(config) } } diff --git a/rs/moq-stats/src/produce.rs b/rs/moq-stats/src/produce.rs index ea370e84ef..6fec5048a7 100644 --- a/rs/moq-stats/src/produce.rs +++ b/rs/moq-stats/src/produce.rs @@ -11,6 +11,7 @@ use moq_net::stats::{Presence, Registry, Report, Role, Tier, Traffic}; use moq_net::{Path, PathOwned, broadcast, kio, origin, track}; use serde::Serialize; use web_async::spawn; +use web_async::time::Instant; use crate::{COMPRESSED_SUFFIX, sessions_track, traffic_track}; @@ -46,9 +47,18 @@ pub struct Config { /// Default `0` publishes one `/node/` broadcast carrying every /// path. `1` publishes one broadcast per first segment at /// `//node/`, and larger values include more leading - /// segments. Group broadcasts are announced while their group has live traffic; - /// at depth `0`, the single broadcast stays announced for the producer's life. + /// segments. Group broadcasts are announced while their group has live traffic, + /// plus the [`Self::linger`]; at depth `0`, the single broadcast stays + /// announced for the producer's life. pub depth: usize, + /// How long a group broadcast stays announced after its group's last entry + /// leaves. Default 5 minutes. + /// + /// A group that returns within the linger keeps its broadcast, so a viewer + /// leaving and another arriving causes no unannounce and re-announce across + /// the mesh. While it lingers empty, every track reads `{}`. Zero + /// unannounces on the first drain the group is empty. Unused at depth `0`. + pub linger: Duration, } impl Config { @@ -62,6 +72,7 @@ impl Config { node: None, interval: Duration::from_secs(1), depth: 0, + linger: DEFAULT_LINGER, } } @@ -95,8 +106,19 @@ impl Config { self.depth = depth; self } + + /// Set how long an empty group broadcast stays announced (default 5 + /// minutes). See [`Self::linger`]. + pub fn with_linger(mut self, linger: Duration) -> Self { + self.linger = linger; + self + } } +/// The default [`Config::linger`]. Sized from a live fleet, where an idle +/// group's viewers returned 46 to 112 seconds after the last one left. +const DEFAULT_LINGER: Duration = Duration::from_secs(300); + impl Default for Config { fn default() -> Self { Self::new() @@ -156,6 +178,7 @@ impl Producer { node, interval, depth, + linger, } = config; // An empty path after normalization is indistinguishable from "no node // set"; collapse it so downstream code only sees a single representation. @@ -180,6 +203,7 @@ impl Producer { prefix, node, depth, + linger, interval, sequence: Arc::new(AtomicU64::new(first_sequence(SystemTime::now()))), }; @@ -219,6 +243,7 @@ struct Task { prefix: PathOwned, node: Option, depth: usize, + linger: Duration, interval: Duration, sequence: Arc, } @@ -244,7 +269,7 @@ impl Task { } drain.collect(); - drain.publish(); + drain.publish(Instant::now()); } } fn node(&self) -> Option<&str> { @@ -343,14 +368,14 @@ impl Drain { } /// Write every group's pending frames, serve consumer track requests, and - /// unpublish groups with nothing left to report. - fn publish(&mut self) { + /// unpublish groups that have lingered empty. + fn publish(&mut self, now: Instant) { // At depth 0 the single broadcast stays for the producer's life; a - // group broadcast lives while its group has entries. - let depth = self.task.depth; + // group broadcast lives while its group has entries, plus the linger. + let Task { depth, linger, .. } = self.task; for (_, group) in self .groups - .extract_if(|_, group| depth > 0 && group.traffic_rows.is_empty() && group.session_rows.is_empty()) + .extract_if(|_, group| depth > 0 && group.lingered(now, linger)) { // Deliberate unpublish: finish (tracks included) rather than drop, // so there is no dropped-without-finish warning. @@ -758,6 +783,8 @@ struct GroupPublisher { /// This drain's entries for the group, as indices into the report. traffic_rows: Vec, session_rows: Vec, + /// When the group's last entry left; `None` while it has entries. + empty_since: Option, } /// The plain track names one tier's entries land on. @@ -830,9 +857,21 @@ impl GroupPublisher { names: HashMap::new(), traffic_rows: Vec::new(), session_rows: Vec::new(), + empty_since: None, }) } + /// Whether this drain left the group empty for at least `linger`. An entry + /// returning within the linger re-arms it. + fn lingered(&mut self, now: Instant, linger: Duration) -> bool { + if !self.traffic_rows.is_empty() || !self.session_rows.is_empty() { + self.empty_since = None; + return false; + } + let since = *self.empty_since.get_or_insert(now); + now.saturating_duration_since(since) >= linger + } + /// Run this drain's rows through change detection into the pending frames. fn collect(&mut self, report: &Report, tick: u64) { let Self { @@ -1290,6 +1329,18 @@ mod tests { serde_json::from_slice(&frame.payload).expect("json parse") } + async fn read_session_frame_last( + broadcast: &moq_net::broadcast::Consumer, + name: &str, + ) -> BTreeMap { + let mut track = subscribe(broadcast, name).await; + let mut last = next_frame(&mut track).await; + while let Some(frame) = try_next_frame(&mut track) { + last = frame; + } + serde_json::from_slice(&last.payload).expect("json parse") + } + async fn subscribe(broadcast: &moq_net::broadcast::Consumer, name: &str) -> track::Ordered { broadcast .track(name) @@ -1489,6 +1540,114 @@ mod tests { ); } + /// A depth-1 producer with `linger`, its origin, and a hidden announce + /// listener started before anything publishes. + fn grouped_producer(linger: Duration) -> (Producer, origin::Producer, announce::Consumer) { + let origin = produce_origin(); + let events = origin.consume().with_hidden(true).announced(); + let producer = Producer::new( + Config::new() + .with_origin(origin.clone()) + .with_node(PathOwned::from("sjc")) + .with_depth(1) + .with_linger(linger), + ); + (producer, origin, events) + } + + /// The announce events buffered so far, as `(path, active)`. + fn take_events(events: &mut announce::Consumer) -> Vec<(String, bool)> { + use futures::FutureExt; + let mut out = Vec::new(); + while let Some(Some(update)) = events.next().now_or_never() { + out.push((update.prefix.as_str().to_string(), update.kind.is_active())); + } + out + } + + const ACME: &str = ".stats/acme/node/sjc"; + const FEED: &str = ".stats/feed/node/sjc"; + + #[tokio::test(start_paused = true)] + async fn empty_group_lingers_then_unannounces() { + let (producer, origin, mut events) = grouped_producer(Duration::from_secs(10)); + let registry = producer.registry(); + + // `feed` records traffic on `acme/live` (group `acme`) and holds a + // session rooted at `feed` (group `feed`). + let first = feed(registry, Tier::default(), "acme/live", true, 1, 100).await; + drive_tick().await; + let mut started = take_events(&mut events); + started.sort(); + assert_eq!(started, [(ACME.to_string(), true), (FEED.to_string(), true)]); + let acme = origin.consume().request_broadcast(ACME).await.expect("resolve"); + let sessions = origin.consume().request_broadcast(FEED).await.expect("resolve"); + assert_eq!(read_last_frame(&acme, "publisher.json").await["acme/live"].bytes, 100); + + // The viewer leaves: both groups empty but stay announced, reading zero. + drop(first); + for _ in 0..3 { + drive_tick().await; + } + assert_eq!(take_events(&mut events), [], "an empty group lingers"); + let frame = read_last_frame(&acme, "publisher.json").await; + assert!(frame.is_empty(), "no live counters while lingering, got {frame:?}"); + let frame = read_session_frame_last(&sessions, "sessions.json").await; + assert!(frame.is_empty(), "no presence while lingering, got {frame:?}"); + + // Another viewer arrives within the linger: same broadcast, no announce. + // The path restarted, so a reader diffing frames counts 100 + 50, the + // same as it would across an unannounce and re-announce. + let second = feed(registry, Tier::default(), "acme/live", true, 1, 50).await; + drive_tick().await; + assert_eq!( + take_events(&mut events), + [], + "a return within the linger re-announces nothing" + ); + assert_eq!(read_last_frame(&acme, "publisher.json").await["acme/live"].bytes, 50); + + // The return re-armed the linger: it unannounces only once it elapses + // again with the group still empty. + drop(second); + for _ in 0..8 { + drive_tick().await; + } + assert_eq!(take_events(&mut events), [], "the return re-armed the linger"); + for _ in 0..4 { + drive_tick().await; + } + let mut ended = take_events(&mut events); + ended.sort(); + assert_eq!(ended, [(ACME.to_string(), false), (FEED.to_string(), false)]); + + // A return after the linger is a new broadcast counted from zero. + let _third = feed(registry, Tier::default(), "acme/live", true, 1, 25).await; + drive_tick().await; + let mut restarted = take_events(&mut events); + restarted.sort(); + assert_eq!(restarted, [(ACME.to_string(), true), (FEED.to_string(), true)]); + let acme = origin.consume().request_broadcast(ACME).await.expect("resolve"); + assert_eq!(read_last_frame(&acme, "publisher.json").await["acme/live"].bytes, 25); + } + + #[tokio::test(start_paused = true)] + async fn zero_linger_unannounces_once_empty() { + let (producer, _origin, mut events) = grouped_producer(Duration::ZERO); + let first = feed(producer.registry(), Tier::default(), "acme/live", true, 1, 100).await; + drive_tick().await; + assert_eq!(take_events(&mut events).len(), 2); + + // The closing readout drain still carries the entry; the next is empty. + drop(first); + drive_tick().await; + assert_eq!(take_events(&mut events), []); + drive_tick().await; + let mut ended = take_events(&mut events); + ended.sort(); + assert_eq!(ended, [(ACME.to_string(), false), (FEED.to_string(), false)]); + } + #[tokio::test(start_paused = true)] async fn session_track_surfaces_by_root() { let (producer, origin) = test_producer(Some("sjc")); @@ -1890,6 +2049,7 @@ mod tests { node: None, sequence: Arc::new(AtomicU64::new(0)), depth, + linger: DEFAULT_LINGER, interval: Duration::from_secs(1), }) .expect("drain"); @@ -1897,13 +2057,13 @@ mod tests { // Warm up: create the groups and tracks and grow every buffer. for _ in 0..3 { drain.collect(); - drain.publish(); + drain.publish(Instant::now()); } let before = counting::allocs(); drain.collect(); let allocs = counting::allocs() - before; - drain.publish(); + drain.publish(Instant::now()); let pending: usize = drain .groups From 5b9344a3f188a999212740f2902c7f060a5b19be Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 5 Oct 2026 23:27:28 -0700 Subject: [PATCH 2/3] fix(moq-tokio): finite default connection receive window (backport #4605) (#4858) Co-authored-by: Claude Opus 5.5 --- doc/bin/relay/config.md | 10 +++++++--- doc/lib/rs/index.md | 3 ++- rs/moq-tokio/src/iroh.rs | 8 ++++---- rs/moq-tokio/src/noq.rs | 28 +++++++++++++++++----------- rs/moq-tokio/src/quic.rs | 16 ++++++++++++---- 5 files changed, 42 insertions(+), 23 deletions(-) diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 8282c145b4..be3b7bd702 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -55,9 +55,9 @@ idle_timeout = "10s" # Drop a connection after this long with no keep_alive = "3s" # Ping interval; "0s" disables it. Ignored by iroh. Default. gso = true # UDP segmentation offload. iroh cannot turn it off. mtu_discovery = false # Path MTU discovery. Default. -receive_window = 67108864 # Flow-control windows, in bytes. Omit for the backend default. -stream_receive_window = 8388608 -send_window = 33554432 +receive_window = 67108864 # Whole-connection window, in bytes. Default (64 MiB). +stream_receive_window = 8388608 # Per-stream window. Omit for the backend default. +send_window = 33554432 # Unacknowledged outgoing data. Omit for the backend default. qlog = "/var/log/moq/qlog" # Existing directory. Needs the `qlog` build feature. ``` @@ -74,6 +74,10 @@ again before the deadline. Raise the receive windows when a fat, long path idles below the link rate: a window under the bandwidth-delay product stalls the sender waiting for credit. +`receive_window` bounds how much unread data one peer can make the relay buffer +across all of its streams; the transport's own default is unlimited, so it +defaults to 64 MiB, enough for a relay-to-relay session to carry several Gbps at +a 100 ms RTT. Keep `stream_receive_window` well under `receive_window` so one slow group cannot starve the connection. `send_window` caps unacknowledged outgoing data whatever the peer allows, bounding the transport send buffer. A zero window is refused, and the receive windows must fit a QUIC diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index ac11e13127..4170a70ef6 100644 --- a/doc/lib/rs/index.md +++ b/doc/lib/rs/index.md @@ -102,7 +102,8 @@ quic.send_window = Some(32 << 20); // unacknowledged data we may hold let client = moq_tokio::connect::Config::default().init(quic)?; ``` -Unset flow-control windows keep the transport defaults, and `init` errors on a +Unset flow-control windows keep the transport defaults, except `receive_window`, +which defaults to 64 MiB because the transport's is unlimited. `init` errors on a knob the transport cannot honor rather than dropping it: iroh cannot disable GSO. `quic::Resolved::default()` is what an untouched config resolves to, so read the defaults from there. The diff --git a/rs/moq-tokio/src/iroh.rs b/rs/moq-tokio/src/iroh.rs index 951260a137..43cfb02c2c 100644 --- a/rs/moq-tokio/src/iroh.rs +++ b/rs/moq-tokio/src/iroh.rs @@ -253,10 +253,10 @@ impl Config { if !quic.mtu_discovery { transport = transport.mtu_discovery_config(None); } - if let Some(window) = quic.receive_window { - let window = iroh::endpoint::VarInt::from_u64(window).unwrap_or(iroh::endpoint::VarInt::MAX); - transport = transport.receive_window(window); - } + // iroh's connection window is unlimited, so apply ours when unset. + let window = quic.receive_window.unwrap_or(crate::quic::DEFAULT_RECEIVE_WINDOW); + let window = iroh::endpoint::VarInt::from_u64(window).unwrap_or(iroh::endpoint::VarInt::MAX); + transport = transport.receive_window(window); if let Some(window) = quic.stream_receive_window { let window = iroh::endpoint::VarInt::from_u64(window).unwrap_or(iroh::endpoint::VarInt::MAX); transport = transport.stream_receive_window(window); diff --git a/rs/moq-tokio/src/noq.rs b/rs/moq-tokio/src/noq.rs index 15e3692d75..c831288691 100644 --- a/rs/moq-tokio/src/noq.rs +++ b/rs/moq-tokio/src/noq.rs @@ -60,13 +60,13 @@ fn apply_transport(transport: &mut noq::TransportConfig, quic: &Resolved) { transport.congestion_controller_factory(congestion_factory(quic.congestion())); } -/// Apply the flow-control windows, leaving each at the backend default when unset. +/// Apply the flow-control windows. The connection window defaults to ours, since the +/// backend's is unlimited; the others stay at the backend default when unset. fn apply_windows(transport: &mut noq::TransportConfig, quic: &Resolved) { // Saturating rather than erroring: `Config::validate` already rejects a window // past the varint, so this only bites a `Resolved` built without it. - if let Some(window) = quic.receive_window { - transport.receive_window(noq::VarInt::from_u64(window).unwrap_or(noq::VarInt::MAX)); - } + let window = quic.receive_window.unwrap_or(crate::quic::DEFAULT_RECEIVE_WINDOW); + transport.receive_window(noq::VarInt::from_u64(window).unwrap_or(noq::VarInt::MAX)); if let Some(window) = quic.stream_receive_window { transport.stream_receive_window(noq::VarInt::from_u64(window).unwrap_or(noq::VarInt::MAX)); } @@ -1043,14 +1043,13 @@ mod tests { } /// noq exposes no getters for the flow-control windows, but its `Debug` prints - /// them, which is enough to prove each one reached the transport config and that - /// an unset knob leaves noq's own default in place. + /// them, which is enough to prove each one reached the transport config, that an + /// unset connection window gets our finite default instead of noq's unlimited one, + /// and that the other unset knobs leave noq's own defaults in place. #[test] fn apply_windows_writes_each_field() { - let defaults = format!("{:?}", noq::TransportConfig::default()); - let mut quic = crate::quic::Config::default(); - quic.receive_window = Some(64 << 20); + quic.receive_window = Some(128 << 20); quic.stream_receive_window = Some(8 << 20); quic.send_window = Some(32 << 20); @@ -1059,7 +1058,7 @@ mod tests { let applied = format!("{:?}", transport); for field in [ - format!("receive_window: {}", 64 << 20), + format!("receive_window: {}", 128 << 20), format!("stream_receive_window: {}", 8 << 20), format!("send_window: {}", 32 << 20), ] { @@ -1068,7 +1067,14 @@ mod tests { let mut untouched = noq::TransportConfig::default(); apply_windows(&mut untouched, &crate::quic::Config::default().resolve()); - assert_eq!(format!("{:?}", untouched), defaults); + let mut expected = noq::TransportConfig::default(); + expected.receive_window(noq::VarInt::from_u64(crate::quic::DEFAULT_RECEIVE_WINDOW).unwrap()); + assert_eq!(format!("{:?}", untouched), format!("{:?}", expected)); + assert_ne!( + format!("{:?}", untouched), + format!("{:?}", noq::TransportConfig::default()), + "noq's own connection window is unlimited, so ours must replace it" + ); } /// Build a controller from each family's factory and downcast it to the diff --git a/rs/moq-tokio/src/quic.rs b/rs/moq-tokio/src/quic.rs index 275ba84d6e..b375f95bc4 100644 --- a/rs/moq-tokio/src/quic.rs +++ b/rs/moq-tokio/src/quic.rs @@ -82,6 +82,14 @@ impl std::str::FromStr for CongestionControl { /// Default maximum number of concurrent QUIC streams (bidi and uni) per connection. pub const DEFAULT_MAX_STREAMS: u64 = 10_000; +/// Default connection-wide receive window, used when [`Config::receive_window`] is unset. +/// +/// Bounds how much unread data one peer can make us buffer across all of its streams, +/// which the backends otherwise leave unlimited. Sized so a relay-to-relay session, which +/// shares one connection, can still sustain several Gbps at intercontinental RTTs. +#[cfg(any(feature = "noq", feature = "iroh"))] +pub(crate) const DEFAULT_RECEIVE_WINDOW: u64 = 64 << 20; + /// Default idle timeout before an inactive connection is dropped. /// /// A peer that dies or drops off the network sends no close, so this is how long @@ -189,7 +197,7 @@ pub struct Config { pub congestion_control: Option, /// Connection-wide receive window, in bytes: how much data the peer may have - /// in flight across every stream at once. Unset leaves the backend default. + /// in flight across every stream at once. Defaults to 64 MiB. /// /// Raise it when a fat, long path is idling below the link rate: the window /// has to cover the bandwidth-delay product or the peer stalls waiting for @@ -605,7 +613,7 @@ pub struct Resolved { pub mtu_discovery: bool, /// Congestion control override, or `None` for the default. pub congestion_control: Option, - /// Connection-wide receive window in bytes, or `None` for the backend default. + /// Connection-wide receive window in bytes, or `None` for the 64 MiB default. pub receive_window: Option, /// Per-stream receive window in bytes, or `None` for the backend default. pub stream_receive_window: Option, @@ -845,8 +853,8 @@ mod tests { assert!(at_limit.validate().is_ok()); } - /// Each window reaches `Resolved` untouched, and stays `None` when unset so the - /// backend keeps its own default rather than being pinned to ours. + /// Each window reaches `Resolved` untouched, and stays `None` when unset so each + /// backend applies the default itself (and the io_uring workers can tell it was unset). #[test] fn windows_resolve_per_field() { let unset = Config::default().resolve(); From b084269d17559b67138a72ebc08ada072c5114bc Mon Sep 17 00:00:00 2001 From: "moq-bot[bot]" <186640430+moq-bot[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:06:50 -0700 Subject: [PATCH 3/3] chore: release (#4867) Co-authored-by: moq-bot[bot] <186640430+moq-bot[bot]@users.noreply.github.com> --- Cargo.lock | 42 +++++++++++++++++++-------------------- Cargo.toml | 8 ++++---- rs/libmoq/CHANGELOG.md | 6 ++++++ rs/libmoq/Cargo.toml | 2 +- rs/moq-boy/CHANGELOG.md | 6 ++++++ rs/moq-boy/Cargo.toml | 2 +- rs/moq-cli/CHANGELOG.md | 6 ++++++ rs/moq-cli/Cargo.toml | 2 +- rs/moq-ffi/CHANGELOG.md | 6 ++++++ rs/moq-ffi/Cargo.toml | 2 +- rs/moq-gst/CHANGELOG.md | 6 ++++++ rs/moq-gst/Cargo.toml | 2 +- rs/moq-relay/CHANGELOG.md | 6 ++++++ rs/moq-relay/Cargo.toml | 2 +- rs/moq-rtc/CHANGELOG.md | 6 ++++++ rs/moq-rtc/Cargo.toml | 2 +- rs/moq-stats/CHANGELOG.md | 6 ++++++ rs/moq-stats/Cargo.toml | 2 +- rs/moq-tokio/CHANGELOG.md | 6 ++++++ rs/moq-tokio/Cargo.toml | 2 +- 20 files changed, 88 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fd98e16425..5ef52f29da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1603,9 +1603,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ "cmov", "subtle", @@ -2054,7 +2054,7 @@ dependencies = [ "elliptic-curve", "rfc6979", "signature 3.0.0", - "spki 0.8.0", + "spki 0.8.1", "zeroize", ] @@ -3894,7 +3894,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmoq" -version = "0.6.11" +version = "0.6.12" dependencies = [ "anyhow", "bytes", @@ -4274,7 +4274,7 @@ dependencies = [ [[package]] name = "moq-boy" -version = "0.5.11" +version = "0.5.12" dependencies = [ "anyhow", "boytacean", @@ -4294,7 +4294,7 @@ dependencies = [ [[package]] name = "moq-cli" -version = "0.14.1" +version = "0.14.2" dependencies = [ "anyhow", "axum", @@ -4352,7 +4352,7 @@ dependencies = [ [[package]] name = "moq-ffi" -version = "0.4.11" +version = "0.4.12" dependencies = [ "bytes", "getrandom 0.4.3", @@ -4386,7 +4386,7 @@ dependencies = [ [[package]] name = "moq-gst" -version = "0.4.11" +version = "0.4.12" dependencies = [ "anyhow", "bytes", @@ -4620,7 +4620,7 @@ dependencies = [ [[package]] name = "moq-relay" -version = "0.17.1" +version = "0.17.2" dependencies = [ "anyhow", "axum", @@ -4679,7 +4679,7 @@ dependencies = [ [[package]] name = "moq-rtc" -version = "0.3.11" +version = "0.3.12" dependencies = [ "aws-lc-rs", "axum", @@ -4768,7 +4768,7 @@ dependencies = [ [[package]] name = "moq-stats" -version = "0.2.11" +version = "0.2.12" dependencies = [ "futures", "moq-json", @@ -4801,7 +4801,7 @@ dependencies = [ [[package]] name = "moq-tokio" -version = "0.19.22" +version = "0.19.23" dependencies = [ "anyhow", "bytes", @@ -6574,7 +6574,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ "der 0.8.2", - "spki 0.8.0", + "spki 0.8.1", ] [[package]] @@ -8252,9 +8252,9 @@ dependencies = [ [[package]] name = "spki" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" dependencies = [ "base64ct", "der 0.8.2", @@ -9544,9 +9544,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -10902,18 +10902,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.59" +version = "0.8.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +checksum = "6400fc4bb7426f6faac3fb07566122bb18622e3022bd2c36426a92ed489b1dbe" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.59" +version = "0.8.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +checksum = "99c27cc7525bad4df59a45e073c45b2c2aa820efb588234cc23a547ec69a34db" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 320004a1a5..6f10dc407c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -158,13 +158,13 @@ moq-noq-udp = "1.3.3" # used by moq-video on Linux. moq-nvenc = { version = "0.1.2", path = "rs/moq-nvenc" } moq-pattern = { version = "0.1.2", path = "rs/moq-pattern" } -moq-relay = { version = "0.17.1", path = "rs/moq-relay", default-features = false } -moq-rtc = { version = "0.3.11", path = "rs/moq-rtc" } +moq-relay = { version = "0.17.2", path = "rs/moq-relay", default-features = false } +moq-rtc = { version = "0.3.12", path = "rs/moq-rtc" } moq-rtmp = { version = "0.3.11", path = "rs/moq-rtmp" } moq-sock = { version = "0.2.0", path = "rs/moq-sock" } moq-srt = { version = "0.3.11", path = "rs/moq-srt" } -moq-stats = { version = "0.2.11", path = "rs/moq-stats" } -moq-tokio = { version = "0.19.22", path = "rs/moq-tokio", default-features = false } +moq-stats = { version = "0.2.12", path = "rs/moq-stats" } +moq-tokio = { version = "0.19.23", path = "rs/moq-tokio", default-features = false } # Default features off on moq-transcode and moq-video so each workspace consumer # chooses native codecs, OpenH264, and rendering explicitly. Both crates still # provide working native plus software defaults when depended on directly. diff --git a/rs/libmoq/CHANGELOG.md b/rs/libmoq/CHANGELOG.md index fb5230aa8d..0f927c0668 100644 --- a/rs/libmoq/CHANGELOG.md +++ b/rs/libmoq/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.12](https://github.com/moq-dev/moq/compare/libmoq-v0.6.11...libmoq-v0.6.12) - 2026-10-06 + +### Other + +- updated the following local packages: moq-tokio + ## [0.6.11](https://github.com/moq-dev/moq/compare/libmoq-v0.6.10...libmoq-v0.6.11) - 2026-10-05 ### Other diff --git a/rs/libmoq/Cargo.toml b/rs/libmoq/Cargo.toml index e0b28d8090..ddabe63515 100644 --- a/rs/libmoq/Cargo.toml +++ b/rs/libmoq/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley ", "Brian Medley " repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.6.11" +version = "0.6.12" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-boy/CHANGELOG.md b/rs/moq-boy/CHANGELOG.md index 3f001294bb..6c6db9f2d8 100644 --- a/rs/moq-boy/CHANGELOG.md +++ b/rs/moq-boy/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.12](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.11...moq-boy-v0.5.12) - 2026-10-06 + +### Other + +- updated the following local packages: moq-tokio + ## [0.5.11](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.10...moq-boy-v0.5.11) - 2026-10-05 ### Other diff --git a/rs/moq-boy/Cargo.toml b/rs/moq-boy/Cargo.toml index 4b23c0f282..a20246aa6d 100644 --- a/rs/moq-boy/Cargo.toml +++ b/rs/moq-boy/Cargo.toml @@ -7,7 +7,7 @@ license = "MIT OR Apache-2.0" keywords = ["moq", "gameboy", "streaming", "emulator", "live"] categories = ["multimedia::video", "emulators", "network-programming"] -version = "0.5.11" +version = "0.5.12" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-cli/CHANGELOG.md b/rs/moq-cli/CHANGELOG.md index 5bc00687b5..0291c228f3 100644 --- a/rs/moq-cli/CHANGELOG.md +++ b/rs/moq-cli/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.2](https://github.com/moq-dev/moq/compare/moq-cli-v0.14.1...moq-cli-v0.14.2) - 2026-10-06 + +### Other + +- updated the following local packages: moq-tokio, moq-relay, moq-rtc + ## [0.14.1](https://github.com/moq-dev/moq/compare/moq-cli-v0.14.0...moq-cli-v0.14.1) - 2026-10-05 ### Other diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index ebc81bea28..fbd7d2b083 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-cli/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.14.1" +version = "0.14.2" edition = "2024" # Depends on moq-relay, whose sysinfo 0.39 needs 1.95, above the 1.91 workspace # floor. This binary is an application, so the bump stays here rather than diff --git a/rs/moq-ffi/CHANGELOG.md b/rs/moq-ffi/CHANGELOG.md index 4d78530cee..c24f9c3551 100644 --- a/rs/moq-ffi/CHANGELOG.md +++ b/rs/moq-ffi/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.12](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.11...moq-ffi-v0.4.12) - 2026-10-06 + +### Other + +- updated the following local packages: moq-tokio + ## [0.4.11](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.10...moq-ffi-v0.4.11) - 2026-10-05 ### Other diff --git a/rs/moq-ffi/Cargo.toml b/rs/moq-ffi/Cargo.toml index 48c5e3e0f1..6311b8deec 100644 --- a/rs/moq-ffi/Cargo.toml +++ b/rs/moq-ffi/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley ", "Brian Medley " repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.4.11" +version = "0.4.12" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-gst/CHANGELOG.md b/rs/moq-gst/CHANGELOG.md index ee25d344ad..ad9d35e3c4 100644 --- a/rs/moq-gst/CHANGELOG.md +++ b/rs/moq-gst/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.12](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.11...moq-gst-v0.4.12) - 2026-10-06 + +### Other + +- updated the following local packages: moq-tokio + ## [0.4.11](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.10...moq-gst-v0.4.11) - 2026-10-05 ### Other diff --git a/rs/moq-gst/Cargo.toml b/rs/moq-gst/Cargo.toml index 02739d5c8f..cc4e410613 100644 --- a/rs/moq-gst/Cargo.toml +++ b/rs/moq-gst/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.4.11" +version = "0.4.12" edition = "2024" rust-version.workspace = true publish = true diff --git a/rs/moq-relay/CHANGELOG.md b/rs/moq-relay/CHANGELOG.md index 3a132ea274..b9142cd7b0 100644 --- a/rs/moq-relay/CHANGELOG.md +++ b/rs/moq-relay/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.17.2](https://github.com/moq-dev/moq/compare/moq-relay-v0.17.1...moq-relay-v0.17.2) - 2026-10-06 + +### Added + +- *(stats)* linger an empty group broadcast before unannouncing it (backport #4871) + ## [0.17.1](https://github.com/moq-dev/moq/compare/moq-relay-v0.17.0...moq-relay-v0.17.1) - 2026-10-05 ### Fixed diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index f13f1e7c07..0e3581da11 100644 --- a/rs/moq-relay/Cargo.toml +++ b/rs/moq-relay/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.17.1" +version = "0.17.2" edition = "2024" # sysinfo 0.39 (cache governor cgroup limits) needs 1.95, above the 1.91 # workspace floor. moq-relay is lib+bin, so this applies to its library target diff --git a/rs/moq-rtc/CHANGELOG.md b/rs/moq-rtc/CHANGELOG.md index d7f55a3399..5a298f0579 100644 --- a/rs/moq-rtc/CHANGELOG.md +++ b/rs/moq-rtc/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.12](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.11...moq-rtc-v0.3.12) - 2026-10-06 + +### Other + +- updated the following local packages: moq-tokio + ## [0.3.11](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.10...moq-rtc-v0.3.11) - 2026-10-05 ### Other diff --git a/rs/moq-rtc/Cargo.toml b/rs/moq-rtc/Cargo.toml index 7148871842..9bc42544ef 100644 --- a/rs/moq-rtc/Cargo.toml +++ b/rs/moq-rtc/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.3.11" +version = "0.3.12" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-stats/CHANGELOG.md b/rs/moq-stats/CHANGELOG.md index e863446263..d9ca6d432d 100644 --- a/rs/moq-stats/CHANGELOG.md +++ b/rs/moq-stats/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.12](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.11...moq-stats-v0.2.12) - 2026-10-06 + +### Added + +- *(stats)* linger an empty group broadcast before unannouncing it (backport #4871) + ## [0.2.11](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.10...moq-stats-v0.2.11) - 2026-10-05 ### Fixed diff --git a/rs/moq-stats/Cargo.toml b/rs/moq-stats/Cargo.toml index cc6e27e3f8..ef821dbe46 100644 --- a/rs/moq-stats/Cargo.toml +++ b/rs/moq-stats/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.2.11" +version = "0.2.12" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-tokio/CHANGELOG.md b/rs/moq-tokio/CHANGELOG.md index cbcb38808a..1a9b8ba021 100644 --- a/rs/moq-tokio/CHANGELOG.md +++ b/rs/moq-tokio/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.23](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.22...moq-tokio-v0.19.23) - 2026-10-06 + +### Fixed + +- *(moq-tokio)* finite default connection receive window (backport #4605) ([#4858](https://github.com/moq-dev/moq/pull/4858)) + ## [0.19.22](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.21...moq-tokio-v0.19.22) - 2026-10-05 ### Other diff --git a/rs/moq-tokio/Cargo.toml b/rs/moq-tokio/Cargo.toml index 81ba5b8df1..f9cc1a9be8 100644 --- a/rs/moq-tokio/Cargo.toml +++ b/rs/moq-tokio/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.19.22" +version = "0.19.23" edition = "2024" rust-version.workspace = true