From b00dfffd17c6d2f770edf415cf4c95a34c71c368 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 16:35:36 -0700 Subject: [PATCH 01/45] quest: scope JS announce interest and close sessions gracefully (#4209) Co-authored-by: Grok 4.7 --- quest/m1/README.md | 2 ++ quest/m1/js-origin-scope.md | 27 +++++++++++++++++++++++++++ quest/m1/session-close.md | 29 +++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 quest/m1/js-origin-scope.md create mode 100644 quest/m1/session-close.md diff --git a/quest/m1/README.md b/quest/m1/README.md index fee11fb135..f3b9d5c9da 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -34,6 +34,8 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` is the one way to end a broadcast in every language, a permanent retraction that leaves in-flight tracks alone - [Relay peer set](/quest/m1/relay-peer-set.md) - a wire consumer tells a client hop from a peer hop, and every mesh credential can mark a peer - [CLI inspection](/quest/m1/cli-inspect/README.md) - `moq ls` lists what is live and `moq fetch` reads a group over MoQ, and a guide shows how to inspect a relay +- [JS origin scope](/quest/m1/js-origin-scope.md) - a scoped `@moq/net` origin subscribes only to its allowed prefixes +- [Session close](/quest/m1/session-close.md) - a graceful session end withdraws announces and waits one second for the ack - [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust - [Bindings caught up](/quest/m1/announce-live-bindings.md) - moq-ffi, libmoq, and every wrapper yield the same flat announce event, `Live` included - [Optional max age](/quest/m1/ietf-max-age.md) - max age is optional, set only by the publisher, and crosses moq-transport as MAX_CACHE_DURATION diff --git a/quest/m1/js-origin-scope.md b/quest/m1/js-origin-scope.md new file mode 100644 index 0000000000..9bc8530c60 --- /dev/null +++ b/quest/m1/js-origin-scope.md @@ -0,0 +1,27 @@ +# [M] JS origin scope drives announce interest + +## Goal + +A `@moq/net` origin scoped to a prefix asks the peer only for that prefix. +An origin that was never scoped still asks for every namespace. A relay that +ignores an empty `SUBSCRIBE_NAMESPACE` then delivers a namespace the caller +actually named. + +## Plan + +`Producer.scope(root, patterns)` matches Rust `origin::Producer::scope`: the +root is stripped from paths, and the patterns are what the handle may publish +and subscribe under. `forwardAnnounced` sends one `SUBSCRIBE_NAMESPACE`, and +the moq-lite equivalent, per literal head of those patterns, the same rule as +Rust `interest_prefixes`. Hidden routes stay opted in on those subscriptions. + +An unscoped origin still allows `**`. Its head is the empty prefix, so the +session sends one subscription for everything, which is today's behavior. + +`doc/concept/moq-lite.md` says the prefixes a session asks for are the +origin's scope. No new page. + +## Related + +- [JS caught up](/quest/m1/js-announce-caught-up.md) - when the replay of those prefixes has landed +- [IETF announce count](/quest/m1/ietf-announce-count.md) - how an IETF consumer knows that replay is finished diff --git a/quest/m1/session-close.md b/quest/m1/session-close.md new file mode 100644 index 0000000000..a846d5e977 --- /dev/null +++ b/quest/m1/session-close.md @@ -0,0 +1,29 @@ +# [M] Graceful session close withdraws announces + +## Goal + +Ending a session on purpose withdraws the namespaces it published and waits +for the peer to acknowledge that, up to one second. `abort`, and dropping the +last handle, still end the session immediately and do not wait. + +## Plan + +Rust has `abort` and drop, and both end the session now. Drop sends a bare +`Cancel`, so `PUBLISH_NAMESPACE_DONE` never goes out. Add +`moq_tokio::Connection::close()`. It unannounces what this session published, +waits up to one second for the acknowledgements, then ends the session. If +the peer has not answered by then, the session still ends and `close()` +returns that timeout. There is no existing request-ack timer to reuse; this +one second is its own constant. + +JS `Established.close()` is synchronous today. It becomes the same graceful +end and returns a promise, which is a published break, so that change targets +`dev`. `abort` stays immediate in both languages. + +`doc/concept/moq-lite.md` says a graceful close withdraws announces and an +abort does not. No new page. + +## Related + +- [Session death error](/quest/m1/session-death-error.md) - a session that dies still ends its tracks with its own error +- [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` on a broadcast, which is not this session end From 3e52c7c0c2fb7573df827e9227cb7826a18f05af Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 16:43:40 -0700 Subject: [PATCH 02/45] fix(nvenc): commit rate changes and cleanup only once the driver accepts (#4146) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- quest/m1/README.md | 2 +- quest/m1/nvdec-teardown.md | 21 ++++ quest/m1/nvenc-recovery.md | 25 ---- rs/moq-nvenc/src/safe/buffer.rs | 77 ++++++++++-- rs/moq-nvenc/src/safe/encoder.rs | 102 +++++++++++++-- rs/moq-nvenc/src/safe/session.rs | 152 +++++++++++++++++++---- rs/moq-video/src/encode/backend/nvenc.rs | 29 +++++ 7 files changed, 344 insertions(+), 64 deletions(-) create mode 100644 quest/m1/nvdec-teardown.md delete mode 100644 quest/m1/nvenc-recovery.md diff --git a/quest/m1/README.md b/quest/m1/README.md index f3b9d5c9da..5990696a70 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -58,7 +58,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [OBS native codecs](/quest/m1/obs-moq-video/README.md) - remove FFmpeg decoding dependencies, deliver GPU frames, and use native audio/video encoders - [Audio codecs](/quest/m1/audio-codecs/README.md) - platform audio codecs, explicit unsupported cases, and channel layouts up to 7.1 - [CMAF Opus](/quest/m1/cmaf-opus-dops.md) - fMP4 import and export keep the Opus pre-skip and gain -- [NVENC recovery](/quest/m1/nvenc-recovery.md) - partial initialization and rejected rate changes preserve valid state +- [NVDEC teardown](/quest/m1/nvdec-teardown.md) - dropping an NVDEC decoder no longer segfaults - [GPU pool reservation](/quest/m1/gpu-pool-reservation.md) - a full GPU frame pool is a `None` reservation the caller drops on, not an error to match - [Transcode source](/quest/m1/transcode-source.md) - select a rendition the chosen backend can actually decode - [Egress rendition pick](/quest/m1/egress-rendition-pick.md) - WHEP and single-track RTMP/FLV serve the best rendition, not the first by name diff --git a/quest/m1/nvdec-teardown.md b/quest/m1/nvdec-teardown.md new file mode 100644 index 0000000000..cb631e47c5 --- /dev/null +++ b/quest/m1/nvdec-teardown.md @@ -0,0 +1,21 @@ +# [S] Dropping an NVDEC decoder does not crash + +## Goal + +Dropping a `moq-video` NVDEC decoder releases it without a segfault, and the +hardware tests `nvdec_h264_round_trip` and `nvdec_resize_scales_output` pass. + +## Plan + +Both tests die with SIGSEGV on `main` on an RTX 3070 Ti (driver 595.91), while +`nvdec_h265_round_trip` and `nvdec_to_nvenc_zero_copy` pass. The backtrace +ends in `cuEventDestroy_v2`, called by `cuvidDestroyDecoder` from +`::drop` while the test drops the backend. That +`Drop` assumes the caller keeps the CUDA context bound, which nothing +enforces. Suspect the context is not current on the dropping thread, or is +released before the decoder; confirm before fixing. + +Under the Nix dev shell the driver libraries are not on the loader path, so +the NVIDIA tests skip. To run them, expose `libcuda`, `libnvidia-encode`, and +`libnvcuvid` from `/usr/lib/x86_64-linux-gnu` through a directory of symlinks +on `LD_LIBRARY_PATH`. diff --git a/quest/m1/nvenc-recovery.md b/quest/m1/nvenc-recovery.md deleted file mode 100644 index 201b96cbc7..0000000000 --- a/quest/m1/nvenc-recovery.md +++ /dev/null @@ -1,25 +0,0 @@ -# [M] Recover NVENC initialization and rate-change failures - -## Goal - -NVENC releases every partially initialized resource, destruction does not -panic, and a rejected rate change leaves the last accepted settings intact. - -## Plan - -Registration rollback and non-panicking destructors landed with the ownership -work (#3834, #3835, #3838). A submission whose wait fails abandons its -buffers: the raw handles leak, but the encoder reference is released so the -session is still destroyed. Session::reconfigure still mutates retained -bitrate/VBV fields before the driver accepts the change; a rejected zero-rate -update can corrupt the basis of the next proportional update. - -Use the settled resource ownership model and a fake driver to verify rollback -after each acquisition stage, cleanup order, and teardown during an existing -failure. Compute candidate rate settings with checked arithmetic and commit -only on driver success. A following successful update must be based on the -last accepted settings. Preserve fallible explicit operations and non-panicking -Drop without disguising a live operational failure as success. - -Run the failure-injection suite in CI. Do not duplicate cleanup guarantees -already delivered by the ownership quest. Public API and wire: unchanged. diff --git a/rs/moq-nvenc/src/safe/buffer.rs b/rs/moq-nvenc/src/safe/buffer.rs index 3234005885..a2f135994d 100644 --- a/rs/moq-nvenc/src/safe/buffer.rs +++ b/rs/moq-nvenc/src/safe/buffer.rs @@ -687,8 +687,8 @@ struct Mapping { reg_ptr: *mut c_void, map_ptr: *mut c_void, api: A, - // Dropped after the resource is unregistered. - _marker: T, + // Dropped only once the resource is unregistered. + marker: ManuallyDrop, } impl Mapping { @@ -712,7 +712,7 @@ impl Mapping { reg_ptr, map_ptr, api, - _marker: marker, + marker: ManuallyDrop::new(marker), }) } } @@ -721,8 +721,17 @@ impl Mapping { /// when it goes out of scope. impl Drop for Mapping { fn drop(&mut self) { - let _ = self.api.unmap_input_resource(self.map_ptr); - let _ = self.api.unregister_resource(self.reg_ptr); + // A failed unmap leaves the resource mapped. Unregistering or freeing + // it could release memory NVENC still holds, so leak the owner instead. + if self.api.unmap_input_resource(self.map_ptr).is_err() { + return; + } + // As in `new`: NVENC may still refer to an allocation it failed to + // unregister, so leak its owner rather than free it. + if self.api.unregister_resource(self.reg_ptr).is_ok() { + // SAFETY: dropped once, here, and never touched again. + unsafe { ManuallyDrop::drop(&mut self.marker) }; + } } } @@ -771,8 +780,10 @@ mod tests { #[derive(Debug)] struct TestApi { calls: RefCell, + mapped: Cell, owner_alive: Rc>, map_error: Option, + unmap_error: Option, unregister_error: Option, } @@ -780,8 +791,10 @@ mod tests { fn new(owner_alive: Rc>) -> Self { Self { calls: RefCell::new(Calls::default()), + mapped: Cell::new(false), owner_alive, map_error: None, + unmap_error: None, unregister_error: None, } } @@ -810,18 +823,28 @@ mod tests { self.calls.borrow_mut().map += 1; match self.map_error { Some(kind) => Err(EncodeError::new(kind, None)), - None => Ok(TestApi::handle()), + None => { + self.mapped.set(true); + Ok(TestApi::handle()) + } } } fn unmap_input_resource(&self, _mapped: *mut c_void) -> Result<(), EncodeError> { self.assert_owner_alive(); self.calls.borrow_mut().unmap += 1; - Ok(()) + match self.unmap_error { + Some(kind) => Err(EncodeError::new(kind, None)), + None => { + self.mapped.set(false); + Ok(()) + } + } } fn unregister_resource(&self, _registered: *mut c_void) -> Result<(), EncodeError> { self.assert_owner_alive(); + assert!(!self.mapped.get(), "unregistered a resource that is still mapped"); self.calls.borrow_mut().unregister += 1; match self.unregister_error { Some(kind) => Err(EncodeError::new(kind, None)), @@ -931,4 +954,44 @@ mod tests { } ); } + + #[test] + fn failed_unmap_on_drop_retains_the_owner() { + let (owner, alive) = setup(); + let mut api = TestApi::new(alive.clone()); + api.unmap_error = Some(ErrorKind::ResourceNotMapped); + + drop(register(&api, owner).expect("mapping should succeed")); + + assert!(alive.get(), "a possibly mapped allocation must remain owned"); + assert_eq!( + *api.calls.borrow(), + Calls { + register: 1, + map: 1, + unmap: 1, + unregister: 0, + } + ); + } + + #[test] + fn failed_unregister_on_drop_retains_the_owner() { + let (owner, alive) = setup(); + let mut api = TestApi::new(alive.clone()); + api.unregister_error = Some(ErrorKind::ResourceNotRegistered); + + drop(register(&api, owner).expect("mapping should succeed")); + + assert!(alive.get(), "a possibly registered allocation must remain owned"); + assert_eq!( + *api.calls.borrow(), + Calls { + register: 1, + map: 1, + unmap: 1, + unregister: 1, + } + ); + } } diff --git a/rs/moq-nvenc/src/safe/encoder.rs b/rs/moq-nvenc/src/safe/encoder.rs index af50e7846d..f94ecb9a8f 100644 --- a/rs/moq-nvenc/src/safe/encoder.rs +++ b/rs/moq-nvenc/src/safe/encoder.rs @@ -94,7 +94,6 @@ impl Encoder { /// ``` pub fn initialize_with_cuda(cuda_ctx: Arc) -> Result { let api = api::get()?; - let mut encoder = ptr::null_mut(); let mut session_params = NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS { version: NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER, deviceType: NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_CUDA, @@ -104,14 +103,10 @@ impl Encoder { device: cuda_ctx.cu_ctx().cast::(), ..Default::default() }; - - if let err @ Err(_) = - unsafe { (api.open_encode_session_ex)(&mut session_params, &mut encoder) }.result_without_string() - { - // We are required to destroy the encoder if there was an error. - unsafe { (api.destroy_encoder)(encoder) }.result_without_string()?; - err?; - } + let encoder = open_session( + |encoder| unsafe { (api.open_encode_session_ex)(&mut session_params, encoder) }.result_without_string(), + |encoder| unsafe { (api.destroy_encoder)(encoder) }.result_without_string(), + )?; Ok(Self { ptr: encoder, @@ -460,6 +455,26 @@ impl Encoder { } } +/// Open an encode session, destroying whatever a failed open left behind as +/// NVENC requires. A failed destroy is attached to the open error, not +/// reported in its place. +fn open_session( + open: impl FnOnce(&mut *mut c_void) -> Result<(), EncodeError>, + destroy: impl FnOnce(*mut c_void) -> Result<(), EncodeError>, +) -> Result<*mut c_void, EncodeError> { + let mut encoder = ptr::null_mut(); + let Err(primary) = open(&mut encoder) else { + return Ok(encoder); + }; + if encoder.is_null() { + return Err(primary); + } + match destroy(encoder) { + Ok(()) => Err(primary), + Err(cleanup) => Err(primary.with_cleanup(cleanup)), + } +} + /// A safe wrapper for [`NV_ENC_INITIALIZE_PARAMS`], which is the encoder /// initialize parameter. pub struct EncoderInitParams { @@ -554,3 +569,72 @@ impl EncoderInitParams { self } } + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + + use super::*; + use crate::safe::result::ErrorKind; + + fn handle() -> *mut c_void { + ptr::NonNull::::dangling().as_ptr().cast() + } + + fn fail(kind: ErrorKind) -> Result<(), EncodeError> { + Err(EncodeError::new(kind, None)) + } + + #[test] + fn failed_open_destroys_the_partial_session() { + let destroyed = RefCell::new(Vec::new()); + let error = open_session( + |encoder| { + *encoder = handle(); + fail(ErrorKind::InvalidVersion) + }, + |encoder| { + destroyed.borrow_mut().push(encoder); + Ok(()) + }, + ) + .expect_err("open failed"); + assert_eq!(error.kind(), ErrorKind::InvalidVersion); + assert!(error.cleanup().is_none()); + assert_eq!(*destroyed.borrow(), [handle()]); + } + + #[test] + fn failed_destroy_keeps_the_open_error() { + let error = open_session( + |encoder| { + *encoder = handle(); + fail(ErrorKind::NoEncodeDevice) + }, + |_| fail(ErrorKind::InvalidPtr), + ) + .expect_err("open failed"); + assert_eq!(error.kind(), ErrorKind::NoEncodeDevice); + assert_eq!(error.cleanup().map(EncodeError::kind), Some(ErrorKind::InvalidPtr)); + } + + #[test] + fn failed_open_without_a_handle_destroys_nothing() { + let error = open_session(|_| fail(ErrorKind::OutOfMemory), |_| panic!("destroyed a null session")) + .expect_err("open failed"); + assert_eq!(error.kind(), ErrorKind::OutOfMemory); + } + + #[test] + fn successful_open_destroys_nothing() { + let encoder = open_session( + |encoder| { + *encoder = handle(); + Ok(()) + }, + |_| panic!("destroyed a live session"), + ) + .unwrap(); + assert_eq!(encoder, handle()); + } +} diff --git a/rs/moq-nvenc/src/safe/session.rs b/rs/moq-nvenc/src/safe/session.rs index 5e6156cd51..68d2e3fea5 100644 --- a/rs/moq-nvenc/src/safe/session.rs +++ b/rs/moq-nvenc/src/safe/session.rs @@ -114,9 +114,12 @@ impl Session { /// # Errors /// /// Returns [`ErrorKind::InvalidParam`] when the session was started without - /// an encode config, since there is then no config to resubmit. Otherwise - /// returns whatever `NvEncReconfigureEncoder` reports, e.g. + /// an encode config, since there is then no config to resubmit, when + /// `bitrate` is zero, when a nonzero VBV would scale to zero, or when the + /// proportionally scaled VBV overflows. + /// Otherwise returns whatever `NvEncReconfigureEncoder` reports, e.g. /// [`ErrorKind::UnsupportedParam`] if the driver rejects the rate change. + /// After any error the session keeps its last accepted rate settings. pub fn reconfigure(&mut self, bitrate: u32) -> Result<(), EncodeError> { let Some(config) = self.config.as_mut() else { return Err(EncodeError::new( @@ -124,32 +127,16 @@ impl Session { Some("session was started without an encode config to reconfigure".into()), )); }; - - // Keep a caller-sized VBV proportional to the rate, so a buffer sized to - // one frame at open stays one frame: left alone it would loosen the - // keyframe cap as the bitrate falls, right when the link can least afford it. - if config.rcParams.vbvBufferSize != 0 && config.rcParams.averageBitRate != 0 { - let scale = |v: u32| (u64::from(v) * u64::from(bitrate) / u64::from(config.rcParams.averageBitRate)) as u32; - config.rcParams.vbvBufferSize = scale(config.rcParams.vbvBufferSize); - config.rcParams.vbvInitialDelay = scale(config.rcParams.vbvInitialDelay); - } - config.rcParams.averageBitRate = bitrate; debug_assert_eq!( self.init.encodeConfig, std::ptr::from_mut::(&mut **config), "init.encodeConfig must point at our owned copy, not the caller's dead one" ); - let mut params = NV_ENC_RECONFIGURE_PARAMS { - version: NV_ENC_RECONFIGURE_PARAMS_VER, - reInitEncodeParams: self.init, - ..unsafe { std::mem::zeroed() } - }; - // Leave resetEncoder and forceIDR clear: retune in place, no keyframe. - params.set_resetEncoder(0); - params.set_forceIDR(0); - - unsafe { (self.encoder.api.reconfigure_encoder)(self.encoder.ptr, &mut params) }.result(&self.encoder) + let encoder = &self.encoder; + retune(&self.init, config, bitrate, |params| { + unsafe { (encoder.api.reconfigure_encoder)(encoder.ptr, params) }.result(encoder) + }) } /// Encode a frame. @@ -362,6 +349,60 @@ fn same_session(input: &Arc, output: &Arc, session: &Arc) -> bool { Arc::ptr_eq(input, session) && Arc::ptr_eq(output, session) } +/// Submit `config` retuned to `bitrate` and commit it only once `submit` +/// accepts, so a rejected change cannot skew the basis of the next one. +fn retune( + init: &NV_ENC_INITIALIZE_PARAMS, + config: &mut NV_ENC_CONFIG, + bitrate: u32, + submit: impl FnOnce(&mut NV_ENC_RECONFIGURE_PARAMS) -> Result<(), EncodeError>, +) -> Result<(), EncodeError> { + let invalid = |reason: &str| EncodeError::new(ErrorKind::InvalidParam, Some(reason.into())); + // A zero rate would also zero a proportional VBV, which no later rate could scale back up. + if bitrate == 0 { + return Err(invalid("bitrate must be nonzero")); + } + + let mut candidate = *config; + let rc = &mut candidate.rcParams; + // Keep a caller-sized VBV proportional to the rate, so a buffer sized to + // one frame at open stays one frame: left alone it would loosen the + // keyframe cap as the bitrate falls, right when the link can least afford it. + if rc.vbvBufferSize != 0 && rc.averageBitRate != 0 { + let basis = u64::from(rc.averageBitRate); + let scale = |v: u32| { + let scaled = u64::from(v) * u64::from(bitrate) / basis; + // Rounding a nonzero VBV to zero is the same dead end as a zero rate: + // the next retune would skip this branch and could never restore it. + if v != 0 && scaled == 0 { + return Err(invalid("scaled VBV must be nonzero")); + } + u32::try_from(scaled).map_err(|_| invalid("scaled VBV exceeds u32")) + }; + rc.vbvBufferSize = scale(rc.vbvBufferSize)?; + rc.vbvInitialDelay = scale(rc.vbvInitialDelay)?; + } + rc.averageBitRate = bitrate; + + // NVENC copies the config during the call, so pointing it at the local + // candidate is sound; `init` keeps pointing at the committed copy. + let mut params = NV_ENC_RECONFIGURE_PARAMS { + version: NV_ENC_RECONFIGURE_PARAMS_VER, + reInitEncodeParams: NV_ENC_INITIALIZE_PARAMS { + encodeConfig: &mut candidate, + ..*init + }, + ..unsafe { std::mem::zeroed() } + }; + // Leave resetEncoder and forceIDR clear: retune in place, no keyframe. + params.set_resetEncoder(0); + params.set_forceIDR(0); + + submit(&mut params)?; + *config = candidate; + Ok(()) +} + trait CompletionDriver { type Input; type Output; @@ -513,6 +554,73 @@ mod tests { assert_eq!(*events.lock().unwrap(), ["wait", "input", "output"]); } + fn rate_config(bitrate: u32, vbv: u32) -> NV_ENC_CONFIG { + let mut config = NV_ENC_CONFIG::default(); + config.rcParams.averageBitRate = bitrate; + config.rcParams.vbvBufferSize = vbv; + config.rcParams.vbvInitialDelay = vbv; + config + } + + /// The (average, VBV size, VBV delay) a reconfigure submitted. + fn submitted(params: &NV_ENC_RECONFIGURE_PARAMS) -> (u32, u32, u32) { + let rc = unsafe { &(*params.reInitEncodeParams.encodeConfig).rcParams }; + (rc.averageBitRate, rc.vbvBufferSize, rc.vbvInitialDelay) + } + + fn rates(config: &NV_ENC_CONFIG) -> (u32, u32, u32) { + let rc = &config.rcParams; + (rc.averageBitRate, rc.vbvBufferSize, rc.vbvInitialDelay) + } + + #[test] + fn rejected_rate_change_keeps_the_last_accepted_basis() { + let init = NV_ENC_INITIALIZE_PARAMS { + encodeWidth: 1280, + ..Default::default() + }; + let mut config = rate_config(1_000_000, 100_000); + + let error = retune(&init, &mut config, 500_000, |params| { + assert_eq!(submitted(params), (500_000, 50_000, 50_000)); + assert_eq!(params.reInitEncodeParams.encodeWidth, 1280); + Err(EncodeError::new(ErrorKind::UnsupportedParam, None)) + }) + .expect_err("the driver rejected the change"); + assert_eq!(error.kind(), ErrorKind::UnsupportedParam); + assert_eq!(rates(&config), (1_000_000, 100_000, 100_000)); + + // Scaled from the last accepted rate, not the rejected one. + retune(&init, &mut config, 2_000_000, |params| { + assert_eq!(submitted(params), (2_000_000, 200_000, 200_000)); + Ok(()) + }) + .unwrap(); + assert_eq!(rates(&config), (2_000_000, 200_000, 200_000)); + } + + #[test] + fn rate_that_zeroes_a_nonzero_vbv_is_refused_before_the_driver() { + let init = NV_ENC_INITIALIZE_PARAMS::default(); + let mut config = rate_config(1_000, 1); + let error = + retune(&init, &mut config, 1, |_| panic!("submitted a zero VBV")).expect_err("the scaled VBV is zero"); + assert_eq!(error.kind(), ErrorKind::InvalidParam); + assert_eq!(rates(&config), (1_000, 1, 1)); + } + + #[test] + fn invalid_rate_change_is_refused_before_the_driver() { + let init = NV_ENC_INITIALIZE_PARAMS::default(); + let mut config = rate_config(1, u32::MAX); + for bitrate in [0, 2] { + let error = retune(&init, &mut config, bitrate, |_| panic!("submitted an invalid rate")) + .expect_err("the rate is invalid"); + assert_eq!(error.kind(), ErrorKind::InvalidParam); + assert_eq!(rates(&config), (1, u32::MAX, u32::MAX)); + } + } + #[test] fn failed_wait_abandons_without_waiting_again() { let events = Events::default(); diff --git a/rs/moq-video/src/encode/backend/nvenc.rs b/rs/moq-video/src/encode/backend/nvenc.rs index 703779120c..60c916934a 100644 --- a/rs/moq-video/src/encode/backend/nvenc.rs +++ b/rs/moq-video/src/encode/backend/nvenc.rs @@ -481,6 +481,35 @@ mod tests { assert!(types.contains(&8), "forced IDR is missing inline PPS: {types:?}"); } + /// A refused retune leaves the session encoding at its last accepted rate, + /// and a later retune is accepted by the real driver. Same skip rule as the + /// H.264 test. + #[test] + fn nvenc_refused_retune_keeps_encoding() { + if !driver_available() { + return; + } + let mut config = crate::encode::Config::new(320, 240, crate::Rate::new(30, 1).unwrap()); + config.kind = crate::encode::Kind::Named(NAME.into()); + config.bitrate = Some(moq_net::bandwidth::Rate::from_bps(1_000_000)); + let Ok(mut encoder) = crate::encode::Encoder::new(&config) else { + return; + }; + + let frame = gray_rgba(320, 240); + assert!(!encoder.encode(&gray_frame(&frame, 0)).unwrap().is_empty()); + encoder + .set_bitrate(moq_net::bandwidth::Rate::ZERO) + .expect_err("a zero rate must be refused"); + assert_eq!(encoder.bitrate(), moq_net::bandwidth::Rate::from_bps(1_000_000)); + encoder + .set_bitrate(moq_net::bandwidth::Rate::from_bps(500_000)) + .unwrap(); + for i in 1..5 { + assert!(!encoder.encode(&gray_frame(&frame, i)).unwrap().is_empty()); + } + } + /// Real-hardware H.265 encode through NVENC. Same skip rule as the H.264 test. /// Asserts the HEVC GUID path emits Annex-B with a self-contained IRAP /// (VPS+SPS+PPS+IDR slice) and repeats them on a mid-stream forced keyframe, From 9cb5416ec35516d2bcac01f1bab5d25dfd058761 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:00:19 -0700 Subject: [PATCH 03/45] feat(moq-net): sans-IO session benchmark over a relay mesh (#4160) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 2 +- quest/m1/bench-ci.md | 1 - quest/m1/bench-relay.md | 8 +- quest/m1/bench-session.md | 31 -- quest/m1/cache-expiry-growth.md | 24 ++ quest/m1/perf/README.md | 3 + quest/m1/perf/announce-replay.md | 22 + quest/m1/perf/group-cost.md | 25 ++ quest/m1/perf/lite-route-rescan.md | 29 ++ rs/moq-net/Cargo.toml | 4 + rs/moq-net/benches/session.rs | 427 ++++++++++++++++++++ rs/moq-net/tests/announce_to_serve.rs | 2 +- rs/moq-net/tests/datagram.rs | 4 +- rs/moq-net/tests/finished_broadcast_mock.rs | 2 +- rs/moq-net/tests/goaway.rs | 4 +- rs/moq-net/tests/rejoin.rs | 2 +- rs/moq-net/tests/support/harness.rs | 4 +- rs/moq-net/tests/track_tail.rs | 2 +- 18 files changed, 547 insertions(+), 49 deletions(-) delete mode 100644 quest/m1/bench-session.md create mode 100644 quest/m1/cache-expiry-growth.md create mode 100644 quest/m1/perf/announce-replay.md create mode 100644 quest/m1/perf/group-cost.md create mode 100644 quest/m1/perf/lite-route-rescan.md create mode 100644 rs/moq-net/benches/session.rs diff --git a/quest/m1/README.md b/quest/m1/README.md index 5990696a70..38fe901419 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -81,7 +81,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Benchmark regressions in CI](/quest/m1/bench-ci.md) - PRs get a non-blocking comparison of the Criterion benches they affect, and a nightly trend on main alerts on regressions - [Benchmark comparisons](/quest/m1/performance-comparisons.md) - retained evidence, repeated paired runs, and uncertainty for performance claims - [#3126](/quest/m1/3126-moq-bench-every-readme-example-fails-to-parse-and.md) - moq-bench reports per-interval latency percentiles so the ramp leaves the steady state -- [Sans-IO session bench](/quest/m1/bench-session.md) - a moq-net bench drives publisher, relay, and subscribers over the in-memory transport, swept over publishers, subscribers, and frame size - [Relay session bench](/quest/m1/bench-relay.md) - the same scenario through moq-relay's own connection handling - [Bench coverage](/quest/m1/bench-coverage.md) - Criterion targets for moq-mux containers, the hang catalog, moq-auth verification, and moq-pattern matching - [Relay profiling](/quest/m1/performance-profiles.md) - reproducible CPU and allocation captures under the existing workloads @@ -90,6 +89,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Watch worker](/quest/m1/watch-worker.md) - watch playback runs in a worker onto an OffscreenCanvas, so main-thread jank never stalls video or audio - [Closure counters](/quest/m1/closure-counters.md) - a departed node's return never regresses the closure counters a consumer already saw - [RTMP interleaving](/quest/m1/rtmp-interleaving.md) - isolate partial messages before optimizing assembly copies +- [Cache expiry growth](/quest/m1/cache-expiry-growth.md) - with the default pool, relay memory plateaus at the expiry window on moq-transport as on moq-lite - [Relay memory](/quest/m1/relay-memory.md) - remeasure what an announcement costs after prefix routes - [PoP skipping](/quest/m1/pop-skipping/README.md) - short cold paths for unpopular broadcasts without losing warm backhaul dedup - [Route cost in the JS origin](/quest/m1/route-cost.md) - the browser origin ranks routes by cost and hops like Rust instead of newest-first diff --git a/quest/m1/bench-ci.md b/quest/m1/bench-ci.md index 3514730d8a..b317185d53 100644 --- a/quest/m1/bench-ci.md +++ b/quest/m1/bench-ci.md @@ -50,5 +50,4 @@ a GitHub App: ## Related - [Benchmark comparisons](/quest/m1/performance-comparisons.md) - extends the same `bench/run.sh` with repeated paired rounds -- [Sans-IO session bench](/quest/m1/bench-session.md) - a low-noise end-to-end bench this job picks up - [Bench coverage](/quest/m1/bench-coverage.md) - more targets for this job to track diff --git a/quest/m1/bench-relay.md b/quest/m1/bench-relay.md index 596cddb10e..1cd12ddfe9 100644 --- a/quest/m1/bench-relay.md +++ b/quest/m1/bench-relay.md @@ -16,9 +16,5 @@ transport session without widening the public API. If the seam costs more than the bench is worth, say so and stop. Reuse the scenario, the publisher and subscriber sweeps, and the delivery -accounting from the `moq-net` bench so the two results line up, and the -difference is the relay layer. - -## Required - -- [Sans-IO session bench](/quest/m1/bench-session.md) - supplies the scenario and harness this bench reuses +accounting from `rs/moq-net/benches/session.rs` so the two results line up, and +the difference is the relay layer. diff --git a/quest/m1/bench-session.md b/quest/m1/bench-session.md deleted file mode 100644 index 82aa479d22..0000000000 --- a/quest/m1/bench-session.md +++ /dev/null @@ -1,31 +0,0 @@ -# [M] Sans-IO session benchmark in moq-net - -## Goal - -A Criterion bench in `moq-net` measures a full publishers to relay to -subscribers path with no sockets: M publishing clients, relay sessions that -forward through an origin the way `moq-relay` does, and N subscribing -clients, all over the in-memory transport. It sweeps publishers, subscribers, -and frame size independently, over lite and IETF, so a per-publisher, -per-subscriber, or per-frame cost shows as a slope and CI can compare it with -little noise. - -## Plan - -`rs/moq-net/tests/support/{mock,harness}.rs` already pairs sessions over an -in-memory WebTransport mock and runs the full handshake. Reuse it from the -bench, as `moq-uring`'s benches reuse their test support, rather than -exporting a mock from the crate. - -- Keep setup (handshakes, announce, the subscribe round trip) outside the - timed region. Time delivery of groups until every subscriber has received - every frame, and count delivered bytes so a skipped delivery can't look like - a speedup. -- Also time subscriber join, since a relay pays that per viewer. -- Report throughput in bytes and frames. -- Pick sweep points that finish quickly enough to run on every `moq-net` PR. - -## Related - -- [Relay session bench](/quest/m1/bench-relay.md) - the same shape through the real `moq-relay` handler -- [Benchmark regressions in CI](/quest/m1/bench-ci.md) - tracks this bench on PRs and nightly diff --git a/quest/m1/cache-expiry-growth.md b/quest/m1/cache-expiry-growth.md new file mode 100644 index 0000000000..17cac468ed --- /dev/null +++ b/quest/m1/cache-expiry-growth.md @@ -0,0 +1,24 @@ +# [S] Cached groups expire on the IETF path + +## Goal + +With the default cache pool, a relay's memory plateaus once groups start +reaching the expiry window, on moq-transport as on moq-lite, or the cause of +continued growth is found and fixed. + +## Plan + +In `session_delivery_broadcasts` with 4096 announced broadcasts and the +default unbounded pool (30 s expiry), IETF RSS reached 1.5 GB at 10 s, 3.5 GB at +30 s, and 4.9 GB at 50 s, still climbing past the expiry window. Lite +reached about 0.36 GB by 50 s. With a 16 MiB bounded pool both stay flat, so +the retained bytes are cached groups, not a leak elsewhere. + +Reproduce with a focused test first. Unexpired groups at higher throughput, +expiry not running on a path IETF uses, or groups held outside the pool's +accounting would each explain it. Fix what is actually wrong. Consider +whether an unbounded default is the right default for an origin at all. + +## Related + +- [Relay memory](/quest/m1/relay-memory.md) - what an announcement costs in memory diff --git a/quest/m1/perf/README.md b/quest/m1/perf/README.md index b1a0a9cf4f..c855d6344d 100644 --- a/quest/m1/perf/README.md +++ b/quest/m1/perf/README.md @@ -43,6 +43,9 @@ row per io_uring worker. - [Open contract](/quest/m1/perf/uring-open-contract.md) - plan concurrent WebTransport opening and cancellation +- [Lite route rescan](/quest/m1/perf/lite-route-rescan.md) - a moq-lite session's per-group cost stops growing with the routes its peer announced +- [Announce replay](/quest/m1/perf/announce-replay.md) - the initial announce set replays in linear time, so joins don't slow with the route count +- [Group cost](/quest/m1/perf/group-cost.md) - count and cut the allocations and time spent relaying one small group to one viewer - [One enter per turn](/quest/m1/perf/uring-one-enter.md) - a parking turn pays one io_uring_enter, submits flush deferred completions, and SQEs per enter is a counter - [Run to quiescence](/quest/m1/perf/uring-quiescence.md) - a received packet's reply is staged in the same turn, under a pass budget that keeps the fairness rule - [Lock wait](/quest/m1/perf/lock-wait.md) - each worker reports time blocked on cross-worker locks, deciding whether the shared model needs work diff --git a/quest/m1/perf/announce-replay.md b/quest/m1/perf/announce-replay.md new file mode 100644 index 0000000000..db7f81b036 --- /dev/null +++ b/quest/m1/perf/announce-replay.md @@ -0,0 +1,22 @@ +# [S] Linear announce replay on subscribe + +## Goal + +A session that subscribes to announcements receives the initial set in time +linear in the number of announced broadcasts, so a viewer joining a relay +with thousands of routes connects as fast per route as one joining a handful. + +## Plan + +The moq-lite publisher's initial replay (`AnnounceRun::init` in +`rs/moq-net/src/lite/publisher.rs`) de-duplicates by scanning the pending list +for every route it drains: `initial.retain` on Lite05+, `init.contains` on +the Lite01/02 init. That is quadratic in the replay size. + +Measured with `session_join_broadcasts` (2026-09, one relay): lite join takes +199 µs with 1 announced broadcast, 517 µs with 64, and 8.9 ms with 1024, +about 8.7 µs per route at the top. IETF is 7.2 ms at 1024 with no quadratic +scan; its profile is SipHash hashing and `Path` comparison, so check whether +a faster hasher for path-keyed maps pays off on both. + +Keep the replay's order and its last-update-wins semantics. diff --git a/quest/m1/perf/group-cost.md b/quest/m1/perf/group-cost.md new file mode 100644 index 0000000000..b558d76b62 --- /dev/null +++ b/quest/m1/perf/group-cost.md @@ -0,0 +1,25 @@ +# [M] Measure and cut the fixed cost of relaying a group + +## Goal + +The fixed cost of relaying one small group to one viewer drops, measured as +allocations and time per viewer-group in the session benchmark, with no +change to what is delivered. + +## Plan + +`session_delivery_viewers` (2026-09) spends about 32 µs per viewer per +4-frame, 64-byte group over the in-memory transport, through a publisher +session, a relay origin, and a viewer session. No single function dominates. +The profile spreads it over `kio` waiter registration and parking, +`TrackState::evict_expired_scan` (4-5%), `Waiter`'s lazily allocated shared +waker (`Once::call`, about 3%, so waiters are created per poll), and +malloc/free (10-12%). + +Count allocations per viewer-group first (a counting allocator in the bench, +as `moq-json`'s allocation bench does), then remove the largest sources. A +measured no-win abandons the quest, per this line's rules. + +## Related + +- [Owned decoding copies](/quest/m1/perf/coding-decode.md) - decode-side copies are part of the same per-group cost diff --git a/quest/m1/perf/lite-route-rescan.md b/quest/m1/perf/lite-route-rescan.md new file mode 100644 index 0000000000..6591609912 --- /dev/null +++ b/quest/m1/perf/lite-route-rescan.md @@ -0,0 +1,29 @@ +# [S] Lite subscriber polls only routes with a request + +## Goal + +A moq-lite session's per-group cost stops growing with the number of +broadcasts its peer has announced: delivery over a session holding thousands +of announced routes costs what it costs with a handful, as it already does +over moq-transport. + +## Plan + +`Announced::poll_serve` (`rs/moq-net/src/lite/subscriber.rs`) polls every +attached route's `Dynamic::poll_requested_broadcast` on every driver wake, so +each incoming group pays for every route, and each pending poll registers the +driver's waiter on every idle route's list. The IETF subscriber runs one task +per route and only wakes the one that got a request. + +Measured with `cargo bench -p moq-net --bench session` (2026-09, one relay, +16 viewers each watching one broadcast): + +- `session_delivery_broadcasts`: lite 445 µs at 16 announced, 15.2 ms at + 4096; IETF flat around 458 µs. 57% of lite CPU is `WaiterList::register` + under `poll_requested_broadcast`. +- `session_delivery_scale` at 256 publishers x 256 viewers: lite 40 ms, IETF + 16.5 ms, since every viewer session holds all 256 routes. + +Wake only routes with a pending request. A task per route like IETF's or a +ready set both fit; pick by what keeps the driver's fairness rules. Land +with the before/after of those two groups. diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index 1dade572a1..288ac36bb2 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -84,3 +84,7 @@ harness = false [[bench]] name = "stats" harness = false + +[[bench]] +name = "session" +harness = false diff --git a/rs/moq-net/benches/session.rs b/rs/moq-net/benches/session.rs new file mode 100644 index 0000000000..42e65e2fd2 --- /dev/null +++ b/rs/moq-net/benches/session.rs @@ -0,0 +1,427 @@ +//! End-to-end session benchmarks with no sockets: publishing clients, a mesh of +//! relays that forward through their origins the way `moq-relay` does, and +//! viewing clients, all connected over the in-memory mock transport. +//! +//! `session_delivery_*` times one round: every watched broadcast writes a group +//! and every viewer reads one group per broadcast it watches. Each group sweeps +//! one or two axes of a [`Shape`] (relays, publisher sessions, broadcasts per +//! publisher, viewers, broadcasts per viewer, frame size) with the rest held +//! fixed, so a cost that grows with a table instead of the touched path shows +//! as a slope. Unwatched broadcasts stay announced and silent: their cost is the +//! route table they occupy, not a publisher writing into its own cache. +//! +//! `session_join_*` times a new viewer connecting, resolving a broadcast, and +//! receiving its latest group, swept over what the relays already announce. +//! +//! Everything runs on one current-thread runtime, so the only work measured is +//! the protocol and model code, never scheduling across threads. +//! +//! Run with `cargo bench -p moq-net --bench session`. + +#[path = "../tests/support/mod.rs"] +mod support; + +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use moq_net::{Hop, Timestamp, Version, broadcast, cache, origin, track}; +use support::harness::{MockConnectOptions, MockPair, connect_mock}; + +/// Newest lite draft (`moq-lite-07-wip`, opt-in) and newest IETF draft. +const VERSIONS: [&str; 2] = ["moq-lite-07-wip", "moq-transport-22"]; + +/// Frames per group, so per-frame and per-group costs both appear. +const FRAMES: usize = 4; + +const TRACK: &str = "video"; + +/// Each endpoint gets its own bounded pool, as a relay configures one per +/// process: without a byte target, every hop keeps each group for the whole +/// expiry window and memory grows with the run length instead of the shape. +const RELAY_CACHE: u64 = 16 * 1024 * 1024; +const CLIENT_CACHE: u64 = 1024 * 1024; + +/// One topology: publishers and viewers spread round-robin over a full mesh of +/// relays. +#[derive(Clone, Copy)] +struct Shape { + /// Relays, each peered with every other. + relays: usize, + /// Publisher sessions. + publishers: usize, + /// Broadcasts each publisher session announces. + broadcasts: usize, + /// Viewer sessions. + viewers: usize, + /// Broadcasts each viewer subscribes to. + watch: usize, + /// Payload bytes per frame. + frame: usize, +} + +impl Shape { + /// A 16-publisher, 16-viewer room on one relay, each viewer watching one + /// broadcast with small frames, so sweeps measure per-message costs. + const BASE: Self = Self { + relays: 1, + publishers: 16, + broadcasts: 1, + viewers: 16, + watch: 1, + frame: 64, + }; + + fn total(&self) -> usize { + self.publishers * self.broadcasts + } + + /// Payload bytes every viewer reads in one round. + fn expected(&self) -> usize { + self.viewers * self.watch * FRAMES * self.frame + } + + fn id(&self, version: &str) -> String { + format!( + "{version}/relays={}/publishers={}/broadcasts={}/viewers={}/watch={}/frame={}", + self.relays, self.publishers, self.broadcasts, self.viewers, self.watch, self.frame + ) + } +} + +fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .unwrap() +} + +fn write_group(track: &track::Producer, payload: &Bytes) { + let mut group = track.append_group().unwrap(); + for _ in 0..FRAMES { + group.write_frame(Timestamp::ZERO, payload.clone()).unwrap(); + } + group.finish().unwrap(); +} + +/// Read one whole group, returning its payload bytes so skipped work can't look +/// like a speedup. +async fn read_group(subscriber: &mut track::Subscriber) -> usize { + let mut group = subscriber.recv_group().await.unwrap().expect("track ended"); + let mut bytes = 0; + while let Some(frame) = group.read_frame().await.unwrap() { + bytes += frame.payload.len(); + } + bytes +} + +fn path(broadcast: usize) -> String { + format!("room/{broadcast}") +} + +/// Relays and publishers, before any viewer joins. Holds every handle that +/// keeps a session or broadcast alive. +struct Cluster { + version: Version, + relays: Vec, + /// One track per broadcast, indexed like [`path`]. + tracks: Vec, + _broadcasts: Vec, + _origins: Vec, + _pairs: Vec, + next_hop: u64, +} + +impl Cluster { + async fn new(version: &str, shape: Shape) -> Self { + let mut this = Self { + version: version.parse().unwrap(), + relays: Vec::new(), + tracks: Vec::new(), + _broadcasts: Vec::new(), + _origins: Vec::new(), + _pairs: Vec::new(), + next_hop: 0, + }; + + for _ in 0..shape.relays { + let relay = this.origin(RELAY_CACHE); + this.relays.push(relay); + } + + // Peer every pair the way a cluster dial does: each side publishes its + // whole origin, hidden broadcasts included, and subscribes into it. + for server in 0..shape.relays { + for client in 0..server { + let client = this.relays[client].clone().peer(); + let server = this.relays[server].clone().peer(); + let mut options = MockConnectOptions::new(this.version); + options.client_publish = Some(client.consume().with_hidden(true)); + options.client_subscribe = Some(client); + options.server_publish = Some(server.consume().with_hidden(true)); + options.server_subscribe = Some(server); + this._pairs.push(connect_mock(options).await); + } + } + + for publisher in 0..shape.publishers { + let origin = this.origin(CLIENT_CACHE); + for index in 0..shape.broadcasts { + let broadcast = origin + .publish(path(publisher * shape.broadcasts + index), Default::default()) + .unwrap(); + this.tracks.push(broadcast.create_track(TRACK, None).unwrap()); + this._broadcasts.push(broadcast); + } + + let mut options = MockConnectOptions::new(this.version); + options.client_publish = Some(origin.consume()); + options.server_subscribe = Some(this.relays[publisher % shape.relays].clone()); + this._pairs.push(connect_mock(options).await); + this._origins.push(origin); + } + + this + } + + fn origin(&mut self, capacity: u64) -> origin::Producer { + self.next_hop += 1; + let mut config = origin::Config::new(Hop::new(self.next_hop).unwrap()); + config.pool = cache::Pool::new(cache::Config::default().with_capacity(capacity)); + let (producer, driver) = origin::Producer::new(config); + tokio::spawn(support::harness::run(driver)); + producer + } + + /// Connect a viewer to `relay` and subscribe it to each of `broadcasts`. + async fn join(&mut self, relay: usize, broadcasts: impl Iterator) -> Viewer { + let origin = self.origin(CLIENT_CACHE); + let mut options = MockConnectOptions::new(self.version); + options.server_publish = Some(self.relays[relay].consume()); + options.client_subscribe = Some(origin.clone()); + let pair = connect_mock(options).await; + + let mut subscribers = Vec::new(); + for broadcast in broadcasts { + let broadcast = origin.consume().routed_broadcast(path(broadcast)).await.unwrap(); + subscribers.push(broadcast.track(TRACK).unwrap().subscribe(None).await.unwrap()); + } + + Viewer { + subscribers, + _pair: pair, + _origin: origin, + } + } +} + +struct Viewer { + subscribers: Vec, + _pair: MockPair, + _origin: origin::Producer, +} + +/// A cluster with its viewers attached. +struct Room { + cluster: Cluster, + viewers: Vec, + /// Broadcasts at least one viewer watches; only these write each round. + watched: Vec, + payload: Bytes, + expected: usize, +} + +impl Room { + async fn new(version: &str, shape: Shape) -> Self { + assert!( + shape.watch <= shape.total(), + "a viewer can't watch more broadcasts than exist" + ); + let mut cluster = Cluster::new(version, shape).await; + + // Viewer v watches a contiguous window starting at v * watch, so viewers + // spread over the broadcasts before any doubles up. + let mut viewers = Vec::new(); + let mut watched = vec![false; shape.total()]; + for viewer in 0..shape.viewers { + let broadcasts: Vec<_> = (0..shape.watch) + .map(|k| (viewer * shape.watch + k) % shape.total()) + .collect(); + for &broadcast in &broadcasts { + watched[broadcast] = true; + } + // Offset by one so a viewer lands on a different relay than the + // publisher it watches whenever there is more than one. + let relay = (viewer + 1) % shape.relays; + viewers.push(cluster.join(relay, broadcasts.into_iter()).await); + } + + let mut room = Self { + cluster, + viewers, + watched: watched + .iter() + .enumerate() + .filter(|(_, w)| **w) + .map(|(i, _)| i) + .collect(), + payload: Bytes::from(vec![0; shape.frame]), + expected: shape.expected(), + }; + // Warm every path so the timed rounds skip first-group setup. + room.round().await; + room + } + + async fn round(&mut self) { + for &broadcast in &self.watched { + write_group(&self.cluster.tracks[broadcast], &self.payload); + } + let mut bytes = 0; + for viewer in &mut self.viewers { + for subscriber in &mut viewer.subscribers { + bytes += read_group(subscriber).await; + } + } + assert_eq!(bytes, self.expected); + } + + async fn measure(&mut self, iters: u64) -> Duration { + let start = Instant::now(); + for _ in 0..iters { + self.round().await; + } + start.elapsed() + } +} + +fn delivery(c: &mut Criterion, name: &str, shapes: impl IntoIterator) { + let rt = runtime(); + let shapes: Vec<_> = shapes.into_iter().collect(); + let mut group = c.benchmark_group(format!("session_delivery_{name}")); + for version in VERSIONS { + for shape in &shapes { + // Built on first call: Criterion only calls a routine its filter + // selects, and calls it again for every sample. + let mut room = None; + group.throughput(Throughput::Bytes(shape.expected() as u64)); + group.bench_function(BenchmarkId::from_parameter(shape.id(version)), |b| { + let room = room.get_or_insert_with(|| rt.block_on(Room::new(version, *shape))); + b.iter_custom(|iters| rt.block_on(room.measure(iters))) + }); + } + } + group.finish(); +} + +fn join(c: &mut Criterion, name: &str, shapes: impl IntoIterator) { + let rt = runtime(); + let shapes: Vec<_> = shapes.into_iter().collect(); + let mut group = c.benchmark_group(format!("session_join_{name}")); + for version in VERSIONS { + for shape in &shapes { + let mut cluster = None; + group.bench_function(BenchmarkId::from_parameter(shape.id(version)), |b| { + let cluster = cluster.get_or_insert_with(|| { + rt.block_on(async { + let cluster = Cluster::new(version, *shape).await; + let payload = Bytes::from(vec![0; shape.frame]); + for track in &cluster.tracks { + write_group(track, &payload); + } + cluster + }) + }); + // Broadcasts published to relay 0, joined from the last relay: every + // sample is local on one relay and crosses one peer hop on a mesh. + let hosted: Vec<_> = (0..shape.total()) + .filter(|broadcast| (broadcast / shape.broadcasts) % shape.relays == 0) + .collect(); + let relay = shape.relays - 1; + b.iter_custom(|iters| { + rt.block_on(async { + let mut elapsed = Duration::ZERO; + for iter in 0..iters as usize { + let broadcast = hosted[iter % hosted.len()]; + let start = Instant::now(); + let mut viewer = cluster.join(relay, std::iter::once(broadcast)).await; + let bytes = read_group(&mut viewer.subscribers[0]).await; + elapsed += start.elapsed(); + assert_eq!(bytes, FRAMES * shape.frame); + // Teardown is a leaving viewer's cost, not a joining one's. + drop(viewer); + tokio::task::yield_now().await; + } + elapsed + }) + }) + }); + } + } + group.finish(); +} + +fn session(c: &mut Criterion) { + let base = Shape::BASE; + + delivery( + c, + "publishers", + [1, 16, 256].map(|publishers| Shape { publishers, ..base }), + ); + delivery(c, "viewers", [1, 16, 256].map(|viewers| Shape { viewers, ..base })); + delivery( + c, + "scale", + [16, 64, 256].map(|n| Shape { + publishers: n, + viewers: n, + ..base + }), + ); + // One publisher session announcing many broadcasts, most of them unwatched. + delivery( + c, + "broadcasts", + [16, 256, 4096].map(|broadcasts| Shape { + publishers: 1, + broadcasts, + ..base + }), + ); + // Each viewer watches many broadcasts over one session, like a conference. + delivery( + c, + "watch", + [1, 16, 256].map(|watch| Shape { + publishers: 1, + broadcasts: 256, + watch, + ..base + }), + ); + delivery(c, "frame", [64, 1024, 16 * 1024].map(|frame| Shape { frame, ..base })); + delivery( + c, + "relays", + [1, 2, 4, 8].map(|relays| Shape { + relays, + viewers: 64, + ..base + }), + ); + + join( + c, + "broadcasts", + [1, 64, 1024].map(|broadcasts| Shape { + publishers: 1, + broadcasts, + ..base + }), + ); + join(c, "relays", [1, 2, 8].map(|relays| Shape { relays, ..base })); +} + +criterion_group!(benches, session); +criterion_main!(benches); diff --git a/rs/moq-net/tests/announce_to_serve.rs b/rs/moq-net/tests/announce_to_serve.rs index 6eb4b57eb6..8fd27f86d8 100644 --- a/rs/moq-net/tests/announce_to_serve.rs +++ b/rs/moq-net/tests/announce_to_serve.rs @@ -119,7 +119,7 @@ async fn lifecycle(observer: Observer) -> Vec { Observer::Remote(version) => { let subscriber = produce_origin(2); let mut options = MockConnectOptions::new(version.parse::().unwrap()); - options.server_publish = Some(publisher.clone()); + options.server_publish = Some(publisher.consume()); options.client_subscribe = Some(subscriber.clone()); let pair = connect_mock(options).await; (subscriber.consume(), Some(pair)) diff --git a/rs/moq-net/tests/datagram.rs b/rs/moq-net/tests/datagram.rs index b16264be9b..4cc78b1297 100644 --- a/rs/moq-net/tests/datagram.rs +++ b/rs/moq-net/tests/datagram.rs @@ -43,7 +43,7 @@ async fn connect_datagram_track() -> Fixture { broadcast.announce(Default::default()).unwrap(); let mut options = MockConnectOptions::new("moq-lite-05".parse::().unwrap()); - options.server_publish = Some(publisher); + options.server_publish = Some(publisher.consume()); options.client_subscribe = Some(consumer_origin.clone()); let pair = connect_mock(options).await; @@ -122,7 +122,7 @@ async fn ietf_does_not_deliver_datagrams() { broadcast.announce(Default::default()).unwrap(); let mut options = MockConnectOptions::new("moq-transport-19".parse::().unwrap()); - options.server_publish = Some(publisher); + options.server_publish = Some(publisher.consume()); options.client_subscribe = Some(consumer_origin.clone()); let _pair = connect_mock(options).await; diff --git a/rs/moq-net/tests/finished_broadcast_mock.rs b/rs/moq-net/tests/finished_broadcast_mock.rs index 30fcefe404..4c987f9317 100644 --- a/rs/moq-net/tests/finished_broadcast_mock.rs +++ b/rs/moq-net/tests/finished_broadcast_mock.rs @@ -38,7 +38,7 @@ async fn round(finish_broadcast: bool) -> (Vec>, Option) let subscriber = produce_origin(2); let mut options = MockConnectOptions::new("moq-lite-05".parse::().unwrap()); - options.server_publish = Some(publisher.clone()); + options.server_publish = Some(publisher.consume()); options.client_subscribe = Some(subscriber.clone()); let pair: MockPair = connect_mock(options).await; diff --git a/rs/moq-net/tests/goaway.rs b/rs/moq-net/tests/goaway.rs index c85713fa82..bc407c2a49 100644 --- a/rs/moq-net/tests/goaway.rs +++ b/rs/moq-net/tests/goaway.rs @@ -336,7 +336,7 @@ async fn goaway_gates_new_subscribes_moq_lite_04() { let sub_origin = produce_origin(Hop::random()); let mut opts = MockConnectOptions::new(version); - opts.server_publish = Some(pub_origin.clone()); + opts.server_publish = Some(pub_origin.consume()); opts.client_subscribe = Some(sub_origin.clone()); let MockPair { client, server, .. } = connect_mock(opts).await; @@ -423,7 +423,7 @@ async fn goaway_drains_routes(version: Version) { let sub_origin = produce_origin(Hop::random()); let mut opts = MockConnectOptions::new(version); - opts.server_publish = Some(pub_origin.clone()); + opts.server_publish = Some(pub_origin.consume()); opts.client_subscribe = Some(sub_origin.clone()); let MockPair { client, server, .. } = connect_mock(opts).await; diff --git a/rs/moq-net/tests/rejoin.rs b/rs/moq-net/tests/rejoin.rs index 5ba6a80b3c..ecadbbfb18 100644 --- a/rs/moq-net/tests/rejoin.rs +++ b/rs/moq-net/tests/rejoin.rs @@ -42,7 +42,7 @@ async fn rejoin_recovers_the_group_reset_on_leave() { broadcast.announce(Default::default()).unwrap(); let mut options = MockConnectOptions::new(version.parse::().unwrap()); - options.server_publish = Some(publisher); + options.server_publish = Some(publisher.consume()); options.client_subscribe = Some(relay.clone()); let _pair = connect_mock(options).await; diff --git a/rs/moq-net/tests/support/harness.rs b/rs/moq-net/tests/support/harness.rs index 701d83fb32..4cd2cf254f 100644 --- a/rs/moq-net/tests/support/harness.rs +++ b/rs/moq-net/tests/support/harness.rs @@ -22,11 +22,11 @@ pub struct MockConnectOptions { /// The MoQ version to negotiate (determines the ALPN protocol string). pub version: Version, /// Origin whose broadcasts the client publishes to the server. - pub client_publish: Option, + pub client_publish: Option, /// Origin the client inserts remote broadcasts into. pub client_subscribe: Option, /// Origin whose broadcasts the server publishes to the client. - pub server_publish: Option, + pub server_publish: Option, /// Origin the server inserts remote broadcasts into. pub server_subscribe: Option, } diff --git a/rs/moq-net/tests/track_tail.rs b/rs/moq-net/tests/track_tail.rs index 28ef3d957f..f9dd8ca201 100644 --- a/rs/moq-net/tests/track_tail.rs +++ b/rs/moq-net/tests/track_tail.rs @@ -66,7 +66,7 @@ async fn round(version: &str, late: Late) -> Outcome { let subscriber = produce_origin(2); let mut options = MockConnectOptions::new(version.parse::().unwrap()); - options.server_publish = Some(publisher.clone()); + options.server_publish = Some(publisher.consume()); options.client_subscribe = Some(subscriber.clone()); let pair = connect_mock(options).await; From a05a2d5a5837e5f3463e34f1c089c386f6456f46 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:03:28 -0700 Subject: [PATCH 04/45] fix(uring): bound io_uring tests by the shared memlock budget (#4167) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- .config/nextest.toml | 12 ++++++++++ doc/bin/relay/config.md | 5 +++- rs/moq-uring/src/error.rs | 44 ++++++++++++++++++++++++++++++++++ rs/moq-uring/src/udp.rs | 3 ++- rs/moq-uring/src/worker.rs | 2 +- rs/moq-uring/tests/identity.rs | 7 +++++- rs/moq-uring/tests/teardown.rs | 6 ++++- rs/moq-uring/tests/workers.rs | 9 +++++++ 8 files changed, 83 insertions(+), 5 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 65691871bd..1d52e0c2ef 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -24,6 +24,18 @@ retries = 0 # workload once, and the large origin fan-outs take ~20s each for no signal. default-filter = "not kind(bench)" +# Every io_uring ring is charged to the user's `RLIMIT_MEMLOCK` (8 MiB by +# default), a budget shared with every other process that user runs, and +# desktop apps built on libuv already spend most of it. A worker's ring is +# ~84 KiB, so a parallel run of tests holding a few workers each exhausts it +# and fails with `ENOMEM`. Four at a time stays near 1 MiB. +[[profile.default.overrides]] +filter = "package(moq-uring) | binary_id(moq-relay::runtime_uring) | (binary_id(moq-relay::embed) & test(/uring/))" +test-group = "io-uring" + +[test-groups] +io-uring = { max-threads = 4 } + # CI has noisier neighbours and cold caches, so give a test longer before # calling it wedged. [profile.ci] diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 6f8677ac36..f72a4fb646 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -90,7 +90,10 @@ and refuses to start anywhere it cannot deliver. `[quic]` applies either way, except that `mtu_discovery` (its datagram path sends a fixed payload) and the three flow-control windows (these workers run fixed ones) are refused under `io_uring` rather than quietly ignored. Each worker reports its own counters at -[`/metrics`](/bin/relay/http#get-metrics). +[`/metrics`](/bin/relay/http#get-metrics). The kernel charges each worker's +ring (~100 KiB, plus a page per socket) to `RLIMIT_MEMLOCK`, a budget shared by +every io\_uring the user runs; raise it (`LimitMEMLOCK=` under systemd) if +workers fail to start with a message naming that limit. ## \[web] diff --git a/rs/moq-uring/src/error.rs b/rs/moq-uring/src/error.rs index d2791cc1fb..b4a43aa663 100644 --- a/rs/moq-uring/src/error.rs +++ b/rs/moq-uring/src/error.rs @@ -12,3 +12,47 @@ pub enum Error { #[error("io error: {0}")] Io(#[from] std::io::Error), } + +impl Error { + /// A failed ring setup or registration, naming the locked-memory limit when + /// that is what ran out. + /// + /// The kernel charges every ring and provided-buffer ring to the user's + /// `RLIMIT_MEMLOCK`, shared with every other io_uring that user runs, so a + /// bare `ENOMEM` points at the wrong culprit. + pub(crate) fn ring(err: std::io::Error) -> Self { + if err.raw_os_error() != Some(libc::ENOMEM) { + return Self::Io(err); + } + let mut limit = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + // SAFETY: valid out-pointer. + let limit = match unsafe { libc::getrlimit(libc::RLIMIT_MEMLOCK, &mut limit) } { + 0 => format!("{} KiB", limit.rlim_cur / 1024), + _ => "unknown".into(), + }; + Self::Io(std::io::Error::new( + err.kind(), + format!( + "{err}: io_uring memory counts against RLIMIT_MEMLOCK ({limit}), shared by every process of this user; \ + raise it with `ulimit -l` or systemd's `LimitMEMLOCK=`" + ), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_ring_enomem_names_the_memlock_limit() { + let err = Error::ring(std::io::Error::from_raw_os_error(libc::ENOMEM)); + assert!(err.to_string().contains("RLIMIT_MEMLOCK"), "{err}"); + + let err = Error::ring(std::io::Error::from_raw_os_error(libc::EBADF)); + assert!(!err.to_string().contains("RLIMIT_MEMLOCK"), "{err}"); + } +} diff --git a/rs/moq-uring/src/udp.rs b/rs/moq-uring/src/udp.rs index 9ca5de176a..41d5b4ce94 100644 --- a/rs/moq-uring/src/udp.rs +++ b/rs/moq-uring/src/udp.rs @@ -576,7 +576,8 @@ impl Socket { unsafe { io_ring .submitter() - .register_buf_ring_with_flags(ring.ptr.as_ptr() as u64, rx_cap, bgid, 0)?; + .register_buf_ring_with_flags(ring.ptr.as_ptr() as u64, rx_cap, bgid, 0) + .map_err(Error::ring)?; } } for (bid, buf) in bufs.iter_mut().enumerate() { diff --git a/rs/moq-uring/src/worker.rs b/rs/moq-uring/src/worker.rs index 3a85fb00ec..45142d2823 100644 --- a/rs/moq-uring/src/worker.rs +++ b/rs/moq-uring/src/worker.rs @@ -118,7 +118,7 @@ impl Worker { kernel_release() )) } - _ => Error::Io(err), + _ => Error::ring(err), })?; // One feature bit gates the whole floor: MIN_TIMEOUT landed in 6.12 diff --git a/rs/moq-uring/tests/identity.rs b/rs/moq-uring/tests/identity.rs index 7df4aedb87..76662b0396 100644 --- a/rs/moq-uring/tests/identity.rs +++ b/rs/moq-uring/tests/identity.rs @@ -115,10 +115,14 @@ fn an_endpoint_runs_on_the_worker_that_adopted_its_socket() { // A client on its own thread and worker, dialing the endpoint. Its // Initial reaches the socket immediately; only the owner can read it. // The client is driven until the server closes on it: its side of the - // handshake completes before the server's, so it cannot stop earlier. + // handshake completes before the server's, so it cannot stop earlier. It + // reports in before dialing, so a setup failure fails here instead of as + // an accept that never arrives. + let (ready, started) = std::sync::mpsc::channel(); let client = std::thread::spawn(move || { let mut worker = Worker::new(Config::default()).expect("client worker"); let sock = socket(&worker.handle()); + ready.send(()).expect("test alive"); worker .block_on(async move { let mut conn = quic::client::connect(sock, &dial_config(addr)).await.expect("dial"); @@ -126,6 +130,7 @@ fn an_endpoint_runs_on_the_worker_that_adopted_its_socket() { }) .expect("client loop") }); + started.recv().expect("the client thread failed to start"); // Driving the bystander polls the accept from its loop, but the demux // that would feed it is a task on the owner, which is not running. diff --git a/rs/moq-uring/tests/teardown.rs b/rs/moq-uring/tests/teardown.rs index b0bde779f5..c04d58ecb8 100644 --- a/rs/moq-uring/tests/teardown.rs +++ b/rs/moq-uring/tests/teardown.rs @@ -103,7 +103,9 @@ fn a_published_close_has_already_left_the_client() { let server_addr = server_sock.local_addr().expect("server addr"); // The server outlives the client's teardown, so its verdict is only about - // what the client managed to send. + // what the client managed to send. It reports in once serving, so a setup + // failure fails here instead of as a dial that idles out. + let (ready, started) = std::sync::mpsc::channel(); let server = std::thread::spawn(move || -> quic::Error { let mut worker = Worker::new(Config::default()).expect("server worker"); let handle = worker.handle(); @@ -113,6 +115,7 @@ fn a_published_close_has_already_left_the_client() { quic::endpoint::Config::default().with_server(server_config(&certs)), ) .expect("endpoint"); + ready.send(()).expect("test alive"); worker .block_on(async move { let mut conn = endpoint.accept().await.expect("accept"); @@ -120,6 +123,7 @@ fn a_published_close_has_already_left_the_client() { }) .expect("server worker") }); + started.recv().expect("the server thread failed to start"); let handle = client_worker.handle(); let sock = handle diff --git a/rs/moq-uring/tests/workers.rs b/rs/moq-uring/tests/workers.rs index 7cd4ae95ce..f3329d9dd2 100644 --- a/rs/moq-uring/tests/workers.rs +++ b/rs/moq-uring/tests/workers.rs @@ -96,6 +96,9 @@ fn a_steered_group_serves_a_shared_port() { // One stop each: the wakers are per-thread, so a shared slot would let one // worker's registration clobber the other's. let stops: Vec> = (0..WORKERS).map(|_| Arc::new(Stop::default())).collect(); + // Each worker reports in once serving, so a setup failure fails here + // instead of as the dials hashed to it idling out. + let (ready, started) = std::sync::mpsc::channel(); let threads: Vec<_> = members .into_iter() @@ -105,6 +108,7 @@ fn a_steered_group_serves_a_shared_port() { let stop = stop.clone(); let cert = certs.cert.clone(); let key = certs.key.clone(); + let ready = ready.clone(); std::thread::spawn(move || { let shard = member.shard(); let mut worker = Worker::new(Config::default()).expect("worker"); @@ -125,10 +129,15 @@ fn a_steered_group_serves_a_shared_port() { accepted[usize::from(shard.index())].fetch_add(1, Ordering::AcqRel); } }); + ready.send(()).expect("test alive"); worker.block_on(stop.wait()).expect("worker loop"); }) }) .collect(); + drop(ready); + for _ in 0..WORKERS { + started.recv().expect("a worker thread failed to start"); + } // Dial the shared port repeatedly from one client worker. Every handshake // completing is the steering assertion (see the module docs). From a1d993c6d4d23666f4fa7fbfc8bb5eb6f702fd34 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:04:58 -0700 Subject: [PATCH 05/45] chore(skills): spawn-quests judges readiness from line branches (#4161) Co-authored-by: Claude Opus 5.5 --- .claude/skills/spawn-quests/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.claude/skills/spawn-quests/SKILL.md b/.claude/skills/spawn-quests/SKILL.md index ce7afbe14b..6ade2b5d88 100644 --- a/.claude/skills/spawn-quests/SKILL.md +++ b/.claude/skills/spawn-quests/SKILL.md @@ -10,6 +10,8 @@ If you are unsure of the best course of action, ask the user for clarification b The scope consists of all ready quests that are not claimed. Use the argument (if provided) to filter to specific quests/questlines. +Main's tree lags its lines: a child finished on its line branch, or on `dev`, still looks ready from `main`. +Judge readiness from each line branch's own quest directory, and treat a quest deleted on `dev` as done. Inspect any blocked quests, and determine if they can be unblocked. A line whose `Quests` list has emptied is a ready quest too: finishing it marks the line's PR ready. From 394c755a4e1c42d222d43832b3a279e9f91ce002 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:10:24 -0700 Subject: [PATCH 06/45] docs(quest): plan partial moxygen compatibility (#4205) Co-authored-by: Grok 4.7 --- quest/m1/README.md | 1 + quest/m1/moxygen/README.md | 40 ++++++++++++++++++++++++++++++++++++ quest/m1/moxygen/datagram.md | 23 +++++++++++++++++++++ quest/m1/moxygen/fetch.md | 26 +++++++++++++++++++++++ quest/m1/moxygen/priority.md | 28 +++++++++++++++++++++++++ 5 files changed, 118 insertions(+) create mode 100644 quest/m1/moxygen/README.md create mode 100644 quest/m1/moxygen/datagram.md create mode 100644 quest/m1/moxygen/fetch.md create mode 100644 quest/m1/moxygen/priority.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 38fe901419..253a183024 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -45,6 +45,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Import discontinuity](/quest/m1/import-discontinuity.md) - a seek or pause resets the flush jitter baseline, from moqsink, libmoq, and moq-ffi - [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` - [Play tune-in backpressure](/quest/m1/play-tunein-backpressure.md) - moq play: a tune-in burst larger than the video queue parks the decoder, so the clock never reaches live at a wide `--delay` +- [Moxygen compatibility](/quest/m1/moxygen/README.md) - one subgroup per group, whole-group FETCH, and one datagram per group, never a full moxygen pass - [JavaScript FETCH](/quest/m1/js-fetch.md) - generic on-demand group serving and IETF FETCH for browser publishers - [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS, on the catalog and store the release ships - [Wildcard](/quest/m1/wildcard/README.md) - a relay resolves subscriptions against advertised prefixes, a service claims the prefix it could serve and refuses the rest instead of enumerating broadcasts, and the browser player treats a covering claim as availability diff --git a/quest/m1/moxygen/README.md b/quest/m1/moxygen/README.md new file mode 100644 index 0000000000..d7d7e72434 --- /dev/null +++ b/quest/m1/moxygen/README.md @@ -0,0 +1,40 @@ +# Moxygen compatibility + +## Goal + +More compatibility with moxygen's moq-test suite, never a full pass. A peer +that speaks one subgroup per group, FETCH of whole groups, and one datagram +per group gets those through the relay. + +## Plan + +`conformance_test.sh` on draft-16 scored 0/76 against moq-relay 0.15.1. +Subgroup subscribes did return objects. The relay decodes IETF into the +moq-lite track and writes a new subgroup. A green run of all 76 cases is not +the exit test. + +Out of scope, so they are not reopened as bugs: + +- PUBLISH. The relay refuses it. Routing stays SUBSCRIBE per namespace. +- Subgroups. A non-zero subgroup id is dropped. One stream per group. +- Per-group priority. `track::Info` has one priority. A 200/201 split by + group parity stays a gap. +- Foreign object extensions. Dropped. A timestamp we add is ours. +- The end-of-group bit. Optional in draft-16. The group's one stream FINs + at the end, so the bit adds nothing this line will do. +- Several datagram objects in one group. +- A peer that answers SUBSCRIBE_NAMESPACE with unimplemented. The session + already continues. + +Docs stay inline in the change that makes them stale. No new guide. + +## Quests + +- [Default track priority](/quest/m1/moxygen/priority.md) - an unset track priority is the midpoint on moq-lite and on IETF, not the least urgent value +- [Group FETCH](/quest/m1/moxygen/fetch.md) - an IETF FETCH of whole groups is served from cache or fetched upstream, one group at a time +- [Datagram groups](/quest/m1/moxygen/datagram.md) - an IETF datagram that is one object in a group arrives as a moq-lite datagram group + +## Related + +- [JavaScript FETCH](/quest/m1/js-fetch.md) - the browser publisher answers a FETCH this relay forwards +- [Track priority scope](/quest/m1/track-priority-scope.md) - send-order fairness, not the default value diff --git a/quest/m1/moxygen/datagram.md b/quest/m1/moxygen/datagram.md new file mode 100644 index 0000000000..c794b5e57d --- /dev/null +++ b/quest/m1/moxygen/datagram.md @@ -0,0 +1,23 @@ +# [M] Datagram groups + +## Goal + +An IETF datagram that carries one object for a group is delivered as a +moq-lite datagram: one single-frame group, sequence preserved. Several +objects in one datagram group stay unsupported. + +## Plan + +moq-lite already does this. A datagram is a subscribe id, a group sequence, a +timestamp, and a payload. `insert_datagram` keeps the sequence so a relay +does not renumber it. + +The IETF session does not read or write QUIC datagrams, so a datagram +subscribe delivers nothing. Copy the lite path onto that session. Do not +invent a second object list inside the group. + +The moxygen cases with one object per group are the ones this can pass. + +## Related + +- [Moxygen compatibility](/quest/m1/moxygen/README.md) - the line this belongs to diff --git a/quest/m1/moxygen/fetch.md b/quest/m1/moxygen/fetch.md new file mode 100644 index 0000000000..f236d1751f --- /dev/null +++ b/quest/m1/moxygen/fetch.md @@ -0,0 +1,26 @@ +# [L] Group FETCH + +## Goal + +An IETF FETCH that asks for whole groups is answered. A group already in +cache is served from there. A miss fetches that group upstream. Subscribers +still FETCH one group at a time. A publisher may be asked for a range, which +the relay walks one group at a time. A publisher refusal is what the +subscriber sees. + +## Plan + +Standalone FETCH and a non-zero joining FETCH are refused today with +"not supported". Walk `track::Consumer::fetch_group`, one group, then the +next. Do not add an archive. + +A joining FETCH is the same walk for the groups it names. A form the walk +cannot express is still an explicit refusal, not a hang. + +The moxygen FETCH cases that ask for whole groups are the check. The rest of +that suite is not. + +## Related + +- [Moxygen compatibility](/quest/m1/moxygen/README.md) - the line this belongs to +- [JavaScript FETCH](/quest/m1/js-fetch.md) - the browser publisher that fills an upstream miss diff --git a/quest/m1/moxygen/priority.md b/quest/m1/moxygen/priority.md new file mode 100644 index 0000000000..6efd6aaa5a --- /dev/null +++ b/quest/m1/moxygen/priority.md @@ -0,0 +1,28 @@ +# [S] Default track priority + +## Goal + +A track that never set a priority is the midpoint on both wires. moq-lite +TRACK_INFO carries that midpoint as written. An IETF subgroup header carries +128. A track that set `track::Info.priority` still uses that value. Per-group +priority stays unsupported. + +## Plan + +`track::Info.priority` defaults to 0, higher-first. moq-lite writes it +verbatim, so the wire byte is 0. IETF flips it, so 0 goes out as 255. The +draft's usual publisher priority is 128. + +One model default serves both. 127 is the higher-first midpoint: lite writes +127, IETF writes 128. Do not special-case each wire to the byte 128. That +would make the two encodings different urgencies. + +Rust and JavaScript both pin the old default TRACK_INFO bytes +(`0x06, 0x00, ...`). Update those fixtures with the new default. + +The moxygen 200 versus 201 split by group parity is out of scope. + +## Related + +- [Moxygen compatibility](/quest/m1/moxygen/README.md) - the line this belongs to +- [Track priority scope](/quest/m1/track-priority-scope.md) - fairness across owners, not this default From bc051b76880543fe890c83da3d1f0e0b20bf1c11 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:10:36 -0700 Subject: [PATCH 07/45] fix(net): skip old groups without reporting them as lost (#4208) Co-authored-by: Claude Opus 5.5 --- rs/moq-net/src/lite/subscriber.rs | 6 +++++- rs/moq-net/src/model/frame.rs | 15 ++++++++++----- rs/moq-net/src/model/resume.rs | 16 ++++++++++------ 3 files changed, 25 insertions(+), 12 deletions(-) diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 46b90b0d5c..c0ab106215 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -766,7 +766,11 @@ impl GroupRecv { } }; - let GroupRecvState::Serve { group, .. } = std::mem::replace(&mut self.state, GroupRecvState::Done) + // Held until the group settles below, so a frame the track or group cut + // short drops into an already-aborted group instead of reporting a loss. + let GroupRecvState::Serve { + group, ingest: _ingest, .. + } = std::mem::replace(&mut self.state, GroupRecvState::Done) else { unreachable!() }; diff --git a/rs/moq-net/src/model/frame.rs b/rs/moq-net/src/model/frame.rs index 0b344f2f97..d2eab832f2 100644 --- a/rs/moq-net/src/model/frame.rs +++ b/rs/moq-net/src/model/frame.rs @@ -359,11 +359,16 @@ impl> Drop for Raw { if !self.done { // An unfinished frame leaves the group stream broken; fail the group so // consumers surface an error instead of hanging on the partial forever. - tracing::warn!( - group = self.group.borrow_mut().info().sequence, - "frame::Producer dropped before writing all bytes" - ); - self.group.borrow_mut().frame_abort(Error::Dropped); + // A group already aborted (superseded, evicted, cancelled) carries its own + // reason, so cutting its in-flight frame short is expected. + let group = self.group.borrow_mut(); + if !group.is_aborted() { + tracing::warn!( + group = group.info().sequence, + "frame::Producer dropped before writing all bytes" + ); + } + group.frame_abort(Error::Dropped); } } } diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index e2227a17f1..ee081f1474 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1214,12 +1214,16 @@ impl Group { Some((_, err)) => { // The only place a spliced group's loss becomes visible, so say which // frames went missing rather than leaving a stuck group to explain itself. - tracing::warn!( - group = self.sequence, - frame = self.index, - %err, - "no route can serve the rest of this group" - ); + // An old group was skipped on purpose (something newer superseded it), so + // it is not a loss worth reporting. + if !matches!(crate::StreamError::from(err), crate::StreamError::Old) { + tracing::warn!( + group = self.sequence, + frame = self.index, + %err, + "no route can serve the rest of this group" + ); + } Err(err.clone()) } None => Ok(false), From f70eba88e63cc21df2f35af7e04ef294750da842 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:11:05 -0700 Subject: [PATCH 08/45] fix(video): run the capture clock tests on a LocalSet (#4210) Co-authored-by: Claude Opus 5.5 --- rs/moq-video/src/encode/producer.rs | 291 +++++++++++++++------------- 1 file changed, 156 insertions(+), 135 deletions(-) diff --git a/rs/moq-video/src/encode/producer.rs b/rs/moq-video/src/encode/producer.rs index 86353d907b..dd76386bcd 100644 --- a/rs/moq-video/src/encode/producer.rs +++ b/rs/moq-video/src/encode/producer.rs @@ -947,7 +947,8 @@ mod tests { let (opens, rx) = tokio::sync::mpsc::unbounded_channel(); let (stop, stopped) = tokio::sync::oneshot::channel::<()>(); - let task = tokio::spawn(async move { + // Local: a capture stream is `!Send` on macOS, so each test runs on a `LocalSet`. + let task = tokio::task::spawn_local(async move { let mut source = Opens(rx); let options = Options { kind: encoder::Kind::Software, @@ -1053,166 +1054,186 @@ mod tests { /// acquisition: not zero, and not the later instant the loop dequeued it. #[tokio::test] async fn a_late_first_frame_publishes_its_acquisition() { - let fixture = Fixture::start(Duration::from_secs(5), SystemTime::now()).await; - let mut track = fixture.subscribe().await; - let camera = fixture.camera(); - - let captured = Instant::now(); - // Delivered well after acquisition: dequeue time must not leak into the timestamp. - tokio::time::sleep(Duration::from_millis(50)).await; - camera.push_at(surface(), captured); - let published = read(&mut track).await; - - assert!(published >= 4_000_000, "{published}us restarted the broadcast at zero"); - fixture.assert_acquired(published, captured); - fixture.finish().await; + tokio::task::LocalSet::new() + .run_until(async { + let fixture = Fixture::start(Duration::from_secs(5), SystemTime::now()).await; + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + + let captured = Instant::now(); + // Delivered well after acquisition: dequeue time must not leak into the timestamp. + tokio::time::sleep(Duration::from_millis(50)).await; + camera.push_at(surface(), captured); + let published = read(&mut track).await; + + assert!(published >= 4_000_000, "{published}us restarted the broadcast at zero"); + fixture.assert_acquired(published, captured); + fixture.finish().await; + }) + .await } /// A device clock that restarts at zero, mid-stream or across a reopen, continues the /// broadcast forward with the device's spacing instead of rewinding it. #[tokio::test] async fn a_device_clock_restart_continues_forward() { - let fixture = Fixture::start(Duration::from_secs(1), SystemTime::now()).await; - let mut track = fixture.subscribe().await; - let camera = fixture.camera(); - - // The device numbers from zero, and real time keeps pace with it. - camera.push_native(surface(), us(0)); - let first = read(&mut track).await; - tokio::time::sleep(Duration::from_millis(40)).await; - camera.push_native(surface(), us(40_000)); - let second = read(&mut track).await; - assert_eq!(second - first, 40_000, "the device's spacing survives"); - - // The device restarts its clock without the stream ending. - camera.push_native(surface(), us(0)); - let restarted = read(&mut track).await; - assert!(restarted >= second, "{restarted}us rewound behind {second}us"); - tokio::time::sleep(Duration::from_millis(40)).await; - camera.push_native(surface(), us(40_000)); - let resumed = read(&mut track).await; - assert_eq!(resumed - restarted, 40_000, "the device's spacing resumes"); - - // The device goes away and comes back numbering from zero again. - camera.close(); - let camera = fixture.camera(); - let pushed = Instant::now(); - camera.push_native(surface(), us(0)); - let reopened = read(&mut track).await; - let arrived = fixture.at(Instant::now()); - assert!(reopened >= resumed, "{reopened}us rewound across the reopen"); - let early = u64::try_from(SAMPLING.as_micros()).unwrap(); - assert!(reopened + early >= fixture.at(pushed) && reopened <= arrived + ROUNDING); - fixture.finish().await; + tokio::task::LocalSet::new() + .run_until(async { + let fixture = Fixture::start(Duration::from_secs(1), SystemTime::now()).await; + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + + // The device numbers from zero, and real time keeps pace with it. + camera.push_native(surface(), us(0)); + let first = read(&mut track).await; + tokio::time::sleep(Duration::from_millis(40)).await; + camera.push_native(surface(), us(40_000)); + let second = read(&mut track).await; + assert_eq!(second - first, 40_000, "the device's spacing survives"); + + // The device restarts its clock without the stream ending. + camera.push_native(surface(), us(0)); + let restarted = read(&mut track).await; + assert!(restarted >= second, "{restarted}us rewound behind {second}us"); + tokio::time::sleep(Duration::from_millis(40)).await; + camera.push_native(surface(), us(40_000)); + let resumed = read(&mut track).await; + assert_eq!(resumed - restarted, 40_000, "the device's spacing resumes"); + + // The device goes away and comes back numbering from zero again. + camera.close(); + let camera = fixture.camera(); + let pushed = Instant::now(); + camera.push_native(surface(), us(0)); + let reopened = read(&mut track).await; + let arrived = fixture.at(Instant::now()); + assert!(reopened >= resumed, "{reopened}us rewound across the reopen"); + let early = u64::try_from(SAMPLING.as_micros()).unwrap(); + assert!(reopened + early >= fixture.at(pushed) && reopened <= arrived + ROUNDING); + fixture.finish().await; + }) + .await } /// Releasing the camera while nobody watches keeps the broadcast clock running: the /// frame after a resume lands after the real idle gap, at its own acquisition. #[tokio::test] async fn a_restart_after_idle_keeps_the_gap() { - let idle = Duration::from_millis(300); - let fixture = Fixture::start(Duration::from_secs(1), SystemTime::now()).await; - - let mut track = fixture.subscribe().await; - let camera = fixture.camera(); - let captured = Instant::now(); - camera.push_at(surface(), captured); - let before = read(&mut track).await; - fixture.assert_acquired(before, captured); - drop(track); - drop(camera); - - tokio::time::sleep(idle).await; - - let mut track = fixture.subscribe().await; - let camera = fixture.camera(); - let captured = Instant::now(); - camera.push_at(surface(), captured); - let after = read_new(&mut track, &[before]).await; - fixture.assert_acquired(after, captured); - assert!( - after - before >= u64::try_from(idle.as_micros()).unwrap(), - "the {idle:?} idle gap collapsed to {}us", - after - before - ); - fixture.finish().await; + tokio::task::LocalSet::new() + .run_until(async { + let idle = Duration::from_millis(300); + let fixture = Fixture::start(Duration::from_secs(1), SystemTime::now()).await; + + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + let captured = Instant::now(); + camera.push_at(surface(), captured); + let before = read(&mut track).await; + fixture.assert_acquired(before, captured); + drop(track); + drop(camera); + + tokio::time::sleep(idle).await; + + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + let captured = Instant::now(); + camera.push_at(surface(), captured); + let after = read_new(&mut track, &[before]).await; + fixture.assert_acquired(after, captured); + assert!( + after - before >= u64::try_from(idle.as_micros()).unwrap(), + "the {idle:?} idle gap collapsed to {}us", + after - before + ); + fixture.finish().await; + }) + .await } /// The wall mapping is pinned when the broadcast clock is built. A system clock stepped /// an hour since then retimes neither the published timestamps nor the advertised mapping. #[tokio::test] async fn a_system_wall_adjustment_retimes_nothing() { - // Whole seconds, so the advertised mapping holds it exactly. - let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); - let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(now.as_secs() - 3600); - let fixture = Fixture::start(Duration::from_secs(1), wall).await; - let advertised = fixture.catalog.snapshot().clock; - assert_eq!(advertised, Some(fixture.clock.wall())); - - let mut track = fixture.subscribe().await; - let camera = fixture.camera(); - let captured = Instant::now(); - camera.push_at(surface(), captured); - let published = read(&mut track).await; - - // Timestamps follow the monotonic epoch and map to walls under the pinned mapping. - fixture.assert_acquired(published, captured); - let mapped = advertised.unwrap().wall_clock(us(published)).unwrap(); - // The catalog maps to walls at millisecond precision. - assert_eq!(mapped, wall + Duration::from_millis(published / 1000)); - assert_eq!(fixture.catalog.snapshot().clock, advertised); - fixture.finish().await; + tokio::task::LocalSet::new() + .run_until(async { + // Whole seconds, so the advertised mapping holds it exactly. + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); + let wall = SystemTime::UNIX_EPOCH + Duration::from_secs(now.as_secs() - 3600); + let fixture = Fixture::start(Duration::from_secs(1), wall).await; + let advertised = fixture.catalog.snapshot().clock; + assert_eq!(advertised, Some(fixture.clock.wall())); + + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + let captured = Instant::now(); + camera.push_at(surface(), captured); + let published = read(&mut track).await; + + // Timestamps follow the monotonic epoch and map to walls under the pinned mapping. + fixture.assert_acquired(published, captured); + let mapped = advertised.unwrap().wall_clock(us(published)).unwrap(); + // The catalog maps to walls at millisecond precision. + assert_eq!(mapped, wall + Duration::from_millis(published / 1000)); + assert_eq!(fixture.catalog.snapshot().clock, advertised); + fixture.finish().await; + }) + .await } /// A recording replays what the live edge published: the archive's segment records /// carry the live timestamps across an idle restart, with the idle gap left in. #[tokio::test] async fn retained_archive_playback_keeps_the_live_timestamps() { - let fixture = Fixture::start(Duration::from_secs(1), SystemTime::now()).await; - let section = fixture - .catalog - .snapshot() - .archive - .expect("the video track enrolls an archive"); - let mut timeline = moq_mux::timeline::Consumer::<()>::subscribe(&fixture.consumer, §ion) - .await - .unwrap(); - - let mut live = Vec::new(); - for _ in 0..2 { - let mut track = fixture.subscribe().await; - let camera = fixture.camera(); - let captured = Instant::now(); - camera.push_at(surface(), captured); - let published = read_new(&mut track, &live).await; - fixture.assert_acquired(published, captured); - live.push(published); - drop(track); - // Idle past the minimum segment, so each run is archived as its own segment. - tokio::time::sleep(moq_mux::timeline::DEFAULT_DURATION_MIN + Duration::from_millis(100)).await; - } + tokio::task::LocalSet::new() + .run_until(async { + let fixture = Fixture::start(Duration::from_secs(1), SystemTime::now()).await; + let section = fixture + .catalog + .snapshot() + .archive + .expect("the video track enrolls an archive"); + let mut timeline = moq_mux::timeline::Consumer::<()>::subscribe(&fixture.consumer, §ion) + .await + .unwrap(); + + let mut live = Vec::new(); + for _ in 0..2 { + let mut track = fixture.subscribe().await; + let camera = fixture.camera(); + let captured = Instant::now(); + camera.push_at(surface(), captured); + let published = read_new(&mut track, &live).await; + fixture.assert_acquired(published, captured); + live.push(published); + drop(track); + // Idle past the minimum segment, so each run is archived as its own segment. + tokio::time::sleep(moq_mux::timeline::DEFAULT_DURATION_MIN + Duration::from_millis(100)).await; + } - let (catalog, _consumer) = fixture.finish().await; - catalog.timeline().finish().unwrap(); - let mut archived = Vec::new(); - while let Some(event) = timeline.next().await.unwrap() { - match event { - moq_mux::timeline::Event::Push { entry, .. } => archived.push(entry), - other => panic!("unexpected timeline event {other:?}"), - } - } + let (catalog, _consumer) = fixture.finish().await; + catalog.timeline().finish().unwrap(); + let mut archived = Vec::new(); + while let Some(event) = timeline.next().await.unwrap() { + match event { + moq_mux::timeline::Event::Push { entry, .. } => archived.push(entry), + other => panic!("unexpected timeline event {other:?}"), + } + } - assert_eq!(archived.len(), live.len(), "one segment per capture run: {archived:?}"); - for (entry, live) in archived.iter().zip(&live) { - // The archive keeps millisecond precision. - assert_eq!(entry.pts.as_micros() / 1000, u128::from(*live / 1000), "{archived:?}"); - assert!(entry.tracks.contains_key("video"), "{archived:?}"); - } - let first = &archived[0]; - assert!( - archived[1].pts.as_micros() >= first.pts.as_micros() + first.duration.as_micros(), - "the resumed segment overlaps the one before it: {archived:?}" - ); + assert_eq!(archived.len(), live.len(), "one segment per capture run: {archived:?}"); + for (entry, live) in archived.iter().zip(&live) { + // The archive keeps millisecond precision. + assert_eq!(entry.pts.as_micros() / 1000, u128::from(*live / 1000), "{archived:?}"); + assert!(entry.tracks.contains_key("video"), "{archived:?}"); + } + let first = &archived[0]; + assert!( + archived[1].pts.as_micros() >= first.pts.as_micros() + first.duration.as_micros(), + "the resumed segment overlaps the one before it: {archived:?}" + ); + }) + .await } } } From 322d887bda9ee2ada33faf21fe0e0ee781400e8d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:11:12 -0700 Subject: [PATCH 09/45] fix(quic): pin noq 1.3.1 for the BBR correctness fixes (#4206) Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 16 ++++++++-------- Cargo.toml | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 55b36549aa..9276b6998b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4522,9 +4522,9 @@ dependencies = [ [[package]] name = "moq-noq" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "274035555d1c26479ff5dabdf2e6d201fc2d9d1c1357993884eb72dfdc135392" +checksum = "39e37523750e5f1313c3fd1798c4ffdb00218571054f601c3193ceb4e100e5ca" dependencies = [ "bytes", "cfg_aliases", @@ -4544,9 +4544,9 @@ dependencies = [ [[package]] name = "moq-noq-proto" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef835fcf9b92c0f04a77c2fc060cf2a23616ee15176bf41c09847b38186fcb36" +checksum = "7448c4a6a6713b276b346931f3def60e0a89415cd8172d12f6465fed6501a261" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -4575,9 +4575,9 @@ dependencies = [ [[package]] name = "moq-noq-udp" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41114fce0ba95fa5caf3efd62565e71a40da4c2e15f189f482d85db2970d2de5" +checksum = "98e7d85788b7ec2437ac3dd83a231c4e403b96a5e53e39a8701ba5d1d4fad4a4" dependencies = [ "cfg_aliases", "libc", @@ -9843,9 +9843,9 @@ dependencies = [ [[package]] name = "web-transport-moq" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cf318557d2c563036e914dc4282275e4196ea5cee22614075157f0d227dbeaf" +checksum = "3d3bdb2400eb49f82b778165df8da318f94be5a667a840a94fe6fc32f3798746" dependencies = [ "bytes", "futures", diff --git a/Cargo.toml b/Cargo.toml index c8d27c0f07..bed70b2450 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -151,8 +151,8 @@ moq-mux = { version = "0.10.5", path = "rs/moq-mux" } moq-net = { version = "0.3.4", path = "rs/moq-net" } # The MoQ fork of noq (moq-dev/noq). iroh keeps upstream noq, so a build with the # iroh feature carries both stacks. -moq-noq-proto = { version = "1.3", default-features = false } -moq-noq-udp = "1.3" +moq-noq-proto = { version = "1.3.1", default-features = false } +moq-noq-udp = "1.3.1" # NVENC bindings, forked from ViliamVadocz/nvidia-video-codec-sdk to dlopen the # driver at runtime. Compiles on any platform (macOS included) but only actually # used by moq-video on Linux. @@ -237,7 +237,7 @@ web-transport-iroh = "0.7" # default-features off so the QUIC crypto provider is chosen by moq-tokio's # aws-lc-rs / ring features. # The WebTransport adapter released with moq-noq, from the same repository. -web-transport-moq = { version = "1.3", default-features = false } +web-transport-moq = { version = "1.3.1", default-features = false } web-transport-proto = "0.6" web-transport-trait = "0.4" web-transport-wasm = "0.6" From c6412eee2d225022b14f1b33425d979ea2548c7a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:11:22 -0700 Subject: [PATCH 10/45] quest: dropped sources, video keyframe flag, interop flakes (#4201) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 3 +++ quest/m1/dropped-sources.md | 27 +++++++++++++++++++++++++++ quest/m1/interop-flakes.md | 23 +++++++++++++++++++++++ quest/m1/video-keyframe-flag.md | 24 ++++++++++++++++++++++++ 4 files changed, 77 insertions(+) create mode 100644 quest/m1/dropped-sources.md create mode 100644 quest/m1/interop-flakes.md create mode 100644 quest/m1/video-keyframe-flag.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 253a183024..9f4a06a51b 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -23,8 +23,10 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Announce compression](/quest/m1/announce-compression.md) - a lite-07 announce reuses the path head and hop-chain tail of a live announcement on its stream instead of resending them - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached - [Session death error](/quest/m1/session-death-error.md) - a dying session ends its tracks with its own error in Rust and JS, never a clean end, `Dropped`, or `Cancel` +- [Dropped sources](/quest/m1/dropped-sources.md) - consumers see the producer's real error on every end path, never `Dropped` - [JS bare FIN](/quest/m1/js-bare-fin.md) - a `@moq/net` subscriber aborts a track whose subscribe stream FINs before its declared end, like Rust - [Track tail interop](/quest/m1/track-tail-interop.md) - a Rust publisher ending a track with a group in flight is read to its end by the JS subscriber, and the reverse, in `just test interop` +- [Interop flakes](/quest/m1/interop-flakes.md) - the interop harness passes with other runs sharing the machine - [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race - [Origin narrowing](/quest/m1/origin-narrowing.md) - a live origin grant narrows in place and ends the subscriptions it no longer covers, the deafen boundary #2714 asked for - [Auth expiry clock](/quest/m1/auth-expiry-clock.md) - moq-auth and the relay hold one fixed expiry deadline and honour the same skew allowance @@ -64,6 +66,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Transcode source](/quest/m1/transcode-source.md) - select a rendition the chosen backend can actually decode - [Egress rendition pick](/quest/m1/egress-rendition-pick.md) - WHEP and single-track RTMP/FLV serve the best rendition, not the first by name - [Keyframe trigger](/quest/m1/keyframe-trigger.md) - an application can ask the built-in capture encoder for a keyframe +- [Video keyframe flag](/quest/m1/video-keyframe-flag.md) - encoded video marks its keyframes, so a requested cut never forces an extra one after a cadence keyframe - [QoS](/quest/m1/qos/README.md) - broadcast health: relay starvation and timeliness histograms, and client stats broadcasts from publishers and viewers - [Drain](/quest/m1/drain/README.md) - relay restarts drain sessions over GOAWAY instead of hard-dropping them - [Transport upgrade](/quest/m1/transport-upgrade/README.md) - a session that came up over WebSocket moves to QUIC once the QUIC dial lands, handing over at a group boundary diff --git a/quest/m1/dropped-sources.md b/quest/m1/dropped-sources.md new file mode 100644 index 0000000000..9fc495174f --- /dev/null +++ b/quest/m1/dropped-sources.md @@ -0,0 +1,27 @@ +# [M] Consumers see the producer's real error, never Dropped + +## Goal + +A track or broadcast that ends because its source ended reports the source's +own error to every consumer, locally and across a relay. `Dropped` means only +that a handle was dropped without an end, which a correct producer never does. +#4179 fixed one path (a revoked upstream subscription now reads +`Unauthorized`); the rest still surface `Dropped`. + +## Plan + +- Known sources, from #4179: a source closing, a route leaving the origin's + table, and a withdrawn source broadcast. Find each place a consumer can + observe `Dropped` and make the ending side carry its real error (an explicit + `abort` or a preserved cause), at the source rather than by remapping at the + consumer. +- moq-transport: a `PUBLISH_DONE` carrying Unauthorized arrives as + `Error::Remote(1)`. Map it to the same error lite reports. +- Regression tests per path, each failing on `Dropped` today, in-process and + over a mock session. + +Public API: none expected; error values consumers observe change. Wire: none. + +## Related + +- [#4179](https://github.com/moq-dev/moq/pull/4179) - fixed the revoked-upstream path diff --git a/quest/m1/interop-flakes.md b/quest/m1/interop-flakes.md new file mode 100644 index 0000000000..679c0344cf --- /dev/null +++ b/quest/m1/interop-flakes.md @@ -0,0 +1,23 @@ +# [S] Interop harness runs clean in parallel + +## Goal + +`just test interop --all` passes reliably while other harness runs share the +machine. Two known flakes: concurrent Nix shells reserve the same port because +each keeps its reservations under its own `TMPDIR`, and the browser driver's +pause click is sometimes blocked by the canvas (`js -> js`). + +## Plan + +- Port reservations live under one root shared by every shell (not + `TMPDIR`), or ports come from the OS (bind to 0 and pass the result). Prefer + whichever removes the reservation file entirely. +- The pause control is driven in a way the canvas cannot intercept (an API or + keyboard path, or waiting for the element to be actionable), not a retry. +- Prove it by running two `--all` harnesses at once, several times. + +Public API: none. Wire: none. + +## Related + +- [#4181](https://github.com/moq-dev/moq/pull/4181) - where both flakes were seen diff --git a/quest/m1/video-keyframe-flag.md b/quest/m1/video-keyframe-flag.md new file mode 100644 index 0000000000..7c97c29d20 --- /dev/null +++ b/quest/m1/video-keyframe-flag.md @@ -0,0 +1,24 @@ +# [S] Encoded video knows its keyframes + +## Goal + +`moq_video::encode::Encoded` says whether an access unit is a keyframe, so the +capture `Control::cut()` throttle counts every keyframe, including the +encoder's own GOP cadence. Today it sees only forced and opening keyframes, so +a cut requested just after a cadence keyframe still forces another one. + +## Plan + +- Every backend already knows whether it produced a keyframe; carry it on + `Encoded`. Whether that is additive depends on whether `Encoded` is + `#[non_exhaustive]`; if it is not, this goes to `dev`. +- The throttle in the capture driver treats any keyframe as satisfying a + pending cut and restarting the spacing window, matching what JS already does. +- Test with a backend whose GOP produces a keyframe right before a requested + cut, asserting no extra keyframe. + +Public API: one field or accessor on `Encoded`. Wire: none. + +## Related + +- [#4184](https://github.com/moq-dev/moq/pull/4184) - the `Control::cut()` this completes From 43fa4f7b7500a8e5a2480261a51c93ea25fb0a2f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:11:39 -0700 Subject: [PATCH 11/45] fix(transcode): choose a source rendition this host can decode (#4182) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- doc/bin/cli.md | 5 + quest/m1/README.md | 1 - quest/m1/egress-rendition-pick.md | 2 +- quest/m1/transcode-source.md | 24 -- rs/moq-transcode/src/catalog.rs | 349 +++++++++++++++++++++++++++--- rs/moq-transcode/src/lib.rs | 141 +++++++++++- rs/moq-transcode/src/pipeline.rs | 6 +- 7 files changed, 461 insertions(+), 67 deletions(-) delete mode 100644 quest/m1/transcode-source.md diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 43f26fdbcd..5f08497161 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -151,6 +151,11 @@ watches them. On NVIDIA the whole pipeline stays on the GPU; `--frames cpu` forces decoded frames into CPU memory instead of the default `native`. Requires the `transcode` feature. +The source is the tallest rendition this host can decode with `--decoder`, so a +software-only host transcodes from an H.264 rendition rather than a taller H.265 +or AV1 one. When no rendition decodes, the command exits naming the decoder's +refusal. + The ladder is sized against the source picture and follows it, so a source that changes resolution mid-stream (a window capture renegotiated by a resize, a publisher reconnecting at a new size) resolves the rungs again. Rungs that still diff --git a/quest/m1/README.md b/quest/m1/README.md index 9f4a06a51b..0ed96c21b1 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -63,7 +63,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [CMAF Opus](/quest/m1/cmaf-opus-dops.md) - fMP4 import and export keep the Opus pre-skip and gain - [NVDEC teardown](/quest/m1/nvdec-teardown.md) - dropping an NVDEC decoder no longer segfaults - [GPU pool reservation](/quest/m1/gpu-pool-reservation.md) - a full GPU frame pool is a `None` reservation the caller drops on, not an error to match -- [Transcode source](/quest/m1/transcode-source.md) - select a rendition the chosen backend can actually decode - [Egress rendition pick](/quest/m1/egress-rendition-pick.md) - WHEP and single-track RTMP/FLV serve the best rendition, not the first by name - [Keyframe trigger](/quest/m1/keyframe-trigger.md) - an application can ask the built-in capture encoder for a keyframe - [Video keyframe flag](/quest/m1/video-keyframe-flag.md) - encoded video marks its keyframes, so a requested cut never forces an extra one after a cadence keyframe diff --git a/quest/m1/egress-rendition-pick.md b/quest/m1/egress-rendition-pick.md index b0b634a583..828f1d66ee 100644 --- a/quest/m1/egress-rendition-pick.md +++ b/quest/m1/egress-rendition-pick.md @@ -21,4 +21,4 @@ lower-quality rendition sorts first. ## Related - [WHEP ABR](/quest/m2/whep-abr.md) - switch the served rendition per peer instead of fixing one -- [Transcode source](/quest/m1/transcode-source.md) - the same largest-rendition ranking for transcode input +- [Transcode](/doc/bin/cli.md) - the source is the tallest rendition this host can decode diff --git a/quest/m1/transcode-source.md b/quest/m1/transcode-source.md deleted file mode 100644 index bf8e8d456d..0000000000 --- a/quest/m1/transcode-source.md +++ /dev/null @@ -1,24 +0,0 @@ -# [M] Choose a locally decodable transcode source - -## Goal - -A higher-resolution rendition that this host cannot decode does not prevent -transcoding another usable rendition in the same source catalog. - -## Plan - -catalog::choose_source filters by codec syntax and dimensions, then ranks the -largest rendition. It does not establish whether the selected backend can -decode it. On a software-only host, a larger H.265 or AV1 entry can win over a -usable H.264 entry and fail later when demand opens the decoder. - -Reproduce that case with deterministic backend capabilities and preserve the -explicit backend selection policy. Choose among locally supported candidates -or return an actionable refusal when none exists. Avoid repeated encoder or -decoder probes for every catalog edit, and do not turn malformed stream input -into silent fallback after a rendition is already being consumed. - -Test mixed-codec catalogs, an explicitly forced unavailable backend, no usable -candidate, and a catalog update while the selected rendition remains valid. -Keep rendition identity, output names, and existing live/fetch behavior stable. -Run the regressions in CI. Public API and wire: unchanged. diff --git a/rs/moq-transcode/src/catalog.rs b/rs/moq-transcode/src/catalog.rs index a164dd70a3..5568b15626 100644 --- a/rs/moq-transcode/src/catalog.rs +++ b/rs/moq-transcode/src/catalog.rs @@ -3,6 +3,7 @@ use hang::catalog::{AV1, Video, VideoCodec, VideoConfig}; use moq_net::path::RelativeOwned; +use moq_video::decode::Codec; use crate::{Error, Ladder}; @@ -69,24 +70,149 @@ pub(crate) struct Published { pub entry: VideoConfig, } -/// Pick the rendition to transcode from: the highest-resolution decodable -/// (H.264/H.265/AV1) rendition local to the source broadcast. -pub(crate) fn choose_source(video: &Video) -> Result<(String, VideoConfig), Error> { - video +/// Which codecs the configured decoder opens on this host, each probed once. +/// +/// A rendition's codec string says what a decoder must handle, not whether this +/// host has one: a software-only build decodes H.264 and nothing else, and a +/// backend forced by name may not be here at all. Only opening a decoder tells, +/// so each codec is opened (and dropped) the first time a candidate needs it and +/// the answer kept for every later snapshot. +pub(crate) struct Decoders { + config: moq_video::decode::Config, + /// Each codec probed so far, and whether its decoder opened. + probed: Vec<(Codec, bool)>, +} + +impl Decoders { + pub fn new(config: moq_video::decode::Config) -> Self { + Self { + config, + probed: Vec::new(), + } + } + + /// A host that decodes exactly `codecs`, for tests that must not depend on + /// the machine they run on. Nothing is opened until a refusal asks why. + #[cfg(test)] + fn assume(codecs: &[Codec]) -> Self { + let probed = [Codec::H264, Codec::H265, Codec::Av1] + .into_iter() + .map(|codec| (codec, codecs.contains(&codec))) + .collect(); + Self { + config: moq_video::decode::Config::new(), + probed, + } + } + + /// Whether `rendition` (in `codec`) has a decoder on this host. + async fn probe(&mut self, codec: Codec, rendition: &VideoConfig) -> bool { + if let Some((_, decodes)) = self.probed.iter().find(|(probed, _)| *probed == codec) { + return *decodes; + } + + let decodes = match self.open(rendition).await { + Ok(()) => true, + Err(err) => { + tracing::warn!(?codec, %err, "no decoder for this codec; its renditions will not be transcoded"); + false + } + }; + self.probed.push((codec, decodes)); + decodes + } + + /// Why `rendition`'s decoder refused to open. Only the verdict is cached, so + /// this opens it once more, on a path that has nothing left to serve. + /// + /// `Ok(())` means that reopen succeeded. The first failure was transient, so + /// the cached refusal is cleared and the caller selects this rendition + /// instead of waiting on a catalog that will not change. + async fn refusal(&mut self, rendition: &VideoConfig) -> Result<(), Error> { + match self.open(rendition).await { + Err(err) => Err(err.into()), + Ok(()) => { + if let Some(codec) = codec(rendition) + && let Some((_, decodes)) = self.probed.iter_mut().find(|(probed, _)| *probed == codec) + { + *decodes = true; + } + Ok(()) + } + } + } + + /// Open and drop a decoder for `rendition`. + async fn open(&self, rendition: &VideoConfig) -> Result<(), moq_video::Error> { + // Parameter sets in band, so the probe asks about the backend and not about + // this rendition's description: a malformed one belongs to the stream and + // fails when the rendition is decoded, not as a verdict on the whole codec. + let mut config = rendition.clone(); + config.description = None; + match &mut config.codec { + VideoCodec::H264(h264) => h264.inline = true, + VideoCodec::H265(h265) => h265.in_band = true, + _ => {} + } + moq_video::decode::Sink::open(&config, &self.config).await.map(drop) + } +} + +/// Pick the rendition to transcode from: the highest-resolution rendition local +/// to the source broadcast that this host can decode. +/// +/// [`Error::NoSource`] means wait for a later snapshot. Any other error means +/// nothing on offer can be decoded here, and is why the tallest one refused. +pub(crate) async fn choose_source(video: &Video, decoders: &mut Decoders) -> Result<(String, VideoConfig), Error> { + let mut candidates: Vec<_> = video .renditions .iter() // A rendition that itself lives in another broadcast can't be subscribed // through this one; composing relative references is a follow-up. .filter(|(_, config)| config.broadcast.is_none()) - .filter(|(_, config)| can_decode(config)) - // A publisher can advertise its codec before it knows its picture: a capture whose camera - // hasn't been opened publishes a rendition with no dimensions, and the first keyframe fills - // them in. There is no ladder to derive from that yet, so leave it for a later snapshot - // rather than choosing it and failing on geometry. - .filter(|(_, config)| dimensions(config).is_some()) - .max_by_key(|(_, config)| (config.coded_height, config.coded_width, config.bitrate)) - .map(|(name, config)| (name.clone(), config.clone())) - .ok_or(Error::NoSource) + .filter_map(|(name, config)| Some((name, config, codec(config)?))) + .collect(); + // Largest first, ties going to the last name as they always have. A rendition + // without dimensions sorts after every one with them: it can't be chosen yet, + // but it can still keep the transcoder waiting. + candidates.reverse(); + candidates.sort_by_key(|(_, config, _)| { + std::cmp::Reverse(( + dimensions(config).is_some(), + config.coded_height, + config.coded_width, + config.bitrate, + )) + }); + + let mut refused = None; + for (name, config, codec) in candidates { + match decoders.probe(codec, config).await { + true if dimensions(config).is_some() => return Ok((name.clone(), config.clone())), + // A publisher can advertise its codec before it knows its picture: a capture + // whose camera hasn't been opened publishes a rendition with no dimensions, + // and the first keyframe fills them in. There is no ladder to derive from + // that yet, but it will be usable, so wait for it rather than refuse. + true => return Err(Error::NoSource), + false => { + refused.get_or_insert((name, config)); + } + } + } + + let Some((name, config)) = refused else { + return Err(Error::NoSource); + }; + match decoders.refusal(config).await { + Err(err) => { + tracing::warn!(rendition = %name, "no source rendition can be decoded on this host"); + Err(err) + } + // The picture is known, so this is the source. A missing size still has + // to wait, but the codec is cached as decodable for the next snapshot. + Ok(()) if dimensions(config).is_some() => Ok((name.clone(), config.clone())), + Ok(()) => Err(Error::NoSource), + } } /// Re-pick the source rendition for a new snapshot, preferring the one already @@ -97,13 +223,21 @@ pub(crate) fn choose_source(video: &Video) -> Result<(String, VideoConfig), Erro /// that was serving. Sticking to the current name follows the picture it now /// carries (the point of this path) without treating a momentary catalog edit as /// a source switch. -pub(crate) fn follow_source(video: &Video, current: &str) -> Result<(String, VideoConfig), Error> { - match video.renditions.get(current) { - Some(config) if config.broadcast.is_none() && can_decode(config) && dimensions(config).is_some() => { - Ok((current.to_string(), config.clone())) - } - _ => choose_source(video), +pub(crate) async fn follow_source( + video: &Video, + current: &str, + decoders: &mut Decoders, +) -> Result<(String, VideoConfig), Error> { + if let Some(config) = video.renditions.get(current) + && config.broadcast.is_none() + && dimensions(config).is_some() + && let Some(codec) = codec(config) + // The name can stay while the codec changes under it. + && decoders.probe(codec, config).await + { + return Ok((current.to_string(), config.clone())); } + choose_source(video, decoders).await } /// Whether a rung decoding `old` can keep decoding `new` untouched. @@ -124,11 +258,13 @@ fn dimensions(config: &VideoConfig) -> Option<(u64, u64)> { } } -fn can_decode(config: &VideoConfig) -> bool { +/// The decoder a rendition needs, if `moq-video` has one for its codec at all. +fn codec(config: &VideoConfig) -> Option { match &config.codec { - VideoCodec::H264(_) | VideoCodec::H265(_) => true, - VideoCodec::AV1(av1) => is_supported_av1(av1), - _ => false, + VideoCodec::H264(_) => Some(Codec::H264), + VideoCodec::H265(_) => Some(Codec::H265), + VideoCodec::AV1(av1) if is_supported_av1(av1) => Some(Codec::Av1), + _ => None, } } @@ -413,19 +549,43 @@ mod tests { /// `run` keeps waiting for a snapshot it can use. Choosing that rendition instead would fail on /// geometry and terminate the transcode before any rung could create the demand that opens the /// camera in the first place. - #[test] - fn dimensionless_rendition_is_not_a_source_yet() { + /// Every codec the tests build a rendition in. + fn decodes_all() -> Decoders { + Decoders::assume(&[Codec::H264, Codec::H265, Codec::Av1]) + } + + /// An H.265 rendition at `width`x`height`. + fn hevc(width: u32, height: u32) -> VideoConfig { + let mut config = VideoConfig::new(hang::catalog::H265 { + in_band: true, + profile_space: 0, + profile_idc: 1, + profile_compatibility_flags: [0x60, 0, 0, 0], + tier_flag: false, + level_idc: 120, + constraint_flags: [0x90, 0, 0, 0, 0, 0], + }); + config.coded_width = Some(width); + config.coded_height = Some(height); + config + } + + #[tokio::test] + async fn dimensionless_rendition_is_not_a_source_yet() { let mut provisional = source(0, 0, None); provisional.coded_width = None; provisional.coded_height = None; let mut video = Video::default(); video.renditions.insert("video".to_string(), provisional.clone()); - assert!(matches!(choose_source(&video), Err(Error::NoSource))); + assert!(matches!( + choose_source(&video, &mut decodes_all()).await, + Err(Error::NoSource) + )); // The keyframe fills the geometry in, and the same rendition becomes usable. video.renditions.insert("video".to_string(), source(1920, 1080, None)); - let (name, chosen) = choose_source(&video).unwrap(); + let (name, chosen) = choose_source(&video, &mut decodes_all()).await.unwrap(); assert_eq!(name, "video"); assert_eq!(chosen.coded_width, Some(1920)); } @@ -529,8 +689,8 @@ mod tests { assert_eq!(names.mint(120), "video/120p.2"); } - #[test] - fn chooses_highest_local_rendition() { + #[tokio::test] + async fn chooses_highest_local_rendition() { let mut video = Video::default(); video.insert("low", source(640, 360, None)).unwrap(); video.insert("high", source(1920, 1080, None)).unwrap(); @@ -538,26 +698,26 @@ mod tests { remote.broadcast = Some(RelativeOwned::from("./other".to_string())); video.insert("remote", remote).unwrap(); - let (name, config) = choose_source(&video).unwrap(); + let (name, config) = choose_source(&video, &mut decodes_all()).await.unwrap(); assert_eq!(name, "high"); assert_eq!(config.coded_height, Some(1080)); } - #[test] - fn chooses_av1_source() { + #[tokio::test] + async fn chooses_av1_source() { let mut video = Video::default(); let mut av1 = VideoConfig::new(hang::catalog::AV1::default()); av1.coded_width = Some(1920); av1.coded_height = Some(1080); video.insert("av1", av1).unwrap(); - let (name, config) = choose_source(&video).unwrap(); + let (name, config) = choose_source(&video, &mut decodes_all()).await.unwrap(); assert_eq!(name, "av1"); assert!(matches!(config.codec, VideoCodec::AV1(_))); } - #[test] - fn skips_unsupported_av1_source() { + #[tokio::test] + async fn skips_unsupported_av1_source() { let mut video = Video::default(); let mut av1 = VideoConfig::new(hang::catalog::AV1 { bitdepth: 10, @@ -568,11 +728,128 @@ mod tests { video.insert("av1", av1).unwrap(); video.insert("h264", source(1920, 1080, None)).unwrap(); - let (name, config) = choose_source(&video).unwrap(); + let (name, config) = choose_source(&video, &mut decodes_all()).await.unwrap(); assert_eq!(name, "h264"); assert!(matches!(config.codec, VideoCodec::H264(_))); } + /// A software-only host decodes H.264 and nothing else, so a taller H.265 + /// rendition loses to it rather than failing once a rung opens its decoder. + #[tokio::test] + async fn skips_a_rendition_this_host_cannot_decode() { + let mut video = Video::default(); + video.insert("hevc", hevc(1920, 1080)).unwrap(); + video.insert("avc", source(640, 360, None)).unwrap(); + let mut av1 = VideoConfig::new(hang::catalog::AV1::default()); + av1.coded_width = Some(3840); + av1.coded_height = Some(2160); + video.insert("av1", av1).unwrap(); + + let mut decoders = Decoders::assume(&[Codec::H264]); + let (name, _) = choose_source(&video, &mut decoders).await.unwrap(); + assert_eq!(name, "avc"); + + // Hardware that decodes H.265 keeps the usual pick of the tallest it can. + let (name, _) = choose_source(&video, &mut Decoders::assume(&[Codec::H264, Codec::H265])) + .await + .unwrap(); + assert_eq!(name, "hevc"); + } + + /// Nothing on offer decodes here: a refusal carrying why the tallest + /// rendition's decoder refused, rather than waiting on a complete catalog. + #[tokio::test] + async fn refuses_when_no_rendition_decodes() { + let mut video = Video::default(); + video.insert("small", hevc(640, 360)).unwrap(); + video.insert("large", hevc(1920, 1080)).unwrap(); + + let mut config = moq_video::decode::Config::new(); + config.kind = moq_video::decode::Kind::Named("missing".to_string()); + match choose_source(&video, &mut Decoders::new(config)).await { + Err(Error::Video(moq_video::Error::UnknownDecoder { name, codec, .. })) => { + assert_eq!(name, "missing"); + assert_eq!(codec, Codec::H265); + } + other => panic!("expected the decoder's refusal, got {other:?}"), + } + } + + /// A probe can fail once and succeed when the refusal path opens the decoder + /// again. That rendition is the source. Returning `NoSource` would leave + /// `run` waiting on a catalog a static source never updates, with the stale + /// refusal still cached. + #[tokio::test] + async fn a_reopen_that_succeeds_is_the_source() { + let mut video = Video::default(); + video.insert("avc", source(640, 360, None)).unwrap(); + + // Cached as refused without opening, the shape of a probe that failed once. + let mut decoders = Decoders::assume(&[]); + decoders.config.kind = moq_video::decode::Kind::Software; + let (name, _) = choose_source(&video, &mut decoders).await.unwrap(); + assert_eq!(name, "avc"); + assert!(decoders.probe(Codec::H264, &source(640, 360, None)).await); + } + + /// A decodable rendition still waiting on its first keyframe will be usable, + /// so an undecodable one that already knows its picture is no reason to refuse. + /// A simulcast publisher fills in each rendition's geometry separately. + #[tokio::test] + async fn a_pending_decodable_rendition_defers_the_refusal() { + let mut pending = source(0, 0, None); + pending.coded_width = None; + pending.coded_height = None; + + let mut video = Video::default(); + video.insert("hevc", hevc(1920, 1080)).unwrap(); + video.insert("avc", pending).unwrap(); + + let mut decoders = Decoders::assume(&[Codec::H264]); + assert!(matches!( + choose_source(&video, &mut decoders).await, + Err(Error::NoSource) + )); + + video.renditions.insert("avc".to_string(), source(640, 360, None)); + let (name, _) = choose_source(&video, &mut decoders).await.unwrap(); + assert_eq!(name, "avc"); + } + + /// The current source stays while it is still decodable, even when a taller + /// rendition appears, and is replaced once its codec changes to one this host + /// can't decode. + #[tokio::test] + async fn follow_keeps_a_valid_source() { + let mut video = Video::default(); + video.insert("avc", source(640, 360, None)).unwrap(); + let mut decoders = Decoders::assume(&[Codec::H264]); + + video.insert("tall", source(1920, 1080, None)).unwrap(); + video.insert("hevc", hevc(3840, 2160)).unwrap(); + let (name, _) = follow_source(&video, "avc", &mut decoders).await.unwrap(); + assert_eq!(name, "avc"); + + video.renditions.insert("avc".to_string(), hevc(640, 360)); + let (name, _) = follow_source(&video, "avc", &mut decoders).await.unwrap(); + assert_eq!(name, "tall"); + } + + /// Probing opens a real decoder, so each codec is opened once per transcoder + /// rather than once per catalog edit. + #[tokio::test] + async fn each_codec_is_probed_once() { + let mut config = moq_video::decode::Config::new(); + config.kind = moq_video::decode::Kind::Named("missing".to_string()); + let mut decoders = Decoders::new(config); + + // Every catalog edit reshapes the rendition; the H.265 verdict stands. + for height in [360, 720, 1080] { + assert!(!decoders.probe(Codec::H265, &hevc(height * 16 / 9, height)).await); + } + assert_eq!(decoders.probed, [(Codec::H265, false)]); + } + #[test] fn populate_preserves_the_child_archive() { let mut child = moq_mux::catalog::hang::Catalog::<()>::default(); diff --git a/rs/moq-transcode/src/lib.rs b/rs/moq-transcode/src/lib.rs index a9cda09c0d..122591eacf 100644 --- a/rs/moq-transcode/src/lib.rs +++ b/rs/moq-transcode/src/lib.rs @@ -123,20 +123,29 @@ impl Transcoder { .subscribe(hang::Catalog::default_subscription()) .await?; let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track); + let mut decoders = catalog::Decoders::new(config.feed_decoder()); let (source_name, source_config, snapshot) = loop { let Some(snapshot) = catalogs.next().await? else { return Err(Error::NoSource); }; - match catalog::choose_source(&snapshot.video) { + match catalog::choose_source(&snapshot.video, &mut decoders).await { Ok((name, config)) => break (name, config, snapshot), - Err(_) => tracing::debug!("no transcodable rendition yet; waiting for a catalog update"), + Err(Error::NoSource) => tracing::debug!("no transcodable rendition yet; waiting for a catalog update"), + Err(err) => return Err(err), } }; // The ladder, the shared decode behind it, and the rungs serving off it. // Resolved again on every source catalog snapshot, so a source that resizes // mid-stream takes the ladder with it. - let mut ladder = - pipeline::Pipeline::new(source.clone(), config.clone(), active, source_name, source_config).await?; + let mut ladder = pipeline::Pipeline::new( + source.clone(), + config.clone(), + active, + decoders, + source_name, + source_config, + ) + .await?; // Publish the derivative catalog before any encoder exists, so subscribers // can pick a rung immediately. Commit so a catalog that cannot be published fails @@ -1328,6 +1337,130 @@ mod tests { transcoder.abort(); } + /// A source broadcast whose catalog offers a 1080p H.265 rendition next to a + /// 360p H.264 one, the way a simulcasting publisher would. The tracks exist + /// but carry no media, since no rung encodes until someone asks. + fn mixed_codec_source() -> ( + moq_net::broadcast::Producer, + moq_mux::catalog::Producer, + [moq_net::track::Producer; 2], + ) { + let mut broadcast = moq_net::broadcast::Info::default().produce(); + let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); + + let mut hevc = hang::catalog::VideoConfig::new(hang::catalog::H265 { + in_band: true, + profile_space: 0, + profile_idc: 1, + profile_compatibility_flags: [0x60, 0, 0, 0], + tier_flag: false, + level_idc: 120, + constraint_flags: [0x90, 0, 0, 0, 0, 0], + }); + hevc.coded_width = Some(1920); + hevc.coded_height = Some(1080); + hevc.bitrate = Some(6_000_000); + + let mut avc = hang::catalog::VideoConfig::new(hang::catalog::H264 { + inline: true, + profile: 0x42, + constraints: 0, + level: 30, + }); + avc.coded_width = Some(640); + avc.coded_height = Some(360); + avc.bitrate = Some(1_000_000); + + let mut guard = catalog.modify().unwrap(); + guard.video.insert("hevc", hevc).unwrap(); + guard.video.insert("avc", avc).unwrap(); + guard.commit().unwrap(); + + let info = hang::container::track_info(hang::catalog::PRIORITY.video); + let tracks = [ + broadcast.create_track("hevc", info.clone()).unwrap(), + broadcast.create_track("avc", info).unwrap(), + ]; + (broadcast, catalog, tracks) + } + + /// A larger rendition this host can't decode must not win the source. With + /// only the software decoder, the 1080p H.265 entry would be chosen, sized a + /// ladder the H.264 one can't serve, and failed the moment a rung was asked for. + #[tokio::test] + async fn an_undecodable_larger_rendition_is_not_the_source() { + let (broadcast, _catalog, _tracks) = mixed_codec_source(); + + let config = Config { + ladder: Ladder::new([ + Rung::new(720, moq_net::bandwidth::Rate::from_bps(2_500_000)), + Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000)), + ]) + .unwrap(), + encoder: moq_video::encode::Kind::Software, + decoder: moq_video::decode::Kind::Software, + source: None, + ..Default::default() + }; + + let output = moq_net::broadcast::Info::default().produce(); + let consumer = output.consume(); + let transcoder = tokio::spawn(run(broadcast.consume(), output, config)); + + let track = loop { + match consumer.track(hang::Catalog::DEFAULT_NAME) { + Ok(track) => break track, + Err(moq_net::Error::NotFound) => tokio::task::yield_now().await, + Err(err) => panic!("catalog track: {err}"), + } + }; + let mut catalogs = moq_mux::catalog::hang::Consumer::<()>::new(track.subscribe(None).await.unwrap()); + let derived = await_catalog(&mut catalogs, |snapshot| !snapshot.video.renditions.is_empty()).await; + + // Sized against the 640x360 H.264 rendition: no room for 720p, and 120p is + // 212 wide either way. + let names: Vec<_> = derived.video.renditions.keys().map(String::as_str).collect(); + assert_eq!( + names, + ["video/120p"], + "the ladder was sized against the H.265 rendition" + ); + + transcoder.abort(); + } + + /// A decoder forced by name that this build doesn't have leaves nothing to + /// transcode from, and `run` has to say so rather than wait forever. + #[tokio::test] + async fn a_missing_decoder_is_refused() { + let (broadcast, _catalog, _tracks) = mixed_codec_source(); + + let config = Config { + ladder: Ladder::new([Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000))]).unwrap(), + encoder: moq_video::encode::Kind::Software, + decoder: moq_video::decode::Kind::Named("missing".to_string()), + source: None, + ..Default::default() + }; + + let output = moq_net::broadcast::Info::default().produce(); + let result = tokio::time::timeout( + std::time::Duration::from_secs(5), + run(broadcast.consume(), output, config), + ) + .await + .expect("run kept waiting for a source it can never decode"); + + match result { + // Why the tallest rendition's decoder refused. + Err(Error::Video(moq_video::Error::UnknownDecoder { name, codec, .. })) => { + assert_eq!(name, "missing"); + assert_eq!(codec, moq_video::decode::Codec::H265); + } + other => panic!("expected the decoder's refusal, got {other:?}"), + } + } + /// `run` must terminate (not hang in its shutdown drain) when the source /// broadcast goes away, even with a rung task that was never subscribed. #[tokio::test] diff --git a/rs/moq-transcode/src/pipeline.rs b/rs/moq-transcode/src/pipeline.rs index 0c95f97db2..a81becc9fe 100644 --- a/rs/moq-transcode/src/pipeline.rs +++ b/rs/moq-transcode/src/pipeline.rs @@ -36,6 +36,8 @@ pub(crate) struct Pipeline { source: moq_net::broadcast::Consumer, config: Config, active: active::Producer, + /// Which codecs this host decodes, so a catalog edit never probes again. + decoders: catalog::Decoders, /// The source rendition the rungs are sized against. name: String, @@ -62,6 +64,7 @@ impl Pipeline { source: moq_net::broadcast::Consumer, config: Config, active: active::Producer, + decoders: catalog::Decoders, name: String, rendition: VideoConfig, ) -> Result { @@ -73,6 +76,7 @@ impl Pipeline { source, config, active, + decoders, name, rendition, feed, @@ -165,7 +169,7 @@ impl Pipeline { /// Resolve the ladder again against a new source catalog snapshot. pub(crate) async fn follow(&mut self, video: &Video) -> Result<(), Error> { - let (name, rendition) = match catalog::follow_source(video, &self.name) { + let (name, rendition) = match catalog::follow_source(video, &self.name, &mut self.decoders).await { Ok(chosen) => chosen, // Nothing transcodable in this snapshot: keep serving the ladder we // have rather than tearing it down over an edit the source may undo. From e5d1f1ceff1e74afa5fab885c3a1dd501ba79830 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:12:17 -0700 Subject: [PATCH 12/45] fix(relay): bind smoke-test listeners on :0 instead of probing for a free port (#4198) Co-authored-by: Claude Opus 5.5 --- rs/moq-relay/src/web.rs | 49 +++------ rs/moq-relay/tests/smoke.rs | 199 +++++++++--------------------------- 2 files changed, 63 insertions(+), 185 deletions(-) diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index f948c49fc0..e6c2f41657 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -227,8 +227,8 @@ impl Web { } } - /// Bind configured web sockets now, so an embedder can read ephemeral ports. - pub(crate) fn bind(mut self) -> anyhow::Result { + /// Bind the configured listeners now, so [`addrs`](Self::addrs) reports ephemeral ports before serving. + pub fn bind(mut self) -> anyhow::Result { if self.https_tls.is_none() && self.config.https.listen.is_some() { let tls = build_https_config(&self.config.https.cert, &self.config.https.key, &self.config.https.root)?; self.https_tls = Some(RustlsConfig::from_config(tls)); @@ -254,7 +254,7 @@ impl Web { Ok(self) } - /// The actual bound addresses after [`crate::Relay::load`]. + /// The actual bound addresses after [`bind`](Self::bind) or [`crate::Relay::load`]. pub fn addrs(&self) -> Addrs { self.addrs } @@ -1283,30 +1283,6 @@ mod tests { } } - /// Two ports the kernel just handed out, released together so neither bind can - /// be handed the other's. - #[cfg(all(unix, feature = "websocket"))] - fn free_ports() -> (u16, u16) { - let http = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let https = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - (http.local_addr().unwrap().port(), https.local_addr().unwrap().port()) - } - - /// Connect to `port`, waiting for [`Web::serve`] to finish binding. - #[cfg(all(unix, feature = "websocket"))] - async fn connect(port: u16) -> tokio::net::TcpStream { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - loop { - match tokio::net::TcpStream::connect(("127.0.0.1", port)).await { - Ok(stream) => return stream, - Err(err) if std::time::Instant::now() >= deadline => { - panic!("web listener never came up on port {port}: {err}") - } - Err(_) => tokio::time::sleep(std::time::Duration::from_millis(25)).await, - } - } - } - /// `GET /socket` over `io`, returning the body the handler produced. /// /// Hand-rolled rather than reached through an HTTP client so the same request @@ -1362,11 +1338,9 @@ mod tests { async fn serve_captures_the_socket_on_every_listener() { let dir = TempDir::new().unwrap(); let (ca, cert, key) = make_certs(&dir); - let (http, https) = free_ports(); - let mut config = Config::default(); - config.http.listen = Some(format!("127.0.0.1:{http}").parse().unwrap()); - config.https.listen = Some(format!("127.0.0.1:{https}").parse().unwrap()); + config.http.listen = Some("127.0.0.1:0".parse().unwrap()); + config.https.listen = Some("127.0.0.1:0".parse().unwrap()); config.https.cert = vec![cert.clone()]; config.https.key = vec![key]; @@ -1380,16 +1354,23 @@ mod tests { let cluster = cluster::Cluster::new(crate::cluster::Options::default()).unwrap(); let certificates = moq_tokio::tls::Certificates::from_pem(&std::fs::read(&cert).unwrap()).unwrap(); - let web = Web::new(auth, cluster, certificates, config); + let web = Web::new(auth, cluster, certificates, config).bind().unwrap(); + let Addrs { + http: Some(http), + https: Some(https), + } = web.addrs() + else { + panic!("both listeners are configured"); + }; let serving = tokio::spawn(web.serve(Router::new().route("/socket", get(report_socket)))); assert_eq!( - get_socket(connect(http).await).await, + get_socket(tokio::net::TcpStream::connect(http).await.unwrap()).await, "captured", "the HTTP listener must install the capturing acceptor" ); - let tcp = connect(https).await; + let tcp = tokio::net::TcpStream::connect(https).await.unwrap(); let name = rustls::pki_types::ServerName::try_from("localhost").unwrap(); let tls = tls_connector(&ca).connect(name, tcp).await.expect("TLS handshake"); assert_eq!( diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index 63b47a00c4..50920fe0c1 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -1,13 +1,13 @@ //! End-to-end smoke test through a real moq-relay. //! -//! Stands up the relay's actual axum + auth + cluster stack on a free port, +//! Stands up the relay's actual axum + auth + cluster stack on an ephemeral port, //! connects a publisher and a subscriber via WebSocket, and confirms that //! a frame round-trips with the newest moq-lite version on both sides. The //! version assertion is the regression guard for the //! "axum-only-advertises-bare-`webtransport`" bug that silently downgraded //! relay clients to moq-lite-02. -use std::{net::TcpListener, time::Duration}; +use std::time::Duration; use moq_relay::{Config, Connection, Relay, auth, cluster, web}; use moq_tokio::moq_net; @@ -29,10 +29,14 @@ fn newest_lite_version() -> moq_net::Version { .expect("parse newest lite ALPN as a Version") } -async fn build_web(port: u16, ws: bool) -> web::Web { +/// A [`web::Web`] already bound to an ephemeral loopback HTTP port. +/// +/// Binding up front, rather than probing for a free port and rebinding it, +/// means no other process can take the port in between and answer our client. +async fn build_web(ws: bool) -> web::Web { let mut config = web::Config::default(); config.ws = ws; - config.http.listen = Some(format!("127.0.0.1:{port}").parse().expect("parse listen")); + config.http.listen = Some("127.0.0.1:0".parse().expect("parse listen")); build_web_with(config).await } @@ -60,79 +64,39 @@ async fn build_web_with(web_config: web::Config) -> web::Web { let server = server_config.init(Default::default()).expect("server init"); web::Web::new(auth, cluster, server.certificates(), web_config) + .bind() + .expect("bind web listeners") } -fn free_tcp_port() -> u16 { - // Pick a free port for HTTP, then immediately drop the probe listener - // so axum_server can bind it. There's a tiny race window where the - // kernel could hand the same port to another process, but on localhost - // in a single-test process it's safe in practice. - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - port -} - -async fn wait_for_http(port: u16, server_result: &mut tokio::sync::oneshot::Receiver>) { - // Wait for axum_server to bind. A short poll is more reliable than a - // fixed sleep when CI is slow. - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { - break; - } - match server_result.try_recv() { - Ok(Ok(())) => panic!("relay web server exited before listening"), - Ok(Err(err)) => panic!("relay web server failed before listening: {err:#}"), - Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {} - Err(tokio::sync::oneshot::error::TryRecvError::Closed) => { - panic!("relay web server task ended before listening") - } - } - if std::time::Instant::now() >= deadline { - panic!("relay http listener never became ready on port {port}"); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } -} - -/// The shared bootstrap: stand up a relay listening on `127.0.0.1:` +/// The shared bootstrap: stand up a relay listening on `127.0.0.1:` /// with fully public auth, and return the port plus an abort handle for the /// spawned web server. async fn spawn_relay() -> (u16, tokio::task::JoinHandle<()>) { - let port = free_tcp_port(); - let web = build_web(port, true).await; - - let (server_result_tx, mut server_result_rx) = tokio::sync::oneshot::channel(); - let handle = tokio::spawn(async move { - // `Web::run` only returns on error; in tests we abort it at teardown. - let _ = server_result_tx.send(web.run().await); - }); + let web = build_web(true).await; + let port = web.addrs().http.expect("HTTP listener is configured").port(); - wait_for_http(port, &mut server_result_rx).await; + // `Web::run` only returns on error; in tests we abort it at teardown. + let handle = tokio::spawn(async move { web.run().await.expect("relay web server") }); (port, handle) } /// Stand up the assembled relay path with `--server-version` restricted. async fn spawn_versioned_relay(versions: Vec) -> (u16, tokio::task::JoinHandle<()>) { - let port = free_tcp_port(); let mut config = Config::default(); config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); config.listen.tls.generate = vec!["localhost".into()]; config.listen.version = versions; config.web.ws = true; - config.web.http.listen = Some(format!("127.0.0.1:{port}").parse().expect("parse listen")); + config.web.http.listen = Some("127.0.0.1:0".parse().expect("parse listen")); config.auth.public = vec![moq_auth::Pattern::all()]; + // `load` binds the web listener, so the port is ours before anyone dials it. let relay = Relay::load(config).await.expect("load relay"); - let (server_result_tx, mut server_result_rx) = tokio::sync::oneshot::channel(); - let handle = tokio::spawn(async move { - let _ = server_result_tx.send(relay.run().await); - }); + let port = relay.web_addrs().http.expect("HTTP listener is configured").port(); + let handle = tokio::spawn(async move { relay.run().await.expect("relay") }); - wait_for_http(port, &mut server_result_rx).await; (port, handle) } @@ -387,18 +351,13 @@ async fn relay_websocket_honors_server_version() { #[tokio::test] async fn relay_web_serves_merged_routes() { tokio::time::pause(); - let port = free_tcp_port(); - let web = build_web(port, false).await; + let web = build_web(false).await; + let port = web.addrs().http.expect("HTTP listener is configured").port(); let app = web .routes() .route("/embedded", axum::routing::get(|| async { "embedded\n" })); - let (server_result_tx, mut server_result_rx) = tokio::sync::oneshot::channel(); - let handle = tokio::spawn(async move { - let _ = server_result_tx.send(web.serve(app).await); - }); - - wait_for_http(port, &mut server_result_rx).await; + let handle = tokio::spawn(async move { web.serve(app).await.expect("relay web server") }); let body = reqwest::get(format!("http://127.0.0.1:{port}/embedded")) .await @@ -419,7 +378,6 @@ async fn relay_web_serves_merged_routes() { /// A compile is not evidence any of that still handshakes. #[tokio::test] async fn relay_https_terminates_tls() { - let port = free_tcp_port(); let dir = tempfile::TempDir::new().expect("tempdir"); let key = rcgen::KeyPair::generate().expect("keypair"); @@ -432,22 +390,18 @@ async fn relay_https_terminates_tls() { let mut config = web::Config::default(); config.ws = false; - config.https.listen = Some(format!("127.0.0.1:{port}").parse().expect("parse listen")); + config.https.listen = Some("127.0.0.1:0".parse().expect("parse listen")); config.https.cert = vec![cert_path]; config.https.key = vec![key_path]; let web = build_web_with(config).await; + let port = web.addrs().https.expect("HTTPS listener is configured").port(); // Held past `serve`, which consumes the server: this is the whole point of // taking the handle up front. `Some` because a listener is configured; a relay // with neither HTTP nor HTTPS reports nothing rather than a permanent zero. let health = web.accept_health().expect("an HTTPS listener is configured"); - let (server_result_tx, mut server_result_rx) = tokio::sync::oneshot::channel(); - let handle = tokio::spawn(async move { - let _ = server_result_tx.send(web.run().await); - }); - - wait_for_http(port, &mut server_result_rx).await; + let handle = tokio::spawn(async move { web.run().await.expect("relay web server") }); let client = reqwest::Client::builder() .add_root_certificate(reqwest::Certificate::from_pem(cert.pem().as_bytes()).expect("parse root")) @@ -627,16 +581,19 @@ async fn two_publish_only_clients_coexist() { /// `main.rs` uses. Authenticates through the shared [`Auth`], here with fully /// public access (`--auth-public ""`) so no-JWT clients get the root. /// -/// Returns the QUIC socket the server bound, when it has one, so a caller that -/// asked for an ephemeral port can dial it. +/// Returns the QUIC and TCP sockets the server bound, when it has them, so a +/// caller that asked for an ephemeral port can dial it. async fn spawn_accept_relay( config: moq_tokio::listen::Config, auth_config: auth::Config, -) -> (Option, tokio::task::JoinHandle<()>) { +) -> ( + Option, + Option, + tokio::task::JoinHandle<()>, +) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let server = config.init(Default::default()).expect("server init"); - let addr = server.local_addr().ok(); let auth = auth_config .init("test", &moq_tokio::tls::Connect::default()) @@ -644,6 +601,8 @@ async fn spawn_accept_relay( let cluster = cluster::Cluster::new(cluster::Options::default()).expect("cluster init"); let mut server = server.listen().await.expect("listen"); + let quic = server.local_addr().ok(); + let tcp = server.tcp_local_addr(); let handle = tokio::spawn(async move { let mut id = 0; @@ -658,40 +617,23 @@ async fn spawn_accept_relay( } }); - (addr, handle) + (quic, tcp, handle) } /// Stand up the relay listening only on a plain-TCP qmux `--server-bind` on a -/// free loopback port, with fully public auth (no-JWT => whole root). Returns +/// ephemeral loopback port, with fully public auth (no-JWT => whole root). Returns /// the port and an abort handle. async fn spawn_internal_relay() -> (u16, tokio::task::JoinHandle<()>) { - // Pick a free TCP port, then drop the probe so the listener can bind it. - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - // Stream-only: a TCP listener with no `--server-bind`, so no QUIC. let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); // Public Simple([""]) lets any no-JWT stream client through at the root. let mut auth_config = auth::Config::default(); auth_config.public = vec![moq_auth::Pattern::all()]; - let (_, handle) = spawn_accept_relay(config, auth_config).await; - - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { - break; - } - if std::time::Instant::now() >= deadline { - panic!("internal listener never became ready on port {port}"); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - - (port, handle) + let (_, tcp, handle) = spawn_accept_relay(config, auth_config).await; + (tcp.expect("relay bound no TCP socket").port(), handle) } /// Connect a publisher and subscriber to a stream `--server-bind` over `tcp://` @@ -792,20 +734,7 @@ async fn spawn_internal_unix_relay() -> (std::path::PathBuf, tokio::task::JoinHa let mut auth_config = auth::Config::default(); auth_config.public = vec![moq_auth::Pattern::all()]; - let (_, handle) = spawn_accept_relay(config, auth_config).await; - - // Wait for the socket file to appear. - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::UnixStream::connect(&path).await.is_ok() { - break; - } - if std::time::Instant::now() >= deadline { - panic!("internal Unix listener never became ready at {}", path.display()); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - + let (_, _, handle) = spawn_accept_relay(config, auth_config).await; (path, handle) } @@ -1009,8 +938,8 @@ async fn spawn_quic_relay() -> (std::net::SocketAddr, tokio::task::JoinHandle<() let mut auth_config = auth::Config::default(); auth_config.public = vec![moq_auth::Pattern::all()]; - let (addr, handle) = spawn_accept_relay(config, auth_config).await; - (addr.expect("relay bound no QUIC socket"), handle) + let (quic, _, handle) = spawn_accept_relay(config, auth_config).await; + (quic.expect("relay bound no QUIC socket"), handle) } /// Raw QUIC has no request URI either, so `moqt://host:port/` only reaches the @@ -1057,31 +986,15 @@ async fn health_endpoint_reports_ok() { /// the TCP port and an abort handle. A no-JWT client gets the root for subscribing but /// no publish scope, so a publisher's role is rejected. async fn spawn_subscribe_only_relay() -> (u16, tokio::task::JoinHandle<()>) { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); // Subscribe-only public access: the root is granted for subscribing, never publishing. let mut auth_config = auth::Config::default(); auth_config.public_subscribe = vec![moq_auth::Pattern::all()]; - let (_, handle) = spawn_accept_relay(config, auth_config).await; - - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { - break; - } - if std::time::Instant::now() >= deadline { - panic!("subscribe-only listener never became ready on port {port}"); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - - (port, handle) + let (_, tcp, handle) = spawn_accept_relay(config, auth_config).await; + (tcp.expect("relay bound no TCP socket").port(), handle) } /// A publisher whose token grants only subscribe scope is rejected during the @@ -1146,31 +1059,15 @@ async fn subscribe_only_public_accepts_subscriber_role() { /// The mirror of [`spawn_subscribe_only_relay`]: public access grants **publish only**, /// so a no-JWT client gets the root for publishing but no subscribe scope. async fn spawn_publish_only_relay() -> (u16, tokio::task::JoinHandle<()>) { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); // Publish-only public access: the root is granted for publishing, never subscribing. let mut auth_config = auth::Config::default(); auth_config.public_publish = vec![moq_auth::Pattern::all()]; - let (_, handle) = spawn_accept_relay(config, auth_config).await; - - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { - break; - } - if std::time::Instant::now() >= deadline { - panic!("publish-only listener never became ready on port {port}"); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - - (port, handle) + let (_, tcp, handle) = spawn_accept_relay(config, auth_config).await; + (tcp.expect("relay bound no TCP socket").port(), handle) } /// The mirror of the publisher-reject test, covering the other branch of the role gate: From 33c9c9fb460b0fabb0bfcb628efc3feb74da41d2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:21:19 -0700 Subject: [PATCH 13/45] quest: SETUP AUTHORIZATION TOKEN becomes a standalone m1 quest (#4211) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 1 + quest/m1/auth/README.md | 3 ++ quest/m1/auth/request-token.md | 69 ++++++++++++++++++++++++++++++++++ quest/m1/auth/token-in-band.md | 2 + quest/m1/setup-token.md | 67 +++++++++++++++++++++++++++++++++ quest/m2/cat/README.md | 21 ++++++----- quest/m2/cat/present.md | 2 +- quest/m2/cat/setup-token.md | 67 --------------------------------- quest/m2/cat/verify.md | 2 +- 9 files changed, 156 insertions(+), 78 deletions(-) create mode 100644 quest/m1/auth/request-token.md create mode 100644 quest/m1/setup-token.md delete mode 100644 quest/m2/cat/setup-token.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 0ed96c21b1..8b4591d324 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -53,6 +53,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Wildcard](/quest/m1/wildcard/README.md) - a relay resolves subscriptions against advertised prefixes, a service claims the prefix it could serve and refuses the rest instead of enumerating broadcasts, and the browser player treats a covering claim as availability - [Tooling](/quest/m1/tooling/README.md) - justfiles become a one-line menu over `sh/`, one impact map scopes CI, and every workflow step runs a recipe - [Path patterns](/quest/m1/path-patterns.md) - one matcher for every predicate over broadcast paths: tokens, origins, interest +- [Setup token](/quest/m1/setup-token.md) - a moq-transport SETUP `AUTHORIZATION TOKEN` reaches the accepted handshake and the relay's auth request, so a verifier can run on it - [In-band auth](/quest/m1/auth/README.md) - a session tells its peer what it may publish and subscribe to, unions tokens presented in band, and fails loud on an out-of-scope publish - [Tests under load](/quest/m1/test-flakes.md) - three tests that time out or run out of file descriptors under `just check` are fixed at the cause - [Decoded frame ownership](/quest/m1/decoded-frames.md) - retain moq-video Frames across bindings, with native views or CPU conversion as needed diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index a7de50b497..7042f61feb 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -98,6 +98,9 @@ existing lite-06 ALPN. loses access resets with a dedicated UNAUTHORIZED stream code - [Relay tokens](/quest/m1/auth/relay-refresh.md) - the relay verifies tokens sent in band, unions their grants, and cancels only work that loses access +- [Request tokens](/quest/m1/auth/request-token.md) - an `AUTHORIZATION + TOKEN` on a moq-transport request authorizes that request when the session + grant does not, and REQUEST_UPDATE refreshes it - [moq-transport](/quest/m1/auth/moq-transport.md) - the same exchange as a setup-option extension on draft-17+, specified in a new draft - [Bindings](/quest/m1/auth/bindings.md) - grants and tokens reach every diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md new file mode 100644 index 0000000000..c39486256a --- /dev/null +++ b/quest/m1/auth/request-token.md @@ -0,0 +1,69 @@ +# [M] A token on a request authorizes that request + +## Goal + +A moq-transport peer that is not on moq-dev can present or refresh a +credential the draft-17+ way, with the `AUTHORIZATION TOKEN` parameter +(`0x03`) on SUBSCRIBE, REQUEST_UPDATE, PUBLISH, FETCH, PUBLISH_NAMESPACE, +SUBSCRIBE_NAMESPACE, TRACK_STATUS, or any other request that carries +parameters. A request is authorized by the session's grant first; when that +does not cover it, by the token on the request; with neither it is refused +`UNAUTHORIZED`. The token's grant covers only the request it rode on and +lives exactly as long as that request, and a REQUEST_UPDATE carrying a new +token replaces it, which is how a peer refreshes. It scopes by path, never +by method. Today the strict decoder on newer drafts fails the whole message +on the unknown key, and the legacy drafts silently ignore it. + +## Plan + +- Decode with the [Setup token](/quest/m1/setup-token.md) structure and + rules: `USE_VALUE` yields the token, `REGISTER` is a value since we + advertise no `MAX_AUTH_TOKEN_CACHE_SIZE`, and `DELETE` or `USE_ALIAS` + closes with `PROTOCOL_VIOLATION`. Both decoder families change: the strict + `decode_params!` path, which rejects the key today, and the generic KVP + path the legacy drafts use, which ignores it. +- Fallback only: a request the session grant already covers is served + without verifying its token. Otherwise its token becomes an + `auth::Request` on the session's `auth::Handle`, the seam an AUTH stream's + token takes, marked as belonging to that one request (its path and kind). + The `Grant` the acceptor answers is checked against that request alone, + never joins the session union, and ends when the request ends. With no + `requests()` consumer the default acceptor rejects a non-empty token + `Unsupported`, so an app that does not opt in refuses every request that + needs one. +- The request waits on its token's verdict before it is resolved. A refused + or insufficient token refuses the request with the verdict's code + (`UNAUTHORIZED`, `EXPIRED_AUTH_TOKEN`, `MALFORMED_AUTH_TOKEN`, or + `NOT_SUPPORTED`) through `to_code` for the draft; the session continues. +- Refresh: a REQUEST_UPDATE with a token verifies it the same way and, once + accepted, replaces the request's grant; a refused one leaves the old grant + until it lapses. When a request's grant expires or is revoked, that request + alone ends with `EXPIRED_AUTH_TOKEN` or `UNAUTHORIZED`. A session grant + that shrinks cancels the requests it covered, as for any request, through + [Origin narrowing](/quest/m1/origin-narrowing.md). +- Relay: each such request attaches its own lease through the + `Client::attach` path [Relay tokens](/quest/m1/auth/relay-refresh.md) + builds, once per request, with no sharing across requests carrying the + same bytes; the lease is dropped with the request. The request is resolved + against the origin with the path checked against that lease's grant, not + through the session's scoped origin handle. +- `js/net` mirrors the decode and the default refusal. +- Docs: `doc/bin/relay/auth.md` states the order (session grant, then the + request's token, then `UNAUTHORIZED`), that a request token covers only its + request, and how a peer refreshes with REQUEST_UPDATE. +- Tests: a SUBSCRIBE outside the session grant succeeds with a covering token + and is refused `UNAUTHORIZED` without one; its token grants nothing to a + second SUBSCRIBE; a request inside the session grant never calls the + verifier; a REQUEST_UPDATE token keeps a subscription alive past the old + token's expiry; an expired request token ends only that request; with no + consumer a token-bearing request is refused `Unsupported`; one legacy and + one strict draft, Rust and JS. + +Public API: additive on `moq_net::auth::Request` (the request it belongs +to). Wire: none new; the parameter already exists in every supported draft. + +## Required + +- [Setup token](/quest/m1/setup-token.md) - supplies the token decoder +- [Relay tokens](/quest/m1/auth/relay-refresh.md) - supplies the per-token + lease and `Client::attach` path each request uses diff --git a/quest/m1/auth/token-in-band.md b/quest/m1/auth/token-in-band.md index 6b9077aaf1..2c38ad0345 100644 --- a/quest/m1/auth/token-in-band.md +++ b/quest/m1/auth/token-in-band.md @@ -77,3 +77,5 @@ Additive. token setters sit beside - [moq-transport](/quest/m1/auth/moq-transport.md) - supplies the IETF AUTH exchange the setup-option token pairs with +- [Setup token](/quest/m1/setup-token.md) - supplies `setup::Token` and the + setup-option encoder diff --git a/quest/m1/setup-token.md b/quest/m1/setup-token.md new file mode 100644 index 0000000000..f90d4f5d97 --- /dev/null +++ b/quest/m1/setup-token.md @@ -0,0 +1,67 @@ +# [M] The SETUP AUTHORIZATION TOKEN option reaches the verifier + +## Goal + +A moq-transport peer's SETUP `AUTHORIZATION TOKEN` option (key `0x03`, +draft-14 through the newest supported draft) is decoded by `rs/moq-net` into +a token type and value, exposed on the accepted handshake so an app can run +its own verifier, and forwarded by the relay in `moq_auth::Request` as +`token`, so an auth server can verify it. Today the option is stored as +opaque bytes and ignored. + +Boundaries: SETUP only. The same parameter on SUBSCRIBE, REQUEST_UPDATE, and +every other request is [Request tokens](/quest/m1/auth/request-token.md): a +fallback that authorizes only that request. No client configuration: [Token in +band](/quest/m1/auth/token-in-band.md) owns presenting one. + +## Plan + +- `moq_net::setup::Token { kind: u64, value: Vec }`, `kind` being the + wire Token Type: `Token::OUT_OF_BAND` is `0x0` and `Token::CAT` is the + `0x01` c4m-01 registers. `setup` becomes a public module exporting only + `Token`; the SETUP wire types stay crate-private. Decode the draft-21 + section 8.9 structure: Alias Type `USE_VALUE` yields the token; `REGISTER` + is treated as `USE_VALUE` because we advertise no + `MAX_AUTH_TOKEN_CACHE_SIZE` (the default of 0 makes that the draft's own + rule), so no alias state exists; `DELETE` or `USE_ALIAS` in SETUP closes + with `PROTOCOL_VIOLATION`; an undecodable structure closes with + `KEY_VALUE_FORMATTING_ERROR`. More than one token in SETUP is refused: one + credential per connection is the shape the contract has. +- Encode side: `Parameters` learns to write a `USE_VALUE` token, so [Token + in band](/quest/m1/auth/token-in-band.md) and + [Present](/quest/m2/cat/present.md) have nothing to add on the wire. +- `moq_net::server::Handshake::token() -> Option<&setup::Token>` beside + `path()` and `role()`, carried through the `Legacy` (draft-14 to 16) and + `PeerSetup` (draft-17+) paths; `moq_tokio::server::Request::token()` + forwards it; lite sessions return `None`. +- `moq_auth::Request.token: Option`, + serialized with `serde_with` base64 like the rest of the request. The relay + fills it in `request_for` in `rs/moq-relay/src/auth.rs`, beside the URL + query it already forwards. `moq auth serve` treats kind `0x0` as the JWT + the deployment negotiated out of band, verified exactly like `?jwt=`, and + refuses any other kind naming the code until + [Verify](/quest/m2/cat/verify.md) teaches it `0x01`. A request carrying + both a SETUP token and a `jwt` query is refused naming both. +- `js/net/src/ietf/parameters.ts` mirrors the decode and encode rules. No JS + accept-side API: nothing in `js/net` authorizes an IETF session. +- Docs: `doc/concept` on the IETF binding gains the option; + `doc/lib/rs/moq-auth.md` gains the request field; `doc/bin/relay/auth.md` + says the relay forwards the option and `moq auth serve` verifies a type-0 + token like `?jwt=`. +- Tests: decode and encode round trips for every alias type on every draft + in Rust and JS; `DELETE`/`USE_ALIAS` in SETUP close the session; `REGISTER` + is accepted as a value; two tokens in SETUP are refused; the handshake + exposes the bytes on one legacy and one draft-17+ session and a lite + session reports none; the relay forwards the bytes to a wiremock auth + server byte for byte; `moq auth serve` admits a type-0 JWT, refuses an + unknown kind, and refuses a token plus `?jwt=`. + +Public API: additive on `moq-net`, `moq-tokio`, `moq-auth`, and `js/net`. +Wire: none new; the option already exists in every supported draft. + +## Related + +- [Token in band](/quest/m1/auth/token-in-band.md) - writes the same option + from the client side with the shared `setup::Token` +- [Common Access Tokens](/quest/m2/cat/README.md) - verifies and presents a + CAT through this option diff --git a/quest/m2/cat/README.md b/quest/m2/cat/README.md index 53ae09c224..8460cd1520 100644 --- a/quest/m2/cat/README.md +++ b/quest/m2/cat/README.md @@ -15,10 +15,10 @@ the URL, and the JWT stays the URL credential. Boundaries decided while planning: -- SETUP only. A token on SUBSCRIBE, PUBLISH, FETCH, or any other request is - refused; per-operation authorization is not planned. The relay authorizes - per session through origin scopes, and a token that arrives after SETUP is - the [in-band auth](/quest/m1/auth/README.md) line's problem. +- SETUP only. Per-operation authorization is not planned: the relay + authorizes by path through origin scopes. A token on any other request is + [Request tokens](/quest/m1/auth/request-token.md)'s path-scoped fallback + for that request, and a CAT's per-message scope is never enforced. - A token whose `moqt` scope restricts the track name is refused naming the token. Grants are broadcast-path patterns and tracks are not scoped. - Core CWT claims plus `moqt` and `moqt-reval` are enforced; any other CAT @@ -41,7 +41,8 @@ Boundaries decided while planning: ## Plan -Order: the wire first so a token reaches the auth server; verification; +Order: the wire first so a token reaches the auth server, which is the +standalone [Setup token](/quest/m1/setup-token.md) quest; verification; then our clients present one. Everything rides `moq_auth::Request` and `moq auth serve`, which shipped on dev. The JWT types sit at the crate root; the verify quest moves them under `moq_auth::jwt` so `cat` is a sibling @@ -49,18 +50,20 @@ module rather than a set of prefixed names. ## Quests -- [Setup token](/quest/m2/cat/setup-token.md) - the SETUP `AUTHORIZATION - TOKEN` option is decoded on both IETF stacks, reaches `moq_auth::Request` - as `token`, and is refused on every other message - [Verify](/quest/m2/cat/verify.md) - `moq_auth::cat` turns a CAT into a grant and `moq auth serve` admits one; `moq auth sign|verify` mint and check the format - [Present](/quest/m2/cat/present.md) - a CAT is one kind of configured token, riding the SETUP option the in-band token quest already writes +## Required + +- [Setup token](/quest/m1/setup-token.md) - the SETUP option reaches + `moq_auth::Request` as `token` + ## Related - [In-band auth](/quest/m1/auth/README.md) - credentials presented after - SETUP, which this line refuses on the IETF wire; its [Token in + SETUP, including on IETF requests; its [Token in band](/quest/m1/auth/token-in-band.md) quest owns the client token configuration a CAT joins diff --git a/quest/m2/cat/present.md b/quest/m2/cat/present.md index b342d69d32..cb318d20e1 100644 --- a/quest/m2/cat/present.md +++ b/quest/m2/cat/present.md @@ -34,7 +34,7 @@ Public API: additive on `moq-tokio` and `js/net`. Wire: none. ## Required -- [Setup token](/quest/m2/cat/setup-token.md) - the server-side exposure +- [Setup token](/quest/m1/setup-token.md) - the server-side exposure and the shared `setup::Token` - [Verify](/quest/m2/cat/verify.md) - the server that admits the token the end-to-end test presents diff --git a/quest/m2/cat/setup-token.md b/quest/m2/cat/setup-token.md deleted file mode 100644 index de68919466..0000000000 --- a/quest/m2/cat/setup-token.md +++ /dev/null @@ -1,67 +0,0 @@ -# [M] The SETUP AUTHORIZATION TOKEN option reaches the auth server - -## Goal - -A moq-transport peer's SETUP `AUTHORIZATION TOKEN` option (type `0x03`, -draft-14 through the newest supported draft) is decoded by `rs/moq-net` and -`js/net` into a token type and value, exposed on the accepted request, and -forwarded by the relay in `moq_auth::Request` as `token`, so an auth server -can verify it. The same parameter on any other message is refused. Today the -option is stored as opaque bytes and ignored, and the message parameter fails -decode as an unknown key. - -## Plan - -- `moq_net::setup::Token { kind: u64, value: Vec }`, `kind` being the - wire Token Type: `Token::OUT_OF_BAND` is `0x0` and `Token::CAT` is the - `0x01` c4m-01 registers. This is the type [Token in - band](/quest/m1/auth/token-in-band.md) writes for a configured JWT - (`USE_VALUE`, type 0), so the two quests share one struct and one encoder; - whichever lands first adds them. Decode the draft-21 section 8.9 - structure: Alias Type `USE_VALUE` yields the token; `REGISTER` is treated - as `USE_VALUE` because we advertise no `MAX_AUTH_TOKEN_CACHE_SIZE` (the - default of 0 makes that the draft's own rule), so no alias state exists; - `DELETE` or `USE_ALIAS` in SETUP closes with `PROTOCOL_VIOLATION`; an - undecodable structure closes with `KEY_VALUE_FORMATTING_ERROR`. More than - one token in SETUP is refused: one credential per connection is the shape - the contract has. -- Encode side: `Parameters` learns to write a `USE_VALUE` token so - [Present](/quest/m2/cat/present.md) has nothing to add on the wire. -- Message parameter `0x03` on SUBSCRIBE, REQUEST_UPDATE, PUBLISH, FETCH, - PUBLISH_NAMESPACE, SUBSCRIBE_NAMESPACE, TRACK_STATUS, and every other - message whose decoder reads parameters decodes and is refused with the - request's error path (`Unsupported`, mapped through `to_code` for the - draft) naming the parameter, so the session survives and the log says why. - Both decoder families change: the strict `decode_params!` path on newer - drafts, which rejects the key today, and the generic KVP path the legacy - drafts use, which keeps an odd key and lets the caller ignore it. -- `moq_net::Request::token() -> Option<&setup::Token>` on the accepted - request, carried through the `Legacy` (draft-14 to 16) and `PeerSetup` - (draft-17+) paths; `moq_tokio::server::Request` forwards it; lite sessions - return `None`. -- `moq_auth::Request.token: Option`, - serialized with `serde_with` base64 like the rest of the request. The relay - fills it where it builds the request, beside the URL query it already - forwards. `moq auth serve` treats kind `0x0` as the JWT the deployment - negotiated out of band, verified exactly like `?jwt=`, refuses a kind it - does not know naming the code, and hands `0x01` to - [Verify](/quest/m2/cat/verify.md). A request carrying both a SETUP token - and a `jwt` query is refused naming both. -- `js/net/src/ietf/parameters.ts` mirrors the decode and encode, and the - `SetupOption.AuthorizationToken` handling in `handshake.ts`; the JS accept - side exposes the token on its request the same way. -- Docs: `doc/concept` on the IETF binding gains the option and the refusal; - `doc/lib/rs/moq-auth.md` gains the request field. -- Tests: decode and encode round trips for every alias type on every draft - in Rust and JS; `DELETE`/`USE_ALIAS` in SETUP close the session; - `REGISTER` is accepted as a value; a token on SUBSCRIBE is refused and the - session continues, on one legacy draft and one strict draft; the relay forwards the bytes to a wiremock auth server - byte for byte; a lite session reports none. - -Public API: additive on `moq-net`, `moq-tokio`, `moq-auth`, and `js/net`. -Wire: none new; the option already exists in every supported draft. - -## Related - -- [Token in band](/quest/m1/auth/token-in-band.md) - writes the same option - from the client side with the shared `setup::Token` diff --git a/quest/m2/cat/verify.md b/quest/m2/cat/verify.md index 1966f630c3..29a03c3126 100644 --- a/quest/m2/cat/verify.md +++ b/quest/m2/cat/verify.md @@ -85,5 +85,5 @@ gain flags. Wire: none. ## Required -- [Setup token](/quest/m2/cat/setup-token.md) - the token reaches the +- [Setup token](/quest/m1/setup-token.md) - the token reaches the server's request From a0dc19748384e6c5a556dfdd250e6137ee3eae80 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:30:45 -0700 Subject: [PATCH 14/45] test: bind :0 up front instead of probing for a free port (#4212) Co-authored-by: Claude Opus 5.5 --- rs/moq-hls/src/server/routes.rs | 86 +++++++++++++--------------- rs/moq-relay/tests/goaway_cluster.rs | 78 ++++++++----------------- rs/moq-srt/src/dial.rs | 19 +++--- rs/moq-srt/src/server.rs | 36 ++++++++---- rs/moq-tokio/src/connection.rs | 74 +++++++++++++----------- rs/moq-tokio/src/server.rs | 31 +--------- 6 files changed, 138 insertions(+), 186 deletions(-) diff --git a/rs/moq-hls/src/server/routes.rs b/rs/moq-hls/src/server/routes.rs index a01195b488..324fdd80f8 100644 --- a/rs/moq-hls/src/server/routes.rs +++ b/rs/moq-hls/src/server/routes.rs @@ -484,59 +484,51 @@ mod tests { } async fn lite_pair_pub(config: impl Into) -> LitePair { - use std::net::TcpListener; - let lite: moq_net::Version = "moq-lite-05".parse().expect("lite version"); let pub_origin = moq_tokio::origin::spawn_config(config.into()); let sub_origin = moq_tokio::origin::spawn(); - for _ in 0..20 { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - - let mut listen = moq_tokio::listen::Config::default(); - listen.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("addr")); - listen.version = vec![lite]; - let Ok(server) = listen.init(Default::default()) else { - continue; - }; - let Ok(mut server) = server.listen().await else { - continue; - }; - - let pub_for_accept = pub_origin.clone(); - let accept = tokio::spawn(async move { - let request = server.accept().await.expect("accept"); - let session = request - .with_publisher(&pub_for_accept) - .ok() - .await - .expect("publisher session"); - let _ = session.closed().await; - }); - - let mut connect = moq_tokio::connect::Config::default(); - connect.version = vec![lite]; - let client = connect - .init(Default::default()) - .expect("client") - .with_subscriber(sub_origin.clone()) - .with_reconnect(false); - let url: url::Url = format!("tcp://127.0.0.1:{port}/").parse().expect("url"); - let connection = tokio::time::timeout(TIMEOUT, client.connect(url).established()) + let mut listen = moq_tokio::listen::Config::default(); + listen.tcp.bind = Some("127.0.0.1:0".parse().expect("addr")); + listen.version = vec![lite]; + let mut server = listen + .init(Default::default()) + .expect("server") + .listen() + .await + .expect("listen"); + let addr = server.tcp_local_addr().expect("tcp listener bound"); + + let pub_for_accept = pub_origin.clone(); + let accept = tokio::spawn(async move { + let request = server.accept().await.expect("accept"); + let session = request + .with_publisher(&pub_for_accept) + .ok() .await - .expect("connect timed out") - .expect("connect"); - - return LitePair { - pub_origin, - sub_origin, - _connection: connection, - accept, - }; + .expect("publisher session"); + let _ = session.closed().await; + }); + + let mut connect = moq_tokio::connect::Config::default(); + connect.version = vec![lite]; + let client = connect + .init(Default::default()) + .expect("client") + .with_subscriber(sub_origin.clone()) + .with_reconnect(false); + let url: url::Url = format!("tcp://{addr}/").parse().expect("url"); + let connection = tokio::time::timeout(TIMEOUT, client.connect(url).established()) + .await + .expect("connect timed out") + .expect("connect"); + + LitePair { + pub_origin, + sub_origin, + _connection: connection, + accept, } - panic!("could not bind a free TCP port after 20 attempts"); } async fn oneshot(app: axum::Router, uri: &str) -> axum::http::Response { diff --git a/rs/moq-relay/tests/goaway_cluster.rs b/rs/moq-relay/tests/goaway_cluster.rs index c2b6a061c5..d9cb9511c6 100644 --- a/rs/moq-relay/tests/goaway_cluster.rs +++ b/rs/moq-relay/tests/goaway_cluster.rs @@ -7,7 +7,6 @@ //! of the cluster origin observes contiguous groups and no unannounce. use std::collections::BTreeSet; -use std::net::TcpListener; use std::time::Duration; use moq_relay::{ @@ -54,23 +53,19 @@ where .expect("test thread panicked"); } -/// Bind a stream-only moq server to a free loopback TCP port, retrying if the -/// port is claimed in the window between the free-port probe and the real bind. -/// Returns the chosen port and the initialized server. Avoids the spurious -/// `init()` panic that a probe/drop/bind race can cause under parallel tests. -fn bind_free_tcp_server() -> (u16, moq_tokio::Server) { - for _ in 0..20 { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - - let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); - if let Ok(server) = config.init(Default::default()) { - return (port, server); - } - } - panic!("could not bind a free TCP port after 20 attempts"); +/// A stream-only moq listener on an ephemeral loopback TCP port, already +/// accepting, so a dial can follow immediately. Returns the port and listener. +async fn listen_tcp() -> (u16, moq_tokio::Listener) { + let mut config = moq_tokio::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); + let server = config + .init(Default::default()) + .expect("server init") + .listen() + .await + .expect("listen"); + let port = server.tcp_local_addr().expect("tcp listener bound").port(); + (port, server) } #[test] @@ -98,8 +93,7 @@ async fn drain_session_with_zero_timeout_closes_at_once_inner() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let origin = moq_tokio::origin::spawn(); - let (port, mut accepted, _handle) = spawn_upstream(origin); - wait_listening(port).await; + let (port, mut accepted, _handle) = spawn_upstream(origin).await; let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); @@ -132,19 +126,18 @@ fn cluster_reconnects_on_empty_uri_goaway() { /// A fake sibling upstream: a stream-only moq server publishing `origin`'s /// broadcasts to whoever connects. Returns its port, a receiver yielding each /// accepted [`moq_net::Session`] (so the test can drain it), and the task. -fn spawn_upstream( +async fn spawn_upstream( origin: moq_net::origin::Producer, ) -> ( u16, tokio::sync::mpsc::UnboundedReceiver, tokio::task::JoinHandle<()>, ) { - let (port, server) = bind_free_tcp_server(); + let (port, mut server) = listen_tcp().await; let (accepted_tx, accepted_rx) = tokio::sync::mpsc::unbounded_channel(); let handle = tokio::spawn(async move { - let mut server = server.listen().await.expect("listen"); while let Some(request) = server.accept().await { // Serve the shared origin bidirectionally, like a relay peer would. let scratch = moq_tokio::origin::spawn(); @@ -162,21 +155,6 @@ fn spawn_upstream( (port, accepted_rx, handle) } -/// Wait for the upstream's TCP listener to come up. -async fn wait_listening(port: u16) { - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { - break; - } - assert!( - std::time::Instant::now() < deadline, - "upstream never became ready on port {port}" - ); - tokio::time::sleep(Duration::from_millis(25)).await; - } -} - /// An upstream GOAWAY with a redirect migrates the cluster dial to the sibling. /// The draining session keeps serving through the handover window, and the path /// never retracts on the cluster origin (route re-pricing and the sibling's @@ -191,10 +169,8 @@ async fn cluster_migrates_on_upstream_goaway_inner() { broadcast.announce(Default::default()).expect("create broadcast"); let track = broadcast.create_track("video", None).expect("create track"); - let (port_a, mut accepted_a, _handle_a) = spawn_upstream(upstream_origin.clone()); - let (port_b, mut accepted_b, _handle_b) = spawn_upstream(upstream_origin.clone()); - wait_listening(port_a).await; - wait_listening(port_b).await; + let (port_a, mut accepted_a, _handle_a) = spawn_upstream(upstream_origin.clone()).await; + let (port_b, mut accepted_b, _handle_b) = spawn_upstream(upstream_origin.clone()).await; // ── the relay cluster under test, dialing sibling A ───────────── let mut client_config = moq_tokio::connect::Config::default(); @@ -306,8 +282,7 @@ async fn spawn_relay_with_upstream( ) -> (u16, tokio::task::JoinHandle<()>) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let (port, server) = bind_free_tcp_server(); - let mut server = server.listen().await.expect("listen"); + let (port, mut server) = listen_tcp().await; // Fully public auth: any no-JWT stream client gets the whole root. let mut auth_config = auth::Config::default(); @@ -352,7 +327,6 @@ async fn spawn_relay_with_upstream( } }); - wait_listening(port).await; (port, handle) } @@ -388,8 +362,7 @@ async fn cluster_diamond_goaway_seamless_failover_inner() { broadcast.announce(Default::default()).expect("create broadcast"); let track = broadcast.create_track("video", None).expect("create track"); - let (top_port, mut top_accepted, _top_handle) = spawn_upstream(top_origin.clone()); - wait_listening(top_port).await; + let (top_port, mut top_accepted, _top_handle) = spawn_upstream(top_origin.clone()).await; let top_url = format!("tcp://127.0.0.1:{top_port}/"); // ── MID-B: full relay clustered to TOP, up for the whole test ─────── @@ -420,8 +393,7 @@ async fn cluster_diamond_goaway_seamless_failover_inner() { .await .expect("TOP accept channel closed"); - let (mid_a_port, mut mid_a_accepted, _mid_a_handle) = spawn_upstream(mid_a_origin.clone()); - wait_listening(mid_a_port).await; + let (mid_a_port, mut mid_a_accepted, _mid_a_handle) = spawn_upstream(mid_a_origin.clone()).await; let mid_a_url = format!("tcp://127.0.0.1:{mid_a_port}/"); // ── BOTTOM: full relay clustered to MID-A ─────────────────────────── @@ -660,8 +632,7 @@ async fn cluster_reconnects_on_empty_uri_goaway_inner() { broadcast.announce(Default::default()).expect("create broadcast"); let track = broadcast.create_track("video", None).expect("create track"); - let (port, mut accepted, _handle) = spawn_upstream(upstream_origin.clone()); - wait_listening(port).await; + let (port, mut accepted, _handle) = spawn_upstream(upstream_origin.clone()).await; let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); @@ -775,11 +746,10 @@ async fn goaway_handover_is_enforced_while_the_replacement_dial_hangs_inner() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let upstream_origin = moq_tokio::origin::spawn(); - let (port, mut accepted, _handle) = spawn_upstream(upstream_origin); - wait_listening(port).await; + let (port, mut accepted, _handle) = spawn_upstream(upstream_origin).await; // Accepts the connection, then never speaks MoQ: the dial hangs in the handshake. - let black_hole = TcpListener::bind("127.0.0.1:0").expect("bind black hole"); + let black_hole = std::net::TcpListener::bind("127.0.0.1:0").expect("bind black hole"); let black_hole_port = black_hole.local_addr().expect("local addr").port(); let _black_hole = std::thread::spawn(move || { // Hold every accepted socket open and idle for the life of the test. diff --git a/rs/moq-srt/src/dial.rs b/rs/moq-srt/src/dial.rs index 95b51faf4d..6c51666405 100644 --- a/rs/moq-srt/src/dial.rs +++ b/rs/moq-srt/src/dial.rs @@ -165,11 +165,11 @@ mod tests { use super::*; use crate::server::{Reject, Request, Server}; - /// Grab a free UDP port by binding `:0` and releasing it. Racy in principle, but - /// the window before the SRT server rebinds it is tiny; good enough for a test. - async fn free_udp_addr() -> SocketAddr { - let sock = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); - sock.local_addr().unwrap() + /// An SRT server on an ephemeral loopback port. + async fn loopback() -> (Server, SocketAddr) { + let server = Server::bind("127.0.0.1:0".parse().unwrap(), None).await.unwrap(); + let addr = server.local_addr(); + (server, addr) } /// Loopback: dial the crate's own server with `m=publish`. The server classifies it @@ -179,8 +179,7 @@ mod tests { /// integration coverage; the TS bridge itself is shared with the tested server path.) #[tokio::test] async fn publish_caller_connects_and_routes() { - let addr = free_udp_addr().await; - let mut server = Server::bind(addr, None).await.unwrap(); + let (mut server, addr) = loopback().await; // Server accepts the publish so the caller's handshake completes; it ingests into // a throwaway origin and returns the routed direction + resource. @@ -219,8 +218,7 @@ mod tests { /// routes to a server [`Request::Subscribe`]. #[tokio::test] async fn request_caller_connects_and_routes() { - let addr = free_udp_addr().await; - let mut server = Server::bind(addr, None).await.unwrap(); + let (mut server, addr) = loopback().await; // Empty origin: the subscribe accept parks waiting for the broadcast, which is // fine -- the caller still connects, and the test aborts the wait. @@ -260,8 +258,7 @@ mod tests { /// caller's handshake completes stops the listener that still owes it the /// rejection packet, and the caller times out instead. async fn rejected(mode: Mode, reason: Reject, code: i32) { - let addr = free_udp_addr().await; - let mut server = Server::bind(addr, None).await.unwrap(); + let (mut server, addr) = loopback().await; let client = Client::new(addr, "cam0"); let (_, err) = tokio::join!( diff --git a/rs/moq-srt/src/server.rs b/rs/moq-srt/src/server.rs index a3c2dd5439..ddbab0d555 100644 --- a/rs/moq-srt/src/server.rs +++ b/rs/moq-srt/src/server.rs @@ -182,6 +182,7 @@ pub struct Server { /// Held to keep the listener (and its UDP socket) alive for the server's lifetime. _listener: SrtListener, incoming: SrtIncoming, + local_addr: SocketAddr, /// The negotiated SRT receive latency, reused as the egress skip threshold on /// each [`Subscribe`] (see [`crate::ts::Subscriber::new`]). latency: Duration, @@ -195,18 +196,34 @@ impl Server { /// threshold for [`Subscribe`] requests. pub async fn bind(addr: SocketAddr, latency: impl Into>) -> Result { let latency = latency.into().unwrap_or(DEFAULT_LATENCY); + + // srt-tokio refuses to listen on port 0 and never reports the port it bound, + // so bind the UDP socket first (with srt-tokio's own buffer sizing) and hand + // it over under its resolved address. + let mut options = SocketOptions::default(); + options.connect.local = addr; + let socket = srt_tokio::bind_socket(&options).await?; + let local_addr = socket.local_addr()?; + let (listener, incoming) = SrtListener::builder() + .socket(socket) .latency(latency) .set(configure_buffers) - .bind(addr) + .bind(local_addr) .await?; Ok(Self { _listener: listener, incoming, + local_addr, latency, }) } + /// The address the listener is bound to, with any `:0` port resolved. + pub fn local_addr(&self) -> SocketAddr { + self.local_addr + } + /// Wait for the next connection that wants to publish or subscribe. /// /// Connections whose stream id can't be routed (no usable resource name) are @@ -582,7 +599,6 @@ fn parse_stream_id(stream_id: Option<&StreamId>) -> Option<(String, ConnectionMo mod tests { use super::*; use bytes::Bytes; - use std::net::SocketAddr; use std::time::Duration; #[test] @@ -623,13 +639,12 @@ mod tests { /// (see [`configure_buffers`]). #[tokio::test] async fn accepted_socket_sends_a_burst_larger_than_srt_tokio_default() { - let probe = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); - let addr: SocketAddr = probe.local_addr().unwrap(); - drop(probe); - // TSBPD holds every payload for the negotiated latency before releasing it, so // ask for a short one: this asserts buffering, not delay. - let mut server = Server::bind(addr, Duration::from_millis(50)).await.unwrap(); + let mut server = Server::bind("127.0.0.1:0".parse().unwrap(), Duration::from_millis(50)) + .await + .unwrap(); + let addr = server.local_addr(); let caller = tokio::spawn(async move { SrtSocket::builder() .call(addr, Some("#!::r=buffer-test,m=request")) @@ -881,11 +896,8 @@ mod tests { } } - let probe = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); - let addr: SocketAddr = probe.local_addr().unwrap(); - drop(probe); - - let mut server = Server::bind(addr, LATENCY).await.unwrap(); + let mut server = Server::bind("127.0.0.1:0".parse().unwrap(), LATENCY).await.unwrap(); + let addr = server.local_addr(); let caller = tokio::spawn(async move { SrtSocket::builder() .call(addr, Some("#!::r=rewind,m=request")) diff --git a/rs/moq-tokio/src/connection.rs b/rs/moq-tokio/src/connection.rs index 13f3fb4e2a..2bae530190 100644 --- a/rs/moq-tokio/src/connection.rs +++ b/rs/moq-tokio/src/connection.rs @@ -1729,43 +1729,47 @@ mod tests { value.parse().expect("valid url") } - /// A loopback TCP port with nothing on it, which refuses instantly. + /// A loopback TCP port with nothing listening on it, which refuses instantly. /// /// A dead address that *refuses* rather than black-holes is what keeps these /// tests fast and deterministic: the walk itself is what's under test, and /// bounding a black-holed attempt is covered by /// [`only_a_candidate_with_a_fallback_is_bounded`] as a pure function. + /// + /// The returned socket is bound but never listens, so the port refuses while + /// staying reserved: no other listener (or an ephemeral self-connect) can take + /// it mid-test. Hold it for as long as the URL is dialed. #[cfg(feature = "tcp")] - fn refused() -> Url { - let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - url(&format!("tcp://127.0.0.1:{port}/")) + fn refused() -> (tokio::net::TcpSocket, Url) { + let socket = tokio::net::TcpSocket::new_v4().expect("tcp socket"); + socket.bind("127.0.0.1:0".parse().unwrap()).expect("bind"); + let addr = socket.local_addr().expect("local addr"); + (socket, url(&format!("tcp://{addr}/"))) } - /// A bound stream listener, the URL that reaches it, and a client for it. + /// A bound stream listener and the URL that reaches it. + #[cfg(feature = "tcp")] + async fn live() -> (crate::Listener, Url) { + let mut config = crate::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); + let listener = config + .init(Default::default()) + .expect("build server") + .listen() + .await + .expect("listen"); + let addr = listener.tcp_local_addr().expect("tcp listener bound"); + (listener, url(&format!("tcp://{addr}/"))) + } + + /// A client that trusts the self-signed [`live`] listener. #[cfg(feature = "tcp")] - fn pair() -> (crate::Server, Url, Client) { + fn client() -> Client { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - for _ in 0..20 { - let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - - let mut config = crate::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("valid address")); - let Ok(server) = config.init(Default::default()) else { - continue; - }; - - let mut config = crate::connect::Config::default(); - config.tls.insecure = Some(true); - let client = config.init(Default::default()).expect("build client"); - - return (server, url(&format!("tcp://127.0.0.1:{port}/")), client); - } - panic!("could not bind a free TCP port after 20 attempts"); + let mut config = crate::connect::Config::default(); + config.tls.insecure = Some(true); + config.init(Default::default()).expect("build client") } fn shared() -> Shared { @@ -1783,11 +1787,13 @@ mod tests { #[cfg(feature = "tcp")] #[tokio::test] async fn dial_any_walks_past_the_dead_addresses() { - let (server, live, client) = pair(); - let mut server = server.listen().await.expect("listen"); + let client = client(); + let (mut server, live) = live().await; tokio::spawn(async move { while server.accept().await.is_some() {} }); - let addrs = Addrs::collect([refused(), refused(), live.clone()]).expect("not empty"); + let (_a, dead_a) = refused(); + let (_b, dead_b) = refused(); + let addrs = Addrs::collect([dead_a, dead_b, live.clone()]).expect("not empty"); let shared = shared(); let mut draining = None; @@ -1805,9 +1811,11 @@ mod tests { #[cfg(feature = "tcp")] #[tokio::test] async fn dial_any_reports_failure_when_nothing_answers() { - let (_server, _live, client) = pair(); + let client = client(); - let addrs = Addrs::collect([refused(), refused()]).expect("not empty"); + let (_a, dead_a) = refused(); + let (_b, dead_b) = refused(); + let addrs = Addrs::collect([dead_a, dead_b]).expect("not empty"); let shared = shared(); let mut draining = None; @@ -1833,8 +1841,8 @@ mod tests { async fn dialing_never_logs_the_credential() { const SECRET: &str = "b91d7fe20c4a"; - let (_server, _live, client) = pair(); - let mut target = refused(); + let client = client(); + let (_dead, mut target) = refused(); target.set_path(&format!("/.cluster/{SECRET}")); let addrs = Addrs::new(target); diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index d71a05f658..dacd68c671 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -1506,30 +1506,6 @@ mod tests { std::net::TcpListener::bind(("127.0.0.1", port)).expect("the tcp port must be free again"); } - /// Closing consumes the listener and immediately releases its stream sockets. - #[cfg(feature = "tcp")] - #[tokio::test] - async fn close_releases_stream_listeners() { - let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe"); - let addr = probe.local_addr().expect("probe addr"); - drop(probe); - - let mut config = crate::listen::Config::default(); - config.tcp.bind = Some(addr); - let listener = Config { - listen: config, - ..Default::default() - } - .init() - .expect("stream-only server") - .listen() - .await - .expect("listen"); - - listener.close().await; - std::net::TcpListener::bind(addr).expect("close must release the tcp port"); - } - /// The stream listeners must hand accepted sessions to the *configured* /// [`moq_net::Server`]. [`Server::serve_publish`] sets the publisher there /// rather than on the request, so a session that handshakes against any other @@ -1632,12 +1608,8 @@ mod tests { #[cfg(feature = "tcp")] #[tokio::test] async fn close_releases_stream_listener_socket() { - let probe = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = probe.local_addr().unwrap(); - drop(probe); - let mut config = crate::listen::Config::default(); - config.tcp.bind = Some(addr); + config.tcp.bind = Some("127.0.0.1:0".parse().unwrap()); let server = Config { listen: config, ..Default::default() @@ -1645,6 +1617,7 @@ mod tests { .init() .expect("stream-only server"); let listener = server.listen().await.expect("listen"); + let addr = listener.tcp_local_addr().expect("tcp listener bound"); assert!(tokio::net::TcpListener::bind(addr).await.is_err(), "listener is bound"); listener.close().await; From ad48f03b6e3965602ced89b8a5a3f4fdb0613a4a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:31:56 -0700 Subject: [PATCH 15/45] fix(net): resolve a lite fetch only once the publisher answers (#4164) Co-authored-by: Claude Opus 5.5 --- doc/bin/cli.md | 4 ++-- doc/bin/relay/http.md | 2 +- quest/m1/README.md | 1 - quest/m1/fetch-missing-group.md | 18 ----------------- rs/moq-cli/src/fetch.rs | 19 +++++++++--------- rs/moq-net/src/coding/reader.rs | 4 ++-- rs/moq-net/src/lite/subscriber.rs | 32 +++++++++++++++++++++++++++++++ rs/moq-net/src/model/group.rs | 6 ++++-- rs/moq-net/src/model/track.rs | 5 +++-- rs/moq-relay/src/fetch.rs | 5 +++-- rs/moq-relay/src/web.rs | 5 ++++- 11 files changed, 61 insertions(+), 40 deletions(-) delete mode 100644 quest/m1/fetch-missing-group.md diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 5f08497161..8aca668760 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -187,8 +187,8 @@ zero-based `frame` and padded standard base64. `` is the literal track name. `/fetch` splits its path on the last `/`, so the two agree only for names without one. Fetch only dials `--connect`, and refuses a listener or cluster flag. It gives up after 30 seconds, as `/fetch` -does, and exits non-zero when the broadcast or group is not found, the relay -refuses, or the deadline passes. +does, and exits non-zero when the broadcast or group is not found (before +writing anything), the relay refuses, or the deadline passes. ## Multiple stages diff --git a/doc/bin/relay/http.md b/doc/bin/relay/http.md index 28cad8de5c..80460efb30 100644 --- a/doc/bin/relay/http.md +++ b/doc/bin/relay/http.md @@ -13,7 +13,7 @@ operational ones that must stay private. | Endpoint | Returns | | --- | --- | | `GET /announced/` | Broadcasts announced under the prefix. | -| `GET /fetch//?group=N` | One group from the cache, the latest by default. Useful for catch-up and debugging. | +| `GET /fetch//?group=N` | One group from the cache, the latest by default, or `404` if the track has no such group. Useful for catch-up and debugging. | | `GET /certificate.sha256` | The fingerprint of the first configured TLS certificate, for pinning a self-signed dev certificate. | | `GET /health` | `200 ok`, unauthenticated, for load balancers. | diff --git a/quest/m1/README.md b/quest/m1/README.md index 8b4591d324..6c95748db6 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -17,7 +17,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. ## Quests -- [Missing fetch group](/quest/m1/fetch-missing-group.md) - HTTP /fetch answers 404 and `moq fetch` fails cleanly for a group the track lacks - [JS fetch answer](/quest/m1/js-fetch-answer.md) - js/net's lite fetch settles on the publisher's answer, and a JS publisher's miss resets with NotFound - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts - [Announce compression](/quest/m1/announce-compression.md) - a lite-07 announce reuses the path head and hop-chain tail of a live announcement on its stream instead of resending them diff --git a/quest/m1/fetch-missing-group.md b/quest/m1/fetch-missing-group.md deleted file mode 100644 index e144de69aa..0000000000 --- a/quest/m1/fetch-missing-group.md +++ /dev/null @@ -1,18 +0,0 @@ -# [S] Fetching a missing group fails before any output - -## Goal - -HTTP `/fetch//?group=N` answers 404 for a group the track -does not have, and `moq fetch --group N` exits with a clean "not found" -before writing anything. Today the lookup hands back a group consumer that -only fails on its first frame read, so the endpoint answers 200 with a -cut-off body. - -## Plan - -- Resolve whether the group exists before the response starts, in the - shared lookup the relay and the CLI both use. Where the check belongs (the - relay helper or a `moq-net` track consumer method) is the implementer's - call; propose it in the PR. -- Test: a missing group is a 404 over HTTP and a non-zero `moq fetch` exit - with no stdout; an existing group is byte-identical to today. diff --git a/rs/moq-cli/src/fetch.rs b/rs/moq-cli/src/fetch.rs index 11cfc90258..fad7b7862b 100644 --- a/rs/moq-cli/src/fetch.rs +++ b/rs/moq-cli/src/fetch.rs @@ -86,7 +86,6 @@ async fn fetch( None => format!("no group of `{}` found", args.track), })?; - // A missing sequence can resolve to a group that fails on its first read. let sequence = group.sequence; let mut index = 0; while let Some(frame) = group @@ -209,13 +208,13 @@ mod tests { (result, out) } - /// The relay's HTTP `/fetch` body for the same group. - async fn curl(&self, query: &str) -> Vec { + /// The relay's HTTP `/fetch` status and body for the same group. + async fn curl(&self, query: &str) -> (u16, Vec) { let response = reqwest::get(format!("http://{}/fetch/demo/data{query}", self.http)) .await .expect("HTTP fetch"); - assert_eq!(response.status(), 200); - response.bytes().await.expect("HTTP body").to_vec() + let status = response.status().as_u16(); + (status, response.bytes().await.expect("HTTP body").to_vec()) } } @@ -254,7 +253,7 @@ mod tests { let (result, out) = fixture.fetch(&["data", "--group", "1"], TIMEOUT).await; result.expect("fetch"); assert_eq!(out, frames(1).concat()); - assert_eq!(out, fixture.curl("?group=1").await); + assert_eq!(fixture.curl("?group=1").await, (200, out)); } /// No `--group` reads the newest group, as `/fetch` does by default. @@ -266,9 +265,11 @@ mod tests { let (result, out) = fixture.fetch(&["data"], TIMEOUT).await; result.expect("fetch"); assert_eq!(out, frames(2).concat()); - assert_eq!(out, fixture.curl("").await); + assert_eq!(fixture.curl("").await, (200, out)); } + /// A missing sequence fails the lookup itself, before any output, as `/fetch` + /// answers 404 rather than starting a body. #[tokio::test] async fn a_missing_sequence_fails() { let _env = EnvGuard::clear(ENV); @@ -276,9 +277,9 @@ mod tests { let (result, out) = fixture.fetch(&["data", "--group", "99"], TIMEOUT).await; let err = result.expect_err("group 99 does not exist"); - let err = format!("{err:#}"); - assert!(err.contains("group 99") && err.contains("not found"), "{err}"); + assert_eq!(err.to_string(), "group 99 of `data` not found", "{err:#}"); assert!(out.is_empty()); + assert_eq!(fixture.curl("?group=99").await, (404, Vec::new())); } /// A track with no group never resolves "newest", so the deadline ends it. diff --git a/rs/moq-net/src/coding/reader.rs b/rs/moq-net/src/coding/reader.rs index 2005bd11ae..12bf76bf5f 100644 --- a/rs/moq-net/src/coding/reader.rs +++ b/rs/moq-net/src/coding/reader.rs @@ -227,8 +227,8 @@ impl Reader { Poll::Ready(Ok(())) } - /// Poll for whether data is available in the buffer or stream. - fn poll_has_more(&mut self, cx: &mut Context<'_>) -> Poll> { + /// Poll for whether data is available in the buffer or stream: `false` once it finishes. + pub(crate) fn poll_has_more(&mut self, cx: &mut Context<'_>) -> Poll> { if !self.buffer.is_empty() { return Poll::Ready(Ok(true)); } diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index c0ab106215..ca7bff6ba4 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -3507,6 +3507,12 @@ enum FetchRunState { stream: Stream, frame_start: u64, }, + /// Flushed; waiting for the publisher to answer before accepting. + Answer { + request: Option, + stream: Stream, + frame_start: u64, + }, Ingest { stream: Stream, producer: group::Producer, @@ -3617,7 +3623,33 @@ impl kio::Task for FetchServeRun { else { unreachable!() }; + self.state = FetchRunState::Answer { + request, + stream, + frame_start, + }; + } + FetchRunState::Answer { stream, .. } => { + // Lite has no FETCH_OK: a publisher without the group resets the + // stream instead. Accepting before the first byte (or a FIN, for an + // empty group) would resolve every joined `fetch_group` to a group + // that only fails on its first read, so wait for the answer. + let answered = ready!(stream.reader.poll_has_more(&mut cx)); + let FetchRunState::Answer { + request, + stream, + frame_start, + } = std::mem::replace(&mut self.state, FetchRunState::Done) + else { + unreachable!() + }; let request = request.expect("request pending"); + if let Err(err) = answered { + tracing::debug!(track = %self.serve.name, group = self.group, %err, "fetch refused"); + stream.writer.abort(&err); + request.reject(err); + return Poll::Ready(()); + } // Make the group available (resolving the downstream fetch) and fill // it. The track::Info only takes effect if the track isn't accepted yet diff --git a/rs/moq-net/src/model/group.rs b/rs/moq-net/src/model/group.rs index 0205ae10b4..e9f3a4bbda 100644 --- a/rs/moq-net/src/model/group.rs +++ b/rs/moq-net/src/model/group.rs @@ -1804,8 +1804,10 @@ impl Fetch { /// The handler fulfills it by calling [`Self::accept`], which inserts the group /// into the track cache (resolving every [`track::Consumer::fetch_group`] that joined the /// attempt) and returns a [`Producer`] to fill. A relay typically opens a wire -/// FETCH, reads FETCH_OK, then accepts. The request carries its own producer handle, -/// so it works the same whether or not the track has been accepted yet. +/// FETCH and waits for the publisher to answer before accepting, so a group the +/// publisher lacks is rejected rather than accepted and then aborted. The request +/// carries its own producer handle, so it works the same whether or not the track +/// has been accepted yet. pub struct Request { pub(crate) state: kio::Producer, pub(crate) fetch: kio::Shared, diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 6cfda585dc..8ac10f45b9 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -2609,8 +2609,9 @@ impl Consumer { /// or `group::Fetch::default()`. /// /// The returned future resolves to [`Error::NotFound`] when the group can never be served - /// (past the final sequence, or no [`Dynamic`] on the track), or the track's abort error - /// if it's already closed. Concurrent fetches for the same sequence coalesce onto one + /// (past the final sequence, or no [`Dynamic`] on the track), the handler's rejection + /// (a relay's upstream miss is [`StreamError::NotFound`](crate::StreamError::NotFound)), + /// or the track's abort error if it's already closed. Concurrent fetches for the same sequence coalesce onto one /// handler request. pub fn fetch_group(&self, sequence: u64, options: impl Into>) -> kio::Pending { let options = options.into().unwrap_or_default(); diff --git a/rs/moq-relay/src/fetch.rs b/rs/moq-relay/src/fetch.rs index 94dfb063ca..5cc958e8bd 100644 --- a/rs/moq-relay/src/fetch.rs +++ b/rs/moq-relay/src/fetch.rs @@ -3,8 +3,9 @@ /// The newest needs a live subscription to learn its sequence, since a fetch can /// only retrieve a sequence already known. Once known it is fetched rather than /// read off the subscription, so an evicted group is retrieved from upstream -/// instead of waited on forever. Returns [`moq_net::Error::NotFound`] when the -/// group can never be served, including a track that ends before any group. +/// instead of waited on forever. Fails before returning when the group can never +/// be served: [`moq_net::Error::NotFound`] locally, including a track that ends +/// before any group, or [`moq_net::StreamError::NotFound`] from upstream. pub async fn fetch_group( track: &moq_net::track::Consumer, sequence: Option, diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index e6c2f41657..b5e09791ed 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -926,7 +926,10 @@ async fn serve_fetch( }; let group = match async { crate::fetch_group(&broadcast.track(&track)?, sequence).await }.await { Ok(group) => group, - Err(moq_net::Error::NotFound) => return Err(StatusCode::NOT_FOUND), + // A miss upstream arrives as the stream reset that refused the FETCH. + Err(moq_net::Error::NotFound | moq_net::Error::Stream(moq_net::StreamError::NotFound)) => { + return Err(StatusCode::NOT_FOUND); + } Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR), }; From 01e1e4d0e2842edc57e884e36e86874871c0601d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 17:54:16 -0700 Subject: [PATCH 16/45] fix(moq-net): serve only the lite routes a request woke, and bound kio waiter lists (#4216) Co-authored-by: Claude Opus 5.5 --- rs/kio/src/waiter.rs | 36 +++++++- rs/moq-net/src/lite/subscriber.rs | 144 +++++++++++++++++++++++++----- 2 files changed, 155 insertions(+), 25 deletions(-) diff --git a/rs/kio/src/waiter.rs b/rs/kio/src/waiter.rs index 3403d25e36..81a66ba48e 100644 --- a/rs/kio/src/waiter.rs +++ b/rs/kio/src/waiter.rs @@ -123,7 +123,8 @@ impl WaiterList { /// /// Each call probes at most two slots at the rotating cursor and reuses /// a dead one in place. The cursor advances on each live probe so the - /// window covers the list over time. + /// window covers the list over time. A list about to grow sweeps every + /// dead slot first. pub fn register(&mut self, waiter: &Waiter) { let new_weak = Arc::downgrade(waiter.shared()); @@ -139,6 +140,15 @@ impl WaiterList { self.cursor = (self.cursor + 1) % self.entries.len(); } + if self.entries.len() == self.entries.capacity() { + // Probing alone loses to a list that many live waiters keep re-registering on + // and nothing wakes: each retired waiter leaves a dead slot the probe window + // rarely lands on, so the list grows for as long as it lives. + self.entries.retain(|entry| entry.strong_count() > 0); + // Leave at least half free, so each sweep is paid for by the pushes before it. + self.entries.reserve(self.entries.len()); + self.cursor = 0; + } self.entries.push(new_weak); } @@ -796,6 +806,30 @@ mod tests { assert!(list.entries.len() <= 2); } + /// Tasks parked on one list are retired and re-register in whatever order they + /// wake, while the list itself is never woken (a rarely-changing value many tasks + /// watch). The probe window alone let such a list grow without bound. + #[test] + fn retired_live_waiters_do_not_grow_the_list() { + const LIVE: usize = 64; + let mut list = WaiterList::new(); + let mut waiters: Vec = (0..LIVE).map(|_| Waiter::noop()).collect(); + for waiter in &waiters { + waiter.register(&mut list); + } + + let mut seed = 1u64; + for _ in 0..100_000 { + seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + let i = (seed >> 33) as usize % LIVE; + waiters[i] = Waiter::noop(); + waiters[i].register(&mut list); + } + + let len = list.entries.len(); + assert!(len <= 4 * LIVE, "{len} slots for {LIVE} live waiters"); + } + #[test] fn register_appends_a_live_waiter() { let mut list = WaiterList::new(); diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index ca7bff6ba4..4d66fe92a4 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -309,7 +309,7 @@ impl Subscriber { return Ok(false); }; - announced.attach(path, AnnouncedRoute::new(route, dynamic)); + announced.attach(path, route, dynamic); Ok(true) } @@ -411,7 +411,7 @@ impl Subscriber { announced.declined(path); return Ok(false); }; - announced.attach(path, AnnouncedRoute::new(metadata, dynamic)); + announced.attach(path, metadata, dynamic); Ok(true) } @@ -1236,12 +1236,6 @@ impl AnnouncePrefix { if self.subscriber.going_away.poll(waiter).is_ready() { run.announced.drain(); } - // Drain every buffered announce BEFORE serving requests: a route - // this pass attaches must have its request queue polled (and this - // machine's waiter registered on it) below, in the same pass. - // Serving first and then parking on the decode would strand a - // request that arrives in between: its wake finds no waiter, and - // nothing else re-polls this machine. loop { match stream.reader.poll_decode_maybe::(&mut cx) { Poll::Ready(Ok(Some(announce))) => { @@ -1261,8 +1255,8 @@ impl AnnouncePrefix { Poll::Pending => break, } } - // Materialize requested paths under the attached routes; leaves the - // waiter registered on every route's request queue. + // Serve the routes whose request queue woke, including any attached + // above. Each route parks on its own waker, so the rest cost nothing. run.announced.poll_serve(&self.subscriber, waiter); return Poll::Pending; } @@ -1975,6 +1969,58 @@ mod tests { ); } + /// A serve pass polls only the routes whose request queue woke. The driver runs a + /// pass on every wake, which is once per group the session carries, so polling + /// every announced route there made each group cost the whole announce set. + #[tokio::test] + async fn a_request_readies_only_its_route() { + let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let mut subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), + session: SinkSession::new(Default::default()), + origin: origin.clone(), + recv_bandwidth: None, + version: VERSION, + peer_setup: Default::default(), + cost: None, + peer_hop: Some(crate::Hop::new(777).unwrap()), + going_away: Default::default(), + }); + + let mut announced = Announced::default(); + for i in 0..64 { + assert!( + subscriber + .start_announce( + Path::new(&format!("room/{i}")).to_owned(), + crate::Hops::new(), + crate::origin::Cost::default(), + 0, + None, + &mut announced, + ) + .unwrap() + ); + } + + // Attaching queues each route for its first pass, and that pass leaves none queued. + assert_eq!(announced.ready.len(), 64); + announced.poll_serve(&subscriber, &kio::Waiter::noop()); + assert!(announced.ready.is_empty()); + + let consumer = origin.consume(); + let request = tokio::spawn(async move { consumer.request_broadcast("room/7").await }); + + let ready = kio::wait(|waiter| announced.ready.poll_pop(waiter)).await.unwrap(); + assert_eq!(ready.as_str(), "room/7"); + assert!(announced.ready.is_empty(), "only the requested route is ready"); + + // Hand it back, as its waker did, and serve it. + announced.ready.try_push(ready).unwrap(); + announced.poll_serve(&subscriber, &kio::Waiter::noop()); + request.await.unwrap().expect("the requested route serves it"); + } + /// Every path out of `start_announce` that declines an announce records it first. /// /// The decline paths are a list one edit can fall off the end of, and a miss is silent: @@ -2530,19 +2576,33 @@ enum Sub { /// A declined advertisement remains present with no route because the peer still /// owns its path and announce id until it retracts or restarts it. #[derive(Default)] -struct Announced(HashMap>); +struct Announced { + routes: HashMap>, + /// Attached routes whose request queue woke since the last serve pass. The + /// driver wakes for every group the session carries, so a pass must cost what + /// was requested, not every route the peer announced. + ready: kio::Queue, +} impl Announced { fn contains(&self, path: &PathOwned) -> bool { - self.0.contains_key(path) + self.routes.contains_key(path) } - fn attach(&mut self, path: PathOwned, route: AnnouncedRoute) { - self.0.insert(path, Some(route)); + /// Attach a route, queued for its first serve pass. + fn attach(&mut self, path: PathOwned, route: crate::origin::Route, dynamic: crate::origin::Dynamic) { + let wake = Arc::new(RouteWake { + path: path.clone(), + queued: atomic::AtomicBool::new(false), + ready: self.ready.clone(), + }); + let route = AnnouncedRoute::new(route, dynamic, wake); + route.waker.wake_by_ref(); + self.routes.insert(path, Some(route)); } fn declined(&mut self, path: PathOwned) { - if let Some(Some(route)) = self.0.insert(path, None) { + if let Some(Some(route)) = self.routes.insert(path, None) { route.finish(); } } @@ -2558,27 +2618,35 @@ impl Announced { /// with [`Self::contains`]. Overwriting an attached route here would drop its source /// without finishing it, which is [`Self::declined`]'s job. fn reserve(&mut self, path: PathOwned) { - debug_assert!(!self.0.contains_key(&path), "reserved a prefix already advertised"); - self.0.insert(path, None); + debug_assert!(!self.routes.contains_key(&path), "reserved a prefix already advertised"); + self.routes.insert(path, None); } fn attached(&mut self, path: &PathOwned) -> Option<&mut AnnouncedRoute> { - self.0.get_mut(path)?.as_mut() + self.routes.get_mut(path)?.as_mut() } fn retire(&mut self, path: &PathOwned) { - if let Some(Some(route)) = self.0.remove(path) { + if let Some(Some(route)) = self.routes.remove(path) { route.finish(); } } - /// Serve queued requests on every attached route: mint a source per requested + /// Serve queued requests on every ready route: mint a source per requested /// path, hand its dynamic to the driver's serve machines, and answer the /// requester with its consumer. fn poll_serve(&mut self, subscriber: &Subscriber, waiter: &kio::Waiter) { let root = subscriber.origin.root().to_owned(); - for entry in self.0.values_mut().flatten() { - while let Poll::Ready(Ok(request)) = entry.dynamic.poll_requested_broadcast(waiter) { + while let Poll::Ready(Ok(path)) = self.ready.poll_pop(waiter) { + // A route retired since it woke has nothing left to serve. + let Some(Some(entry)) = self.routes.get_mut(&path) else { + continue; + }; + // Cleared before polling, so a request landing mid-pass queues the route again. + entry.wake.queued.store(false, atomic::Ordering::Release); + let cx = std::task::Context::from_waker(&entry.waker); + let route_waiter = entry.park.hold(&cx); + while let Poll::Ready(Ok(request)) = entry.dynamic.poll_requested_broadcast(route_waiter) { // The request path is absolute; the wire (and our origin handle) // speak paths relative to the session's root. let Some(path) = request.path().strip_prefix(&root) else { @@ -2598,7 +2666,7 @@ impl Announced { /// Re-price every attached route to a draining cost (the peer sent a GOAWAY). fn drain(&mut self) { - for entry in self.0.values_mut().flatten() { + for entry in self.routes.values_mut().flatten() { entry.drain(); } } @@ -2618,15 +2686,23 @@ struct AnnouncedRoute { sources: HashMap, /// Whether the GOAWAY drain already re-priced this route. drained: bool, + /// Queues this route on its prefix's ready set when its request queue wakes. + wake: Arc, + waker: std::task::Waker, + /// Keeps this route's registration on its request queue alive between passes. + park: kio::Park, } impl AnnouncedRoute { - fn new(route: crate::origin::Route, dynamic: crate::origin::Dynamic) -> Self { + fn new(route: crate::origin::Route, dynamic: crate::origin::Dynamic, wake: Arc) -> Self { Self { route, dynamic, sources: HashMap::new(), drained: false, + waker: std::task::Waker::from(wake.clone()), + wake, + park: kio::Park::default(), } } @@ -2659,6 +2735,26 @@ impl AnnouncedRoute { } } +/// One attached route's waker: queues the route for the next serve pass. +struct RouteWake { + path: PathOwned, + /// Set while queued, so a burst of wakes queues the route once. + queued: atomic::AtomicBool, + ready: kio::Queue, +} + +impl std::task::Wake for RouteWake { + fn wake(self: Arc) { + self.wake_by_ref(); + } + + fn wake_by_ref(self: &Arc) { + if !self.queued.swap(true, atomic::Ordering::AcqRel) { + let _ = self.ready.try_push(self.path.clone()); + } + } +} + /// How a [`TrackServe`] run ends. enum ServeEnd { /// The upstream FIN'd: the track is over for good. From d537caffe1213b7fcb908bed1cfd976f2bc7a601 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 18:06:25 -0700 Subject: [PATCH 17/45] fix(uring): halve the completion queue and lift the relay's memlock limit (#4197) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- doc/bin/relay/config.md | 2 +- packaging/moq-relay/moq-relay.service | 3 +++ rs/moq-uring/src/worker.rs | 30 +++++++++++++++------------ 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index f72a4fb646..8f5484fcba 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -91,7 +91,7 @@ except that `mtu_discovery` (its datagram path sends a fixed payload) and the three flow-control windows (these workers run fixed ones) are refused under `io_uring` rather than quietly ignored. Each worker reports its own counters at [`/metrics`](/bin/relay/http#get-metrics). The kernel charges each worker's -ring (~100 KiB, plus a page per socket) to `RLIMIT_MEMLOCK`, a budget shared by +ring (~56 KiB, plus a page per socket) to `RLIMIT_MEMLOCK`, a budget shared by every io\_uring the user runs; raise it (`LimitMEMLOCK=` under systemd) if workers fail to start with a message naming that limit. diff --git a/packaging/moq-relay/moq-relay.service b/packaging/moq-relay/moq-relay.service index 9ea9d34621..8cbe5ed7b6 100644 --- a/packaging/moq-relay/moq-relay.service +++ b/packaging/moq-relay/moq-relay.service @@ -17,6 +17,9 @@ RestartSec=5s RestartSteps=5 RestartMaxDelaySec=1min LimitNOFILE=1048576 +# io_uring workers charge their rings to RLIMIT_MEMLOCK (~56 KiB each), and hosts still on the old +# 64 KiB default fit one worker at most. +LimitMEMLOCK=infinity # Sandboxing. DynamicUser allocates a transient UID, StateDirectory creates # /var/lib/moq-relay owned by that UID so cert/key/jwk files placed there are diff --git a/rs/moq-uring/src/worker.rs b/rs/moq-uring/src/worker.rs index 45142d2823..972b31513a 100644 --- a/rs/moq-uring/src/worker.rs +++ b/rs/moq-uring/src/worker.rs @@ -20,13 +20,17 @@ const SQ_ENTRIES: u32 = 256; /// Completion queue depth. Every in-flight operation can post a completion /// (one per send buffer with GSO on, one per provided receive buffer, the -/// park futex, transient cancels), so this covers a few sockets at the -/// default pool ceilings in [`udp::Config`]. Running past it is not fatal: -/// the kernel backlogs completions (`IORING_FEAT_NODROP`) rather than drop -/// them. But the backlog is an allocation-per-CQE slow path and it ends any -/// armed multishot receive, so the CQ is sized to keep it out of steady -/// state. -const CQ_ENTRIES: u32 = 4096; +/// park futex, transient cancels), so this covers one socket at the default +/// pool ceilings in [`udp::Config`], the one-socket-per-worker layout the +/// relay runs. Running past it is not fatal: the kernel backlogs completions +/// (`IORING_FEAT_NODROP`) rather than drop them. But the backlog is an +/// allocation-per-CQE slow path and it ends any armed multishot receive, so +/// the CQ is sized to keep it out of steady state. +/// +/// No larger: the ring is charged to `RLIMIT_MEMLOCK` at 16 bytes per entry, +/// most of each worker's footprint, and that budget is shared by every +/// io_uring the user runs. +const CQ_ENTRIES: u32 = 2048; /// Maximum completions copied at once while teardown is deadline-bounded. const TEARDOWN_CQE_BATCH: usize = 64; @@ -891,14 +895,14 @@ mod tests { #[test] fn cq_covers_the_default_pool_ceilings() { - // The completion queue must cover at least two sockets at their - // default pool ceilings (plus the futex), or the kernel's overflow - // slow path becomes steady state for the workload the ceilings exist - // to serve. Fails when someone raises the udp defaults without - // revisiting CQ_ENTRIES. + // The completion queue must cover a socket at its default pool + // ceilings (plus the futex), or the kernel's overflow slow path + // becomes steady state for the workload the ceilings exist to serve. + // Fails when someone raises the udp defaults without revisiting + // CQ_ENTRIES. let config = udp::Config::default(); let per_socket = u32::from(config.tx_buffers_max) + u32::from(config.rx_buffers_max); - assert!(CQ_ENTRIES > 2 * per_socket, "CQ_ENTRIES fell behind the pool defaults"); + assert!(CQ_ENTRIES > per_socket, "CQ_ENTRIES fell behind the pool defaults"); } #[test] From bb426d9de7898d0695732d48211fc51318feda14 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 18:25:01 -0700 Subject: [PATCH 18/45] fix(watch): keep media-captions external so the package imports outside a browser (#4217) Co-authored-by: Claude Opus 5.5 --- js/watch/vite.config.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/js/watch/vite.config.ts b/js/watch/vite.config.ts index 3fbc9cc463..2ceb18c7c8 100644 --- a/js/watch/vite.config.ts +++ b/js/watch/vite.config.ts @@ -18,7 +18,10 @@ export default defineConfig({ rollupOptions: { // Keep every @moq workspace dep (and its subpaths, e.g. @moq/signals/dom) // external so consumers dedupe against their own copy instead of bundling ours. - external: (id) => id.startsWith("@moq/"), + // media-captions too: its browser build reads `window.VTTCue` on load, and only + // an external import lets a server runtime pick its `node` export instead. Its + // stylesheets stay bundled, since they are inlined as strings. + external: (id) => id.startsWith("@moq/") || id === "media-captions", }, sourcemap: true, target: "esnext", From cd379fe38d49a5c94bc4e01b91dfe21af462a31a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 18:27:33 -0700 Subject: [PATCH 19/45] feat(net): compress lite-07 announcements against live bases (#4196) Co-authored-by: Claude Opus 5.5 --- .github/workflows/nightly.yml | 2 +- doc/concept/moq-lite.md | 5 + drafts/draft-lcurley-moq-lite.md | 47 +- js/net/src/lite/announce.test.ts | 138 +++++ js/net/src/lite/announce.ts | 149 +++++- js/net/src/lite/subscriber.ts | 44 +- js/net/src/lite/version.ts | 16 + quest/m1/README.md | 1 - quest/m1/announce-compression.md | 106 ---- rs/justfile | 2 +- rs/moq-net/Cargo.toml | 10 +- rs/moq-net/benches/announce.rs | 156 ++++++ rs/moq-net/fuzz/Cargo.toml | 7 + rs/moq-net/fuzz/README.md | 4 +- rs/moq-net/fuzz/fuzz_targets/announce.rs | 7 + rs/moq-net/src/fuzz.rs | 133 ++++- rs/moq-net/src/lite/announce.rs | 271 ++++++++-- rs/moq-net/src/lite/compress.rs | 617 +++++++++++++++++++++++ rs/moq-net/src/lite/mod.rs | 2 + rs/moq-net/src/lite/publisher.rs | 103 ++-- rs/moq-net/src/lite/subscriber.rs | 31 +- rs/moq-net/src/lite/version.rs | 18 +- rs/moq-net/src/path/mod.rs | 2 +- rs/moq-tokio/tests/broadcast.rs | 41 +- 24 files changed, 1622 insertions(+), 290 deletions(-) delete mode 100644 quest/m1/announce-compression.md create mode 100644 rs/moq-net/benches/announce.rs create mode 100644 rs/moq-net/fuzz/fuzz_targets/announce.rs create mode 100644 rs/moq-net/src/lite/compress.rs diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9f73f46109..62484ea295 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -224,7 +224,7 @@ jobs: strategy: fail-fast: false matrix: - target: [lite, ietf, varint, path, pattern] + target: [lite, announce, ietf, varint, path, pattern] steps: - name: Free disk space uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # main diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 33930649a2..ce9e8118cf 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -56,6 +56,11 @@ anonymous mark and travels the chain unchanged. A route that passed through an anonymous hop at any depth ranks below every fully identified route, whatever the costs say; among anonymous routes, cost keeps ordering. +On moq-lite 07, an announcement may copy the head of its path and the tail of +its relay chain from one still live on the same stream, so many broadcasts +from a few origins behind the same relays stop repeating those bytes. Rust +compresses when it helps; TypeScript decodes it but always sends literally. + A broadcast exists only while it is announced, for consumers in the same process and across a session alike: one that is created but never announced can be neither discovered nor requested. A broadcast published locally diff --git a/drafts/draft-lcurley-moq-lite.md b/drafts/draft-lcurley-moq-lite.md index 679b2056d6..36bef0581f 100644 --- a/drafts/draft-lcurley-moq-lite.md +++ b/drafts/draft-lcurley-moq-lite.md @@ -383,6 +383,14 @@ A route covers a path when its prefix is a leading run of the path's segments; m A publisher answering a request stream presents each of its routes clamped to the intersection with the requested prefix: a route above the request's prefix appears as the request prefix itself (an empty suffix), which is exactly the covered set the subscriber may see. There MAY be multiple Announce Streams, potentially containing overlapping prefixes, that get their own ANNOUNCE_OK + announcements. +#### Compression {#announce-compression} +An ANNOUNCE_START or ANNOUNCE_UPDATE MAY copy the head of a path or the tail of a Hop ID list from a live advertisement on the same stream, its base. +A `Base` field names it by distance: the base's Announce ID is the next unassigned Announce ID minus `Base`, so 1 names the latest ANNOUNCE_START, and 0 names no base. +The receiver resolves every base against the advertisements live when the message arrives, and the result is identical to the literal encoding: a base ending later changes nothing. +A publisher MAY always send a `Base` of 0. + +The subscriber MUST close the session with a PROTOCOL_VIOLATION if a non-zero `Base` names an Announce ID that was never assigned or already retired, a `Keep` is non-zero with a `Base` of 0 or exceeds what the base has, or the resolved path or Hop ID list breaks its own rules. + #### Hidden Paths {#hidden} A route is hidden from a request when a segment of its path below the requested prefix starts with `.` (0x2E). A segment inside the prefix never hides anything, so a request that names the hidden segment itself (`.stats`) lists what is under it, and a route at or above the prefix has no segment below it. @@ -860,37 +868,53 @@ Only the suffix is encoded on the wire, as the full route prefix can be construc ANNOUNCE_START Message { Type (i) = 0x0 Message Length (i) + Path Base (i), + Path Keep (i), Route Prefix Suffix (s), - Hop Count (i), - Hop ID (i) ..., + Hops (..), Warm Route Cost (i), Cold Route Cost (i), } + +Hops { + Hop Base (i), + Hop Count (i), + Hop ID (i) ..., + Hop Keep (i), +} ~~~ **Type**: Set to 0x0 to indicate an ANNOUNCE_START message. +**Path Base** and **Path Keep**: +The suffix begins with the first `Path Keep` segments of the suffix advertised by the base that `Path Base` names (see [Compression](#announce-compression)). + **Route Prefix Suffix**: -This is combined with the requested prefix to form the route's full prefix. +The remaining segments of the suffix, which is combined with the requested prefix to form the route's full prefix. An empty suffix advertises the requested prefix itself, which is how a route covering more than the request presents (see [Announce](#announce)). +**Hop Base** and **Hop Keep**: +The Hop ID list ends with the last `Hop Keep` entries of the list advertised by the base that `Hop Base` names (see [Compression](#announce-compression)), following the literal `Hop ID` entries. +The two bases are independent. + **Hop Count**: -The number of Hop ID entries that follow, NOT including the publisher's own `Hop ID` from ANNOUNCE_OK. -A value of 0 means no Hop ID entries are present, indicating either that the announcement originated locally on the publisher (the publisher itself is the origin) or that the upstream peer does not support hop tracking. +The number of literal Hop ID entries that follow. +The resolved list does NOT include the publisher's own `Hop ID` from ANNOUNCE_OK. +An empty list indicates either that the announcement originated locally on the publisher (the publisher itself is the origin) or that the upstream peer does not support hop tracking. A receiver MUST close the stream with a PROTOCOL_VIOLATION if the Hop Count does not match the number of subsequent Hop ID entries. **Hop ID**: A unique identifier for each relay in the path from the origin publisher, ordered from origin to the upstream of the responding publisher. -The responding publisher's own Hop ID is NOT included in this list; it is carried once in ANNOUNCE_OK, so the total path length is `Hop Count + 1`. -When forwarding an announcement received from an upstream peer, a relay MUST append the upstream peer's ANNOUNCE_OK `Hop ID` to this list, since that ID is no longer implicit downstream. +The responding publisher's own Hop ID is NOT included in the resolved list; it is carried once in ANNOUNCE_OK, so the total path length is the resolved list's length plus 1 (`Hop Count + Hop Keep + 1`). +When forwarding an announcement received from an upstream peer, a relay MUST append the upstream peer's ANNOUNCE_OK `Hop ID` to the resolved list, since that ID is no longer implicit downstream. The first entry of the reconstructed path identifies the endpoint that originated the route. A Hop ID value of 0 means the hop is unknown: either it was never assigned or a relay deliberately withholds it (see [Routing](#routing)). A received 0 is forwarded unchanged. When bridging an announcement from an upstream that sent no hop list, a relay writes 0 for that hop. An identity a receiver assigned that upstream is local selection state and MUST NOT be forwarded as a Hop ID. -A receiver MUST close the session with a PROTOCOL_VIOLATION if a non-zero Hop ID appears twice in this list. +A receiver MUST close the session with a PROTOCOL_VIOLATION if a non-zero Hop ID appears twice in the resolved list. Duplicate values of 0 are not a violation, since 0 identifies nothing and any number of hops may be unknown. **Warm Route Cost** and **Cold Route Cost**: @@ -936,8 +960,7 @@ ANNOUNCE_UPDATE Message { Type (i) = 0x2 Message Length (i) Announce ID (i), - Hop Count (i), - Hop ID (i) ..., + Hops (..), Warm Route Cost (i), Cold Route Cost (i), } @@ -950,8 +973,9 @@ Set to 0x2 to indicate an ANNOUNCE_UPDATE message. The ordinal implicitly assigned by a prior ANNOUNCE_START on this stream. Referencing an id that was never assigned, or one already retired by an ANNOUNCE_END, is a protocol violation. -**Hop Count**, **Hop ID**, **Warm Route Cost**, and **Cold Route Cost**: +**Hops**, **Warm Route Cost**, and **Cold Route Cost**: As defined for [ANNOUNCE_START](#announce-start). +`Hop Base` may name this advertisement itself, which resolves against the list being replaced. An update whose only change is a Route Cost is valid: it is how a relay re-prices a route without disturbing it. @@ -1315,6 +1339,7 @@ The `Message Length` describes the payload size on the wire. - Added `Stream Count` to SUBSCRIBE_END: the number of Group Streams opened for the subscription. SUBSCRIBE_END is now sent once every counted Group Stream has opened, rather than as soon as the final group is known. - Removed SUBSCRIBE_DROP and its type 0x2; a group without a Group Stream is not counted. - The Subscribe Stream FIN now follows once every counted Group Stream has finished or been reset. +- Added announce compression: ANNOUNCE_START gains `Path Base` and `Path Keep` to copy the head of a live advertisement's suffix, and ANNOUNCE_START and ANNOUNCE_UPDATE gain `Hop Base` and `Hop Keep` to copy the tail of a live advertisement's Hop ID list. ## moq-lite-06 diff --git a/js/net/src/lite/announce.test.ts b/js/net/src/lite/announce.test.ts index 4a6acc7dfc..69b34cb9e5 100644 --- a/js/net/src/lite/announce.test.ts +++ b/js/net/src/lite/announce.test.ts @@ -5,9 +5,11 @@ import * as Path from "../path.ts"; import { Reader, Writer } from "../stream.ts"; import { type AnnounceBroadcast, + AnnounceHistory, AnnounceOk, AnnounceRequest, decodeAnnounceBroadcast, + decodeAnnounceBroadcastMaybe, encodeAnnounceBroadcast, } from "./announce.ts"; import { Version } from "./version.ts"; @@ -212,3 +214,139 @@ test("a hop chain that revisits a hop is refused in both directions", async () = }; expect(await roundTrip(anonymous, Version.DRAFT_05)).toEqual(anonymous); }); + +function hex(data: Uint8Array): string { + return Array.from(data, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function unhex(text: string): Uint8Array { + return new Uint8Array(text.match(/../g)?.map((b) => Number.parseInt(b, 16)) ?? []); +} + +// Resolve every announcement on a lite-07 stream, as the subscriber does. +async function resolveStream(data: Uint8Array) { + const reader = new Reader(undefined, data); + const history = new AnnounceHistory(); + const out: unknown[] = []; + for (;;) { + const msg = await decodeAnnounceBroadcastMaybe(reader, Version.DRAFT_07); + if (!msg) return out; + switch (msg.status) { + case "active": + out.push({ start: history.start(msg) }); + break; + case "restart": + out.push({ update: msg.id, hops: history.update(msg).hops }); + break; + case "endedId": + out.push({ end: msg.id, suffix: history.end(msg.id) }); + break; + default: + throw new Error(`unexpected ${msg.status}`); + } + } +} + +const hop = (id: bigint) => HopSchema.parse(id); +const relay = hop(0x2222n); +const GOLDEN_RESOLVED = [ + { start: { suffix: Path.from("room/a/cam"), hops: [hop(0x1111n), relay] } }, + { start: { suffix: Path.from("room/a/mic"), hops: [hop(0x3333n), relay] } }, + { update: 0n, hops: [hop(0x4444n), relay] }, + { end: 1n, suffix: Path.from("room/a/mic") }, + { start: { suffix: Path.from("room/b"), hops: [hop(0x5555n), relay] } }, +]; + +// Pinned from the Rust encoder (`lite::compress::tests::golden_stream_is_pinned`), so the +// JS decoder is checked against real compressed output. +const GOLDEN = + "001600000a726f6f6d2f612f63616d000251116222000000000d0102036d696301017333010000020a00010180004444010000010101000d02010162020180005555010000"; + +test("AnnounceHistory resolves the Rust encoder's compressed stream", async () => { + expect(await resolveStream(unhex(GOLDEN))).toEqual(GOLDEN_RESOLVED); +}); + +// JS always encodes literally; Rust decodes these bytes too (`js_literal_stream_decodes`). +const JS_LITERAL = + "001600000a726f6f6d2f612f63616d000251116222000000001600000a726f6f6d2f612f6d6963000273336222000000020c0000028000444462220000000101010014000006726f6f6d2f620002800055556222000000"; + +test("the literal draft-07 stream matches what Rust decodes", async () => { + const wire = await bytes(async (w) => { + const v = Version.DRAFT_07; + const cost = { warm: 0n, cold: 0n }; + await encodeAnnounceBroadcast( + w, + { status: "active", suffix: Path.from("room/a/cam"), hops: [hop(0x1111n), relay], cost }, + v, + ); + await encodeAnnounceBroadcast( + w, + { status: "active", suffix: Path.from("room/a/mic"), hops: [hop(0x3333n), relay], cost }, + v, + ); + await encodeAnnounceBroadcast(w, { status: "restart", id: 0n, hops: [hop(0x4444n), relay], cost }, v); + await encodeAnnounceBroadcast(w, { status: "endedId", id: 1n }, v); + await encodeAnnounceBroadcast( + w, + { status: "active", suffix: Path.from("room/b"), hops: [hop(0x5555n), relay], cost }, + v, + ); + }); + expect(hex(wire)).toBe(JS_LITERAL); + expect(await resolveStream(wire)).toEqual(GOLDEN_RESOLVED); +}); + +test("AnnounceHistory rejects a base that is not live", () => { + const history = new AnnounceHistory(); + const base = { distance: 1n, keep: 0 }; + // A new stream has nothing to copy from. + expect(() => history.start({ suffix: Path.from("a"), hops: [], pathBase: base })).toThrow(ProtocolViolation); + + const fresh = new AnnounceHistory(); + fresh.start({ suffix: Path.from("a/b"), hops: [hop(1n)] }); + fresh.start({ suffix: Path.from("c"), hops: [] }); + fresh.end(0n); + // Id 0 is two back, and retired. + expect(() => fresh.start({ suffix: Path.from("x"), hops: [], pathBase: { distance: 2n, keep: 1 } })).toThrow( + ProtocolViolation, + ); +}); + +test("AnnounceHistory rejects a keep longer than the base", () => { + const history = new AnnounceHistory(); + history.start({ suffix: Path.from("a/b"), hops: [hop(1n)] }); + expect(() => history.start({ suffix: Path.from("x"), hops: [], pathBase: { distance: 1n, keep: 3 } })).toThrow( + ProtocolViolation, + ); + expect(() => history.update({ id: 0n, hops: [], hopBase: { distance: 1n, keep: 2 } })).toThrow(ProtocolViolation); +}); + +test("AnnounceHistory rejects a resolved chain that repeats a hop", () => { + const history = new AnnounceHistory(); + history.start({ suffix: Path.from("a"), hops: [hop(1n), hop(2n)] }); + expect(() => + history.start({ suffix: Path.from("b"), hops: [hop(2n)], hopBase: { distance: 1n, keep: 1 } }), + ).toThrow(ProtocolViolation); +}); + +test("a keep without a base is a violation on draft-07", async () => { + // ANNOUNCE_START: type, length, path base 0, path keep 1, empty suffix, empty hops, cost. + const wire = await bytes(async (w) => { + await w.u53(0); + await w.u53(8); + for (const b of [0, 1, 0, 0, 0, 0, 0, 0]) await w.u8(b); + }); + await expect(decodeAnnounceBroadcast(new Reader(undefined, wire), Version.DRAFT_07)).rejects.toThrow( + ProtocolViolation, + ); +}); + +test("draft-06 has no room for a base", async () => { + const msg: AnnounceBroadcast = { + status: "active", + suffix: Path.from("x"), + hops: [], + pathBase: { distance: 1n, keep: 1 }, + }; + await expect(bytes((w) => encodeAnnounceBroadcast(w, msg, Version.DRAFT_06))).rejects.toThrow(); +}); diff --git a/js/net/src/lite/announce.ts b/js/net/src/lite/announce.ts index d9d6bf7874..da874389fa 100644 --- a/js/net/src/lite/announce.ts +++ b/js/net/src/lite/announce.ts @@ -3,7 +3,15 @@ import { type Cost, type Hop, HopSchema, MAX_HOPS, UNKNOWN_HOP } from "../hop.ts import * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; import * as Message from "./message.ts"; -import { hasAnnounceId, hasAnnounceOk, hasExcludeHop, hasHidden, hasRouteCost, Version } from "./version.ts"; +import { + hasAnnounceCompression, + hasAnnounceId, + hasAnnounceOk, + hasExcludeHop, + hasHidden, + hasRouteCost, + Version, +} from "./version.ts"; // Pre-lite-06 inner status values, carried inside the single ANNOUNCE_BROADCAST body. const STATUS_ENDED = 0; @@ -19,6 +27,13 @@ const ANNOUNCE_RESTART = 2; export type { Cost }; +/** + * Lite-07: copy `keep` path segments (from the head) or hops (from the tail) of the + * live announcement `distance` back from the stream's next Announce ID, where 1 is the + * latest ANNOUNCE_START. Resolved by {@link AnnounceHistory}. + */ +export type Base = { distance: bigint; keep: number }; + /** * An announcement on the Announce Stream, advertising or retracting a broadcast. * @@ -32,8 +47,9 @@ export type { Cost }; export type AnnounceBroadcast = /** A broadcast is now available, carrying the path suffix, the hop chain, and * (lite-06+) the route cost. An absent cost encodes as zero; it decodes as - * `undefined` on a wire with no room for one. */ - | { status: "active"; suffix: Path.Valid; hops: Hop[]; cost?: Cost } + * `undefined` on a wire with no room for one. On lite-07, `suffix` follows the + * segments `pathBase` copies and `hops` precede the ones `hopBase` copies. */ + | { status: "active"; suffix: Path.Valid; hops: Hop[]; cost?: Cost; pathBase?: Base; hopBase?: Base } /** Pre-lite-06: a broadcast is no longer available, retracted by path. */ | { status: "ended"; suffix: Path.Valid } /** Lite06+: a broadcast is no longer available, retracted by announce id. @@ -41,7 +57,7 @@ export type AnnounceBroadcast = | { status: "endedId"; id: bigint } /** Lite06+: atomically replace the announcement with this id (e.g. a new hop * chain after a relay failover, or a route whose cost moved). The id stays live. */ - | { status: "restart"; id: bigint; hops: Hop[]; cost?: Cost } + | { status: "restart"; id: bigint; hops: Hop[]; cost?: Cost; hopBase?: Base } /** An unknown lite-06+ announce type, skipped by length. Does not assign an id. */ | { status: "skipped" }; @@ -112,6 +128,53 @@ async function decodeHops(r: Reader, version: Version): Promise { } } +// Lite-07 carries a base around a path and a hop list. A distance of 0 names +// nothing, and a keep without a base is a violation. Other versions have no room for +// a base at all. +function checkBase(version: Version, base: Base | undefined) { + if (base && !hasAnnounceCompression(version)) { + throw new Error("announce compression not supported for this version"); + } +} + +function toBase(distance: bigint, keep: number): Base | undefined { + if (distance !== 0n) return { distance, keep }; + if (keep !== 0) throw new ProtocolViolation("announce keep without a base"); + return undefined; +} + +async function encodePath(w: Writer, version: Version, suffix: Path.Valid, base: Base | undefined) { + checkBase(version, base); + if (hasAnnounceCompression(version)) { + await w.u62(base?.distance ?? 0n); + await w.u53(base?.keep ?? 0); + } + await w.string(Path.encode(suffix)); +} + +async function decodePath(r: Reader, version: Version): Promise<{ suffix: Path.Valid; pathBase?: Base }> { + if (!hasAnnounceCompression(version)) return { suffix: Path.decode(await r.string()) }; + const pathBase = toBase(await r.u62(), await r.u53()); + const suffix = Path.decode(await r.string()); + return pathBase ? { suffix, pathBase } : { suffix }; +} + +async function encodeHopsBlock(w: Writer, version: Version, hops: Hop[], base: Base | undefined) { + checkBase(version, base); + if (!hasAnnounceCompression(version)) return encodeHops(w, version, hops); + await w.u62(base?.distance ?? 0n); + await encodeHops(w, version, hops); + await w.u53(base?.keep ?? 0); +} + +async function decodeHopsBlock(r: Reader, version: Version): Promise<{ hops: Hop[]; hopBase?: Base }> { + if (!hasAnnounceCompression(version)) return { hops: await decodeHops(r, version) }; + const distance = await r.u62(); + const hops = await decodeHops(r, version); + const hopBase = toBase(distance, await r.u53()); + return hopBase ? { hops, hopBase } : { hops }; +} + // The route cost rides lite-06+ announcements as two varints, warm then cold; older // versions carry neither. async function encodeRouteCost(w: Writer, version: Version, cost: Cost | undefined) { @@ -129,8 +192,8 @@ async function decodeRouteCost(r: Reader, version: Version): Promise { switch (typ) { case ANNOUNCE_START: { - const suffix = Path.decode(await r.string()); - const hops = await decodeHops(r, version); - return { status: "active", suffix, hops, cost: await decodeRouteCost(r, version) }; + const path = await decodePath(r, version); + const hops = await decodeHopsBlock(r, version); + return { status: "active", ...path, ...hops, cost: await decodeRouteCost(r, version) }; } case ANNOUNCE_END: return { status: "endedId", id: await r.u62() }; case ANNOUNCE_RESTART: { const id = await r.u62(); - const hops = await decodeHops(r, version); - return { status: "restart", id, hops, cost: await decodeRouteCost(r, version) }; + const hops = await decodeHopsBlock(r, version); + return { status: "restart", id, ...hops, cost: await decodeRouteCost(r, version) }; } default: // Skip the length-prefixed body so an earlier Lite06 build negotiating @@ -191,6 +254,7 @@ async function decodeAnnounce06Body(r: Reader, typ: number, version: Version): P async function encodeLegacyBody(w: Writer, msg: AnnounceBroadcast, version: Version) { switch (msg.status) { case "active": + checkBase(version, msg.pathBase ?? msg.hopBase); await w.u8(STATUS_ACTIVE); await w.string(Path.encode(msg.suffix)); await encodeHops(w, version, msg.hops); @@ -253,6 +317,67 @@ export async function decodeAnnounceBroadcastMaybe( return Message.decodeMaybe(r, (r) => decodeLegacyBody(r, version)); } +type Advertised = { suffix: Path.Valid; hops: Hop[] }; + +/** + * The live announcements on one lite-06+ announce stream, by Announce ID: what + * ANNOUNCE_END and ANNOUNCE_UPDATE reference, and what a lite-07 base copies from. + * Every violation throws {@link ProtocolViolation}. + */ +export class AnnounceHistory { + #next = 0n; + #live = new Map(); + + #base(base: Base): Advertised { + const live = this.#live.get(this.#next - base.distance); + if (!live) throw new ProtocolViolation(`announce base ${base.distance} is not live`); + return live; + } + + #hops(hops: Hop[], base: Base | undefined): Hop[] { + if (!base) return hops; + const tail = this.#base(base).hops; + if (base.keep > tail.length) throw new ProtocolViolation(`announce keeps ${base.keep} of ${tail.length} hops`); + const resolved = [...hops, ...tail.slice(tail.length - base.keep)]; + checkHops(resolved); + return resolved; + } + + /** An ANNOUNCE_START: resolve it and assign it the next id. */ + start(msg: { suffix: Path.Valid; hops: Hop[]; pathBase?: Base; hopBase?: Base }): Advertised { + let suffix = msg.suffix; + if (msg.pathBase) { + const head = Path.parts(this.#base(msg.pathBase).suffix); + const keep = msg.pathBase.keep; + if (keep > head.length) throw new ProtocolViolation(`announce keeps ${keep} of ${head.length} segments`); + suffix = Path.join(Path.from(...head.slice(0, keep)), suffix); + if (Path.parts(suffix).length > Path.MAX_PARTS) { + throw new ProtocolViolation(`path exceeds ${Path.MAX_PARTS} parts`); + } + } + const hops = this.#hops(msg.hops, msg.hopBase); + this.#live.set(this.#next++, { suffix, hops }); + return { suffix, hops }; + } + + /** An ANNOUNCE_UPDATE: resolve its chain before replacing the old one, which it may be based on. */ + update(msg: { id: bigint; hops: Hop[]; hopBase?: Base }): Advertised { + const hops = this.#hops(msg.hops, msg.hopBase); + const live = this.#live.get(msg.id); + if (!live) throw new ProtocolViolation(`unknown announce id: ${msg.id}`); + this.#live.set(msg.id, { suffix: live.suffix, hops }); + return { suffix: live.suffix, hops }; + } + + /** An ANNOUNCE_END: retire the id, returning its suffix. */ + end(id: bigint): Path.Valid { + const live = this.#live.get(id); + if (!live) throw new ProtocolViolation(`unknown announce id: ${id}`); + this.#live.delete(id); + return live.suffix; + } +} + /** * ANNOUNCE_REQUEST: sent by the subscriber to request ANNOUNCE_BROADCAST messages * for a path prefix. Renamed from `AnnounceInterest` in lite-05. diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 6b6f97f18b..021fe1dae4 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -14,7 +14,13 @@ import * as Time from "../time.ts"; import type * as track from "../track.ts"; import { TimeoutError, withTimeout } from "../util/timeout.ts"; import { overrideBroadcastWire, wireOf } from "../wire.ts"; -import { AnnounceInit, AnnounceOk, AnnounceRequest, decodeAnnounceBroadcastMaybe } from "./announce.ts"; +import { + AnnounceHistory, + AnnounceInit, + AnnounceOk, + AnnounceRequest, + decodeAnnounceBroadcastMaybe, +} from "./announce.ts"; import { Datagram as DatagramMessage } from "./datagram.ts"; import * as DatagramStream from "./datagram_stream.ts"; import { Fetch as FetchMessage } from "./fetch.ts"; @@ -273,10 +279,10 @@ export class Subscriber { } // Lite06+: announce ids. Each received `active` implicitly assigns the next - // per-stream ordinal; `endedId`/`restart` reference it. Tracked even for - // announces we skip as reflected, since the sender doesn't know we skipped. - let nextAnnounceId = 0n; - const announcedById = new Map(); + // per-stream ordinal; `endedId`/`restart` reference it, and lite-07 bases copy + // from it. Tracked even for announces we skip as reflected, since the sender + // doesn't know we skipped. + const history = new AnnounceHistory(); // Receive announce updates (for Draft03, this includes initial state) for (;;) { @@ -295,39 +301,31 @@ export class Subscriber { let cost: Cost | undefined; switch (announce.status) { - case "active": + case "active": { + const resolved = hasAnnounceId(this.version) ? history.start(announce) : announce; // The wire names the suffix beneath the interest prefix; the consumer // sees the covered path from the session root. - path = Path.join(prefix, announce.suffix); + path = Path.join(prefix, resolved.suffix); active = true; - hops = announce.hops; + hops = resolved.hops; cost = announce.cost; - if (hasAnnounceId(this.version)) { - announcedById.set(nextAnnounceId++, path); - } break; + } case "ended": path = Path.join(prefix, announce.suffix); active = false; break; - case "endedId": { + case "endedId": // Resolve and retire the id; an unknown or retired id is a protocol violation. - const resolved = announcedById.get(announce.id); - if (resolved === undefined) throw new ProtocolViolation(`unknown announce id: ${announce.id}`); - announcedById.delete(announce.id); - if (resolved === null) continue; - path = resolved; + path = Path.join(prefix, history.end(announce.id)); active = false; break; - } case "restart": { // Resolve the id; it stays live (the replacement reuses it). - const resolved = announcedById.get(announce.id); - if (resolved === undefined) throw new ProtocolViolation(`unknown announce id: ${announce.id}`); - if (resolved === null) continue; - path = resolved; + const resolved = history.update(announce); + path = Path.join(prefix, resolved.suffix); active = true; - hops = announce.hops; + hops = resolved.hops; cost = announce.cost; break; } diff --git a/js/net/src/lite/version.ts b/js/net/src/lite/version.ts index 28d0470dd9..2d83114b6f 100644 --- a/js/net/src/lite/version.ts +++ b/js/net/src/lite/version.ts @@ -249,6 +249,22 @@ export function hasStreamCount(version: Version): boolean { } } +/** Whether ANNOUNCE_START and ANNOUNCE_UPDATE may copy a path head or hop-chain tail from a live announcement. Added in lite-07. */ +export function hasAnnounceCompression(version: Version): boolean { + // Explicitly list older versions so future versions keep the lite-07+ behavior. + switch (version) { + case Version.DRAFT_01: + case Version.DRAFT_02: + case Version.DRAFT_03: + case Version.DRAFT_04: + case Version.DRAFT_05: + case Version.DRAFT_06: + return false; + default: + return true; + } +} + /// The WebTransport subprotocol identifier for moq-lite. /// Version negotiation still happens via SETUP when this is used. export const ALPN = "moql"; diff --git a/quest/m1/README.md b/quest/m1/README.md index 6c95748db6..88edaefce0 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -19,7 +19,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [JS fetch answer](/quest/m1/js-fetch-answer.md) - js/net's lite fetch settles on the publisher's answer, and a JS publisher's miss resets with NotFound - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts -- [Announce compression](/quest/m1/announce-compression.md) - a lite-07 announce reuses the path head and hop-chain tail of a live announcement on its stream instead of resending them - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached - [Session death error](/quest/m1/session-death-error.md) - a dying session ends its tracks with its own error in Rust and JS, never a clean end, `Dropped`, or `Cancel` - [Dropped sources](/quest/m1/dropped-sources.md) - consumers see the producer's real error on every end path, never `Dropped` diff --git a/quest/m1/announce-compression.md b/quest/m1/announce-compression.md deleted file mode 100644 index cb0dc30bc7..0000000000 --- a/quest/m1/announce-compression.md +++ /dev/null @@ -1,106 +0,0 @@ -# [L] Announce compression - -## Goal - -A moq-lite-07 ANNOUNCE_START can reuse the path of any live announcement on -its stream, and ANNOUNCE_START and ANNOUNCE_UPDATE can reuse the tail of any -live announcement's hop chain, so repeated names and relay paths stop costing -full bytes on every announce. Decoded routes are identical to the literal -encoding. Compression is mandatory for every lite-07 endpoint to decode, and -any encoder may still send everything literally. lite-06 and older are -unchanged. - -Non-goals: fewer announces (tree routing was dropped in favour of this), a -separate dictionary with its own bounds, and a production byte counter. - -## Plan - -### Wire - -A base is a live announcement on the same stream, named by its distance back -from the next unassigned Announce ID: `d` names `next - d`, so `1` is the -latest START, and `0` means none. Only the stream's ordered history is -consulted, so there is no new state, table limit, eviction, or reset rule: the -receiver already stores every live route, and a decoded route owns its values, -so a base ending later changes nothing. - -``` -ANNOUNCE_START { - Type (i) = 0x0 - Message Length (i) - Path Base (i), - Path Keep (i), // leading segments of the base's suffix - Route Prefix Suffix (s), // remaining segments, literal - Hops, - Warm Route Cost (i), - Cold Route Cost (i), -} - -ANNOUNCE_UPDATE { - Type (i) = 0x2 - Message Length (i) - Announce ID (i), - Hops, - Warm Route Cost (i), - Cold Route Cost (i), -} - -Hops { - Hop Base (i), - Hop Count (i), - Hop ID (i) ..., // literal leading hops, e.g. a new origin - Hop Keep (i), // trailing hops copied from the base's chain -} -``` - -- Paths share their head, so `Path Keep` counts whole leading segments of the - base's wire suffix (relative to the requested prefix). Segments, not bytes, - to keep the codec simple. -- Chains share their tail: the origin differs per publisher while the relays - behind it repeat. `Hop Keep` counts trailing entries of the base's wire hop - list (the one excluding ANNOUNCE_OK's Hop ID), appended after the literal - hops. The hop base is independent of the path base, since the best path - match can arrive over a different relay path. -- PROTOCOL_VIOLATION: a base that was never assigned or is already retired, a - non-zero keep with base 0, a keep larger than the base has, or a result that - breaks the existing path and hop rules (`MAX_PARTS`, 32 hops, repeated - non-zero Hop ID, duplicate live route). -- ANNOUNCE_END and ANNOUNCE_OK are unchanged. - -### Implementation - -- Rust (`rs/moq-net/src/lite/`): the codec is stateless today, so decoding - moves beside `PrefixRun` (subscriber) and encoding beside `AnnounceRun` - (publisher), which already hold the live set by Announce ID. The encoder - picks bases from two ordered indexes over its live announcements, one keyed - by path segments and one by reversed hop chain, so the longest shared head - or tail is a neighbour lookup. -- JS (`js/net/src/lite/`): decode everything, encode literally (both bases 0). -- Fuzz: `fuzz.rs` round-trips single messages; add a stream-level harness that - feeds message sequences through the stateful decoder. -- Update the MoQ-lite draft's ANNOUNCE_START and ANNOUNCE_UPDATE sections and - its lite-07 changelog, plus `doc/concept/moq-lite.md` where it describes the - format. - -### Verification - -- Codec tests for each violation, base reuse after ENDs, keep of the full - path/chain, anonymous (0) hops, and a new stream starting empty. -- Rust encoder against JS decoder (and literal JS against Rust), plus - `just test interop --all`. -- A benchmark of encoded announce bytes and encode/decode CPU against lite-06 - literal framing, swept over live routes and churn: a health-shaped workload - (`/private/channel_N/stream-health-` from several origins sharing - relay tails) and an all-unique control, whose overhead is the four zero - base/keep bytes per START. An earlier - prototype with separate path and chain dictionaries saved 69% on a similar - workload; report what this shape achieves. - -## Required - -- moq-lite-07 ships as `moq-lite-07-wip`, off by default, so its wire can still change ([#4148](https://github.com/moq-dev/moq/pull/4148)) - -## Related - -- [Relay memory](/quest/m1/relay-memory.md) - route state per relay, which - this leaves unchanged diff --git a/rs/justfile b/rs/justfile index e7fcbeb580..abeafecf82 100644 --- a/rs/justfile +++ b/rs/justfile @@ -969,7 +969,7 @@ loom *args: # run, and libFuzzer writes what it discovers to the FIRST corpus directory, which is # why the generated seeds are passed second and stay read-only. # -# TARGET is lite, ietf, varint, or path. Arguments pass through, so +# TARGET is lite, announce, ietf, varint, path, or pattern. Arguments pass through, so # `just rs fuzz lite -- -max_total_time=300` bounds a run. See rs/moq-net/fuzz/README.md. fuzz target *args: #!/usr/bin/env bash diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index 288ac36bb2..a064f2beca 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -16,8 +16,8 @@ categories = ["multimedia", "network-programming", "web-programming"] ignored = ["getrandom"] [features] -# Exposes the wire codecs to the `fuzz/` harness through the hidden `fuzz` module. -# Off by default and hidden from the docs; nothing depends on it but that harness. +# Exposes the wire codecs to the `fuzz/` harness and the announce bench through the +# hidden `fuzz` module. Off by default and hidden from the docs. fuzz = [] [dependencies] @@ -69,6 +69,12 @@ required-features = ["fuzz"] name = "origin" harness = false +# Reaches the private announce codec through the hidden `fuzz` module. +[[bench]] +name = "announce" +harness = false +required-features = ["fuzz"] + [[bench]] name = "group" harness = false diff --git a/rs/moq-net/benches/announce.rs b/rs/moq-net/benches/announce.rs new file mode 100644 index 0000000000..8c8ad54354 --- /dev/null +++ b/rs/moq-net/benches/announce.rs @@ -0,0 +1,156 @@ +//! Lite-07 announce compression against lite-06 literal framing: the bytes an +//! announce stream costs, and the CPU to encode and decode it. +//! +//! Bytes and decode are measured over a whole stream: `routes` routes, then `churn` of +//! them replaced (an END and a fresh START), so the bases the encoder picks come and +//! go. Encode is measured in the steady state a relay sees: the encoder already holds +//! `routes` live routes, and each iteration retracts the oldest and starts a new one. +//! Two workloads bracket the gain: +//! +//! - health: `/private/channel_N/stream-health-` from a few origins behind +//! a shared pair of relays, the shape that motivated compression. +//! - unique: random single-segment paths and random two-hop chains, where nothing +//! is shared and every START pays the four zero base/keep bytes. +//! +//! The byte counts print once before the timings. +//! +//! Run with `cargo bench -p moq-net --features fuzz --bench announce`. + +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use moq_net::{ + Hop, Hops, PathOwned, + fuzz::{AnnounceWriter, Announced, decode_announces, encode_announces}, +}; + +/// `(routes, churn)` shapes for the whole-stream measurements. +const SHAPES: [(u64, u64); 4] = [(100, 0), (1_000, 0), (1_000, 1_000), (10_000, 1_000)]; + +/// Live routes for the steady-state encode. +const LIVE: [u64; 3] = [100, 1_000, 10_000]; + +/// How many origins publish health streams, each behind the same two relays. +const ORIGINS: u64 = 8; + +/// A deterministic 62-bit id, standing in for a random Hop ID. +fn id(seed: u64) -> u64 { + // SplitMix64, masked to the varint range and kept non-zero. + let mut z = seed.wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + ((z ^ (z >> 31)) & ((1 << 62) - 1)).max(1) +} + +fn hops(ids: &[u64]) -> Hops { + Hops::try_from(ids.iter().map(|id| Hop::new(*id).unwrap()).collect::>()).unwrap() +} + +/// Announce `routes` routes, then replace `churn` of them, oldest first. +fn stream(routes: u64, churn: u64, route: impl Fn(u64) -> (PathOwned, Hops)) -> Vec { + let mut announced: Vec<_> = (0..routes) + .map(|n| { + let (path, hops) = route(n); + Announced::Start(path, hops) + }) + .collect(); + for n in 0..churn { + announced.push(Announced::End(n)); + let (path, hops) = route(routes + n); + announced.push(Announced::Start(path, hops)); + } + announced +} + +fn health(n: u64) -> (PathOwned, Hops) { + let origin = n % ORIGINS; + let path = format!( + "{:x}/private/channel_{}/stream-health-{}", + id(origin), + n % 16, + 1_700_000_000 + n + ); + (path.into(), hops(&[id(origin), id(100), id(101)])) +} + +fn unique(n: u64) -> (PathOwned, Hops) { + ( + format!("{:x}", id(n)).into(), + hops(&[id(n + 1_000_000), id(n + 2_000_000)]), + ) +} + +type Workload = (&'static str, fn(u64) -> (PathOwned, Hops)); +const WORKLOADS: [Workload; 2] = [("health", health), ("unique", unique)]; + +fn report() { + eprintln!("announce stream bytes (lite-06 literal -> lite-07 compressed):"); + for (name, route) in WORKLOADS { + for (routes, churn) in SHAPES { + let announced = stream(routes, churn, route); + let literal = encode_announces(&announced, false).len(); + let compressed = encode_announces(&announced, true).len(); + let starts = routes + churn; + eprintln!( + " {name:>6} routes={routes:>5} churn={churn:>5}: {literal:>8} -> {compressed:>8} bytes, {:.1} -> {:.1} per START ({:+.0}%)", + literal as f64 / starts as f64, + compressed as f64 / starts as f64, + (compressed as f64 / literal as f64 - 1.0) * 100.0, + ); + } + } +} + +fn bench(c: &mut Criterion) { + report(); + + for (name, route) in WORKLOADS { + let mut group = c.benchmark_group(format!("announce_{name}")); + + for routes in LIVE { + // A ring twice the live set, so the route started never collides with one + // still live. + let ring: Vec<_> = (0..2 * routes) + .map(|n| { + let (path, hops) = route(n); + Announced::Start(path, hops) + }) + .collect(); + group.throughput(Throughput::Elements(2)); + + for compress in [false, true] { + let version = if compress { "lite07" } else { "lite06" }; + group.bench_function(BenchmarkId::new(format!("churn_{version}"), routes), |b| { + let mut writer = AnnounceWriter::new(compress); + let mut data = Vec::new(); + for start in &ring[..routes as usize] { + writer.write(start, &mut data); + } + let mut oldest = 0; + b.iter(|| { + data.clear(); + writer.write(&Announced::End(oldest), &mut data); + writer.write(&ring[((routes + oldest) % (2 * routes)) as usize], &mut data); + oldest += 1; + }); + }); + } + } + + for (routes, churn) in SHAPES { + let announced = stream(routes, churn, route); + group.throughput(Throughput::Elements(announced.len() as u64)); + for compress in [false, true] { + let version = if compress { "lite07" } else { "lite06" }; + let encoded = encode_announces(&announced, compress); + group.bench_with_input( + BenchmarkId::new(format!("decode_{version}"), format!("{routes}x{churn}")), + &encoded, + |b, encoded| b.iter(|| decode_announces(encoded, compress)), + ); + } + } + group.finish(); + } +} + +criterion_group!(benches, bench); +criterion_main!(benches); diff --git a/rs/moq-net/fuzz/Cargo.toml b/rs/moq-net/fuzz/Cargo.toml index f19331315d..721e187412 100644 --- a/rs/moq-net/fuzz/Cargo.toml +++ b/rs/moq-net/fuzz/Cargo.toml @@ -27,6 +27,13 @@ test = false doc = false bench = false +[[bin]] +name = "announce" +path = "fuzz_targets/announce.rs" +test = false +doc = false +bench = false + [[bin]] name = "ietf" path = "fuzz_targets/ietf.rs" diff --git a/rs/moq-net/fuzz/README.md b/rs/moq-net/fuzz/README.md index a374f8b500..de42127674 100644 --- a/rs/moq-net/fuzz/README.md +++ b/rs/moq-net/fuzz/README.md @@ -9,7 +9,7 @@ cargo install --locked cargo-fuzz just rs fuzz lite ``` -The targets are `lite`, `ietf`, `varint`, `path`, and `pattern`. Extra arguments pass through to +The targets are `lite`, `announce`, `ietf`, `varint`, `path`, and `pattern`. Extra arguments pass through to libFuzzer, so `just rs fuzz lite -- -max_total_time=300` bounds a run. The Nightly workflow runs every target for five minutes and uploads failure inputs @@ -41,6 +41,8 @@ Beyond "does not panic": where a parameter map reaches the wire, since those encoders walk a `HashMap` and its iteration order differs per instance (moq-lite SETUP, and draft-14/15, which unlike draft-16+ do not sort by key first). +- A lite-07 announce stream our decoder accepts, recompressed by our encoder, resolves + to the same announcements. - `Path::relative` inverts `Path::resolve`, and never produces a reference that walks above the root. diff --git a/rs/moq-net/fuzz/fuzz_targets/announce.rs b/rs/moq-net/fuzz/fuzz_targets/announce.rs new file mode 100644 index 0000000000..f672273f18 --- /dev/null +++ b/rs/moq-net/fuzz/fuzz_targets/announce.rs @@ -0,0 +1,7 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + moq_net::fuzz::announce_stream(data); +}); diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index 7d71f5a49e..5fc9f793b6 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -13,7 +13,7 @@ use bytes::Buf; use crate::{ - Path, Pattern, + Hops, Path, PathOwned, Pattern, coding::{Decode, Encode, VarInt}, ietf, lite, path::Relative, @@ -26,6 +26,7 @@ pub type Target = fn(&[u8]) -> bool; /// Every target, keyed by the name of its `fuzz_targets/.rs` shim. pub const TARGETS: &[(&str, Target)] = &[ ("lite", lite_wire), + ("announce", announce_stream), ("ietf", ietf_wire), ("varint", varint), ("path", path), @@ -154,6 +155,114 @@ pub fn lite_wire(data: &[u8]) -> bool { } } +/// One announcement on an announce stream, resolved: what the application sees. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Announced { + /// ANNOUNCE_START: a route's suffix and wire hop chain, taking the next id. + Start(PathOwned, Hops), + /// ANNOUNCE_UPDATE: a live id's new wire hop chain. + Update(u64, Hops), + /// ANNOUNCE_END: a live id retired. + End(u64), +} + +fn announce_version(compress: bool) -> lite::Version { + match compress { + true => lite::Version::Lite07, + false => lite::Version::Lite06, + } +} + +/// One publisher's side of an announce stream: lite-07 with compression, or lite-06 +/// literal framing. +pub struct AnnounceWriter { + version: lite::Version, + encoder: lite::AnnounceEncoder, +} + +impl AnnounceWriter { + /// A new stream, with nothing live. + pub fn new(compress: bool) -> Self { + let version = announce_version(compress); + Self { + version, + encoder: lite::AnnounceEncoder::new(version), + } + } + + /// Append one announcement to `data`. An update or end must name a live id. + pub fn write(&mut self, announced: &Announced, data: &mut Vec) { + let msg = match announced { + Announced::Start(suffix, hops) => { + let (_, suffix, hops) = self.encoder.start(suffix.clone(), hops.clone()); + lite::AnnounceBroadcast::Active { + suffix, + hops, + cost: Default::default(), + } + } + Announced::Update(id, hops) => lite::AnnounceBroadcast::Restart { + id: *id, + hops: self.encoder.update(*id, hops.clone()), + cost: Default::default(), + }, + Announced::End(id) => { + self.encoder.end(*id); + lite::AnnounceBroadcast::EndedId { id: *id } + } + }; + msg.encode(data, self.version) + .expect("could not encode an announcement"); + } +} + +/// Encode `announced` as a fresh [`AnnounceWriter`] stream. +pub fn encode_announces(announced: &[Announced], compress: bool) -> Vec { + let mut writer = AnnounceWriter::new(compress); + let mut data = Vec::new(); + for announced in announced { + writer.write(announced, &mut data); + } + data +} + +/// Decode and resolve an announce stream, as [`encode_announces`] writes it, stopping +/// at the first message that fails to decode or resolve: a subscriber closes the +/// session there. +pub fn decode_announces(mut data: &[u8], compress: bool) -> Vec { + let version = announce_version(compress); + let mut decoder = lite::AnnounceDecoder::default(); + let mut resolved = Vec::new(); + while let Ok(msg) = lite::AnnounceBroadcast::decode(&mut data, version) { + let announced = match msg { + lite::AnnounceBroadcast::Active { suffix, hops, .. } => decoder + .start(suffix, hops) + .map(|(suffix, hops)| Announced::Start(suffix, hops)), + lite::AnnounceBroadcast::Restart { id, hops, .. } => { + decoder.update(id, hops).map(|(_, hops)| Announced::Update(id, hops)) + } + lite::AnnounceBroadcast::EndedId { id } => decoder.end(id).map(|_| Announced::End(id)), + _ => continue, + }; + let Ok(announced) = announced else { + break; + }; + resolved.push(announced); + } + resolved +} + +/// Feed a lite-07 announce stream through the stateful decoder, then check that our +/// encoder's compression of what it resolved reads back as the same announcements. +/// +/// Returns whether anything resolved. +pub fn announce_stream(data: &[u8]) -> bool { + let resolved = decode_announces(data, true); + let echo = decode_announces(&encode_announces(&resolved, true), true); + assert_eq!(echo, resolved, "compression did not survive a round trip"); + !resolved.is_empty() +} + /// Decode one IETF moq-transport wire object from arbitrary bytes. /// /// Byte 0 picks the negotiated draft, byte 1 the object type, and the rest is the @@ -517,6 +626,28 @@ pub fn seeds() -> Vec { }); } + // Announce streams from our own encoder: routes sharing path heads and relay tails, + // with retractions and updates moving the bases around. + let mut announced = Vec::new(); + let hop = |id: u64| crate::Hop::new(id).expect("a valid hop"); + for n in 0..12u64 { + let suffix = PathOwned::from(format!("pid{}/private/channel_{}/health-{n}", n % 3, n % 2)); + let hops = Hops::try_from(vec![hop(100 + n % 3), hop(1 << 40), hop(1 << 41)]).expect("valid hops"); + announced.push(Announced::Start(suffix, hops)); + if n % 4 == 3 { + let hops = Hops::try_from(vec![hop(200), hop(1 << 41)]).expect("valid hops"); + announced.push(Announced::Update(n, hops)); + } + if n % 5 == 4 { + announced.push(Announced::End(n)); + } + seeds.push(Seed { + target: "announce", + kind: 0, + data: encode_announces(&announced, true), + }); + } + // One of each length tag in both codecs, plus the all-ones forms that only the // leading-ones codec accepts. for selector in 0..12u8 { diff --git a/rs/moq-net/src/lite/announce.rs b/rs/moq-net/src/lite/announce.rs index cfed2f2c9f..e54733e4f7 100644 --- a/rs/moq-net/src/lite/announce.rs +++ b/rs/moq-net/src/lite/announce.rs @@ -34,12 +34,20 @@ pub fn restart_supported(version: Version) -> bool { /// 0); `EndedId` (ANNOUNCE_END) and `Restart` (ANNOUNCE_RESTART) reference /// that id instead of repeating the path. Older versions send a single /// `ANNOUNCE_BROADCAST` message that retracts by path (`Ended`). +/// +/// The path and hop chain are as they appear on the wire: on lite-07 they may name a +/// base announcement, resolved against the stream's history by +/// [`AnnounceDecoder`](super::AnnounceDecoder). #[derive(Clone, Debug, PartialEq, Eq)] pub enum AnnounceBroadcast<'a> { /// ANNOUNCE_START (lite-06) / active (older): a broadcast is now available. /// Carries the path suffix, the hop chain, and (lite-06+) the warm and cold /// route costs, and assigns the next announce id. - Active { suffix: Path<'a>, hops: Hops, cost: Cost }, + Active { + suffix: PathRef<'a>, + hops: HopsRef, + cost: Cost, + }, /// Pre-lite-06: a broadcast is no longer available, retracted by path. Ended { suffix: Path<'a>, hops: Hops }, /// ANNOUNCE_END (lite-06+): a broadcast is no longer available, retracted by @@ -48,14 +56,136 @@ pub enum AnnounceBroadcast<'a> { /// ANNOUNCE_RESTART (lite-06+): atomically replace the announcement with this id /// (e.g. a new hop chain after a relay failover, or a route whose cost moved). /// The id stays live. - /// - /// Only ever received: we advertise a replacement as an `EndedId` + `Active` pair. - Restart { id: u64, hops: Hops, cost: Cost }, + Restart { id: u64, hops: HopsRef, cost: Cost }, /// An unknown lite-06+ announce type. The length-prefixed body was skipped so /// the stream stays up; it does not assign an announce id. Skipped, } +/// A path suffix as it travels: the first `keep` segments of a base announcement's +/// suffix, followed by `rest`. +/// +/// `base` is the base's distance back from the stream's next unassigned announce id, +/// so 1 is the latest ANNOUNCE_START, and 0 names no base (`keep` must be 0 too). Only +/// lite-07 carries a base; every other version is `rest` alone. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PathRef<'a> { + pub base: u64, + pub keep: u64, + pub rest: Path<'a>, +} + +impl<'a> PathRef<'a> { + /// The whole suffix, with no base. + pub fn literal(rest: Path<'a>) -> Self { + Self { base: 0, keep: 0, rest } + } +} + +impl Encode for PathRef<'_> { + fn encode(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + if version.has_announce_compression() { + self.base.encode(w, version)?; + self.keep.encode(w, version)?; + } else if self.base != 0 || self.keep != 0 { + return Err(EncodeError::Version); + } + self.rest.encode(w, version) + } +} + +impl Decode for PathRef<'_> { + fn decode(buf: &mut B, version: Version) -> Result { + if !version.has_announce_compression() { + return Ok(Self::literal(Path::decode(buf, version)?)); + } + let base = u64::decode(buf, version)?; + let keep = u64::decode(buf, version)?; + if base == 0 && keep != 0 { + return Err(DecodeError::InvalidValue); + } + let rest = Path::decode(buf, version)?; + Ok(Self { base, keep, rest }) + } +} + +/// A hop chain as it travels: `literal` leading hops, followed by the last `keep` +/// entries of a base announcement's chain. +/// +/// `base` counts back like [`PathRef::base`], independently of it. Only lite-07 +/// carries a base; every other version is `literal` alone. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HopsRef { + pub base: u64, + pub literal: Hops, + pub keep: u64, +} + +impl HopsRef { + /// The whole chain, with no base. + pub fn literal(literal: Hops) -> Self { + Self { + base: 0, + literal, + keep: 0, + } + } +} + +impl Encode for HopsRef { + fn encode(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + if !version.has_announce_compression() { + if self.base != 0 || self.keep != 0 { + return Err(EncodeError::Version); + } + return self.literal.encode(w, version); + } + self.base.encode(w, version)?; + self.literal.encode(w, version)?; + self.keep.encode(w, version) + } +} + +impl Decode for HopsRef { + fn decode(buf: &mut B, version: Version) -> Result { + if !version.has_announce_compression() { + return Ok(Self::literal(Hops::decode(buf, version)?)); + } + let base = u64::decode(buf, version)?; + let literal = Hops::decode(buf, version)?; + let keep = u64::decode(buf, version)?; + if base == 0 && keep != 0 { + return Err(DecodeError::InvalidValue); + } + Ok(Self { base, literal, keep }) + } +} + +impl AnnounceBroadcast<'_> { + /// Re-own a decoded message so it can outlive the decode buffer. + #[cfg(test)] + pub fn into_owned(self) -> AnnounceBroadcast<'static> { + match self { + Self::Active { suffix, hops, cost } => AnnounceBroadcast::Active { + suffix: PathRef { + base: suffix.base, + keep: suffix.keep, + rest: suffix.rest.into_owned(), + }, + hops, + cost, + }, + Self::Ended { suffix, hops } => AnnounceBroadcast::Ended { + suffix: suffix.into_owned(), + hops, + }, + Self::EndedId { id } => AnnounceBroadcast::EndedId { id }, + Self::Restart { id, hops, cost } => AnnounceBroadcast::Restart { id, hops, cost }, + Self::Skipped => AnnounceBroadcast::Skipped, + } + } +} + impl Encode for Cost { fn encode(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { if !version.has_route_cost() { @@ -119,9 +249,13 @@ impl Encode for AnnounceBroadcast<'_> { match self { // The cost is a lite-06 addition, so it is simply not on the wire here. Self::Active { suffix, hops, .. } => { + // Bases are a lite-07 addition; PathRef and HopsRef refuse one here. + if suffix.base != 0 || hops.base != 0 { + return Err(EncodeError::Version); + } AnnounceStatus::Active.encode(&mut body, version)?; - suffix.encode(&mut body, version)?; - encode_hops(&mut body, version, hops)?; + suffix.rest.encode(&mut body, version)?; + encode_hops(&mut body, version, &hops.literal)?; } Self::Ended { suffix, hops } => { AnnounceStatus::Ended.encode(&mut body, version)?; @@ -151,8 +285,8 @@ impl Decode for AnnounceBroadcast<'_> { let mut body = buf.take(size); let msg = match typ { ANNOUNCE_START => Self::Active { - suffix: Path::decode(&mut body, version)?, - hops: Hops::decode(&mut body, version)?, + suffix: PathRef::decode(&mut body, version)?, + hops: HopsRef::decode(&mut body, version)?, cost: Cost::decode(&mut body, version)?, }, ANNOUNCE_END => Self::EndedId { @@ -160,7 +294,7 @@ impl Decode for AnnounceBroadcast<'_> { }, ANNOUNCE_RESTART => Self::Restart { id: u64::decode(&mut body, version)?, - hops: Hops::decode(&mut body, version)?, + hops: HopsRef::decode(&mut body, version)?, cost: Cost::decode(&mut body, version)?, }, // Unknown types are skipped by length so an earlier Lite06 build @@ -214,8 +348,8 @@ impl AnnounceBroadcast<'_> { Ok(match status { AnnounceStatus::Active => Self::Active { - suffix, - hops, + suffix: PathRef::literal(suffix), + hops: HopsRef::literal(hops), cost: Cost::UNKNOWN, }, AnnounceStatus::Ended => Self::Ended { suffix, hops }, @@ -225,8 +359,8 @@ impl AnnounceBroadcast<'_> { // path it's a fresh announce. Older versions never defined this status, so it's an // invalid value there. AnnounceStatus::Restart if restart_supported(version) => Self::Active { - suffix, - hops, + suffix: PathRef::literal(suffix), + hops: HopsRef::literal(hops), cost: Cost::UNKNOWN, }, AnnounceStatus::Restart => return Err(DecodeError::InvalidValue), @@ -406,8 +540,8 @@ mod tests { // Encode a normal Active, then flip its status byte (1 -> 2). let mut buf = bytes::BytesMut::new(); AnnounceBroadcast::Active { - suffix: Path::new("foo/bar"), - hops: Hops::new(), + suffix: PathRef::literal(Path::new("foo/bar")), + hops: HopsRef::default(), cost: Cost::default(), } .encode(&mut buf, version) @@ -485,21 +619,7 @@ mod tests { let mut slice = &buf[..]; let got = AnnounceBroadcast::decode(&mut slice, version).unwrap(); assert!(slice.is_empty(), "trailing bytes after decode"); - // Decode borrows from `buf`; re-own so the value can outlive this frame. - match got { - AnnounceBroadcast::Active { suffix, hops, cost } => AnnounceBroadcast::Active { - suffix: suffix.to_owned(), - hops, - cost, - }, - AnnounceBroadcast::Ended { suffix, hops } => AnnounceBroadcast::Ended { - suffix: suffix.to_owned(), - hops, - }, - AnnounceBroadcast::EndedId { id } => AnnounceBroadcast::EndedId { id }, - AnnounceBroadcast::Restart { id, hops, cost } => AnnounceBroadcast::Restart { id, hops, cost }, - AnnounceBroadcast::Skipped => AnnounceBroadcast::Skipped, - } + got.into_owned() } #[test] @@ -507,8 +627,8 @@ mod tests { let mut hops = Hops::new(); hops.push(Hop::new(7).unwrap()).unwrap(); let msg = AnnounceBroadcast::Active { - suffix: Path::new("room/cam"), - hops: hops.clone(), + suffix: PathRef::literal(Path::new("room/cam")), + hops: HopsRef::literal(hops.clone()), cost: Cost::UNKNOWN, }; assert_eq!(broadcast_round_trip(&msg, Version::Lite05), msg); @@ -530,8 +650,8 @@ mod tests { let cost = Cost { warm: 12, cold: 30 }; let active = AnnounceBroadcast::Active { - suffix: Path::new("room/cam"), - hops: hops.clone(), + suffix: PathRef::literal(Path::new("room/cam")), + hops: HopsRef::literal(hops.clone()), cost, }; assert_eq!(broadcast_round_trip(&active, Version::Lite06), active); @@ -539,10 +659,81 @@ mod tests { let ended = AnnounceBroadcast::EndedId { id: 3 }; assert_eq!(broadcast_round_trip(&ended, Version::Lite06), ended); - let restart = AnnounceBroadcast::Restart { id: 3, hops, cost }; + let restart = AnnounceBroadcast::Restart { + id: 3, + hops: HopsRef::literal(hops), + cost, + }; assert_eq!(broadcast_round_trip(&restart, Version::Lite06), restart); } + // Lite07 carries both bases as they travel; the codec resolves nothing. + #[test] + fn announce_broadcast_round_trip_on_lite07() { + let mut hops = Hops::new(); + hops.push(Hop::new(7).unwrap()).unwrap(); + let cost = Cost { warm: 12, cold: 30 }; + + let active = AnnounceBroadcast::Active { + suffix: PathRef { + base: 2, + keep: 3, + rest: Path::new("cam"), + }, + hops: HopsRef { + base: 1, + literal: hops.clone(), + keep: 2, + }, + cost, + }; + assert_eq!(broadcast_round_trip(&active, Version::Lite07), active); + + let restart = AnnounceBroadcast::Restart { + id: 3, + hops: HopsRef { + base: 4, + literal: hops, + keep: 1, + }, + cost, + }; + assert_eq!(broadcast_round_trip(&restart, Version::Lite07), restart); + } + + // A keep copies from a base, so one without a base is malformed. + #[test] + fn a_keep_without_a_base_is_rejected() { + for (path_keep, hop_keep) in [(1u8, 0u8), (0, 1)] { + // Path base, path keep, empty rest, hop base, no hops, hop keep, cost. + let body = [0, path_keep, 0, 0, 0, hop_keep, 0, 0]; + let mut buf = vec![ANNOUNCE_START as u8, body.len() as u8]; + buf.extend_from_slice(&body); + assert!(matches!( + AnnounceBroadcast::decode(&mut &buf[..], Version::Lite07), + Err(DecodeError::InvalidValue) + )); + } + } + + // Only lite-07 has room for a base. + #[test] + fn a_base_needs_lite07() { + let msg = AnnounceBroadcast::Active { + suffix: PathRef { + base: 1, + keep: 1, + rest: Path::new("cam"), + }, + hops: HopsRef::default(), + cost: Cost::default(), + }; + for version in [Version::Lite05, Version::Lite06] { + let mut buf = bytes::BytesMut::new(); + assert!(matches!(msg.encode(&mut buf, version), Err(EncodeError::Version))); + } + } + // The id-referencing forms don't exist before lite-06, and the path form is gone on lite-06. #[test] fn announce_broadcast_rejects_cross_version_forms() { @@ -554,7 +745,7 @@ mod tests { assert!(matches!( AnnounceBroadcast::Restart { id: 1, - hops: Hops::new(), + hops: HopsRef::default(), cost: Cost::default() } .encode(&mut buf, Version::Lite05), @@ -577,16 +768,16 @@ mod tests { #[test] fn route_cost_is_dropped_before_lite06() { let msg = AnnounceBroadcast::Active { - suffix: Path::new("room/cam"), - hops: Hops::new(), + suffix: PathRef::literal(Path::new("room/cam")), + hops: HopsRef::default(), cost: Cost { warm: 9, cold: 9 }, }; let got = broadcast_round_trip(&msg, Version::Lite05); assert_eq!( got, AnnounceBroadcast::Active { - suffix: Path::new("room/cam"), - hops: Hops::new(), + suffix: PathRef::literal(Path::new("room/cam")), + hops: HopsRef::default(), cost: Cost::UNKNOWN, } ); diff --git a/rs/moq-net/src/lite/compress.rs b/rs/moq-net/src/lite/compress.rs new file mode 100644 index 0000000000..e908e8cbec --- /dev/null +++ b/rs/moq-net/src/lite/compress.rs @@ -0,0 +1,617 @@ +//! Lite-07 announce compression: an ANNOUNCE_START may copy the head of a live +//! announcement's path, and an ANNOUNCE_START or ANNOUNCE_UPDATE the tail of a live +//! announcement's hop chain, naming that base by its distance back from the stream's +//! next unassigned Announce ID. +//! +//! The codec in `announce.rs` stays stateless and carries bases as they travel. The +//! decoder here resolves them against the stream's live announcements, which the +//! subscriber has to track anyway, and the encoder picks them. + +use std::{ + borrow::Borrow, + collections::{BTreeSet, HashMap}, + hash::Hash, +}; + +use crate::{Error, Hop, Hops, Path, PathOwned, coding::Encode, coding::Sizer}; + +use super::{HopsRef, PathRef, Version}; + +/// A live announcement, as the peer holds it: its wire suffix and wire hop chain +/// (the one excluding ANNOUNCE_OK's Hop ID). +#[derive(Clone, Debug)] +struct Entry { + suffix: PathOwned, + hops: Hops, +} + +/// The live announcements on one stream by Announce ID, which is all a base can name. +#[derive(Debug, Default)] +struct Live { + next: u64, + entries: HashMap, +} + +impl Live { + /// The live announcement `distance` back from the next id, or `None` for 0. + fn base(&self, distance: u64) -> Result, Error> { + if distance == 0 { + return Ok(None); + } + let id = self.next.checked_sub(distance).ok_or(Error::ProtocolViolation)?; + self.entries.get(&id).map(Some).ok_or(Error::ProtocolViolation) + } + + fn resolve_path(&self, path: PathRef<'_>) -> Result { + let Some(base) = self.base(path.base)? else { + return Ok(path.rest.into_owned()); + }; + let keep = usize::try_from(path.keep).map_err(|_| Error::ProtocolViolation)?; + let head = head(&base.suffix, keep).ok_or(Error::ProtocolViolation)?; + let suffix = head.join(&path.rest); + if suffix.parts().count() > Path::MAX_PARTS { + return Err(Error::ProtocolViolation); + } + Ok(suffix) + } + + fn resolve_hops(&self, hops: HopsRef) -> Result { + let Some(base) = self.base(hops.base)? else { + return Ok(hops.literal); + }; + let keep = usize::try_from(hops.keep).map_err(|_| Error::ProtocolViolation)?; + let tail = base.hops.as_slice(); + let start = tail.len().checked_sub(keep).ok_or(Error::ProtocolViolation)?; + let mut out = hops.literal; + for hop in &tail[start..] { + out.push(*hop).map_err(|_| Error::ProtocolViolation)?; + } + Ok(out) + } +} + +/// The first `keep` segments of `path`, or `None` if it has fewer. +fn head<'a>(path: &'a Path<'_>, keep: usize) -> Option> { + if keep == 0 { + return Some(Path::empty()); + } + let s = path.as_str(); + let mut ends = s + .match_indices('/') + .map(|(i, _)| i) + .chain((!s.is_empty()).then_some(s.len())); + ends.nth(keep - 1).map(|end| Path::new(&s[..end])) +} + +/// Resolves the bases on a received announce stream. Only used on versions with +/// announce ids; before lite-06 nothing is ever referenced, so nothing is recorded. +#[derive(Debug, Default)] +pub(crate) struct AnnounceDecoder { + live: Live, +} + +impl AnnounceDecoder { + /// An ANNOUNCE_START: resolve it and assign it the next id. + pub fn start(&mut self, suffix: PathRef<'_>, hops: HopsRef) -> Result<(PathOwned, Hops), Error> { + let suffix = self.live.resolve_path(suffix)?; + let hops = self.live.resolve_hops(hops)?; + let id = self.live.next; + self.live.next += 1; + self.live.entries.insert( + id, + Entry { + suffix: suffix.clone(), + hops: hops.clone(), + }, + ); + Ok((suffix, hops)) + } + + /// An ANNOUNCE_UPDATE: resolve its chain before replacing the old one, which it + /// may itself be based on. + pub fn update(&mut self, id: u64, hops: HopsRef) -> Result<(PathOwned, Hops), Error> { + let hops = self.live.resolve_hops(hops)?; + let entry = self.live.entries.get_mut(&id).ok_or(Error::ProtocolViolation)?; + entry.hops = hops.clone(); + Ok((entry.suffix.clone(), hops)) + } + + /// An ANNOUNCE_END: retire the id, returning its suffix. + pub fn end(&mut self, id: u64) -> Result { + let entry = self.live.entries.remove(&id).ok_or(Error::ProtocolViolation)?; + Ok(entry.suffix) + } +} + +/// The live ids under a run of keys: path segments from the head, or hop ids from +/// the tail. Every node holds the ids of the entries beneath it, so the entry sharing +/// the longest run with a query is found by walking the query down once, at a cost +/// that grows with its length rather than with the number of entries. +#[derive(Debug)] +struct Trie { + children: HashMap>, +} + +#[derive(Debug)] +struct Node { + ids: BTreeSet, + children: HashMap>, +} + +impl Default for Trie { + fn default() -> Self { + Self { + children: HashMap::new(), + } + } +} + +impl Trie { + fn insert<'a, Q>(&mut self, keys: impl Iterator, id: u64) + where + Q: ?Sized + ToOwned + 'a, + { + let mut children = &mut self.children; + for key in keys { + let node = children.entry(key.to_owned()).or_insert_with(|| Node { + ids: BTreeSet::new(), + children: HashMap::new(), + }); + node.ids.insert(id); + children = &mut node.children; + } + } + + fn remove<'a, Q>(&mut self, keys: impl Iterator, id: u64) + where + Q: ?Sized + Hash + Eq + 'a, + K: Borrow, + { + Self::remove_from(&mut self.children, keys, id); + } + + fn remove_from<'a, Q>(children: &mut HashMap>, mut keys: impl Iterator, id: u64) + where + Q: ?Sized + Hash + Eq + 'a, + K: Borrow, + { + let Some(key) = keys.next() else { + return; + }; + let Some(node) = children.get_mut(key) else { + return; + }; + node.ids.remove(&id); + // Nothing else passes through here, so nothing else is beneath it either. + if node.ids.is_empty() { + children.remove(key); + return; + } + Self::remove_from(&mut node.children, keys, id); + } + + /// How many leading keys the query shares with its closest entry, and that entry's + /// id, preferring the newest (the smallest distance to encode). + fn longest<'a, Q>(&self, keys: impl Iterator) -> Option<(usize, u64)> + where + Q: ?Sized + Hash + Eq + 'a, + K: Borrow, + { + let mut children = &self.children; + let mut best = None; + for (depth, key) in keys.enumerate() { + let Some(node) = children.get(key) else { + break; + }; + let newest = *node.ids.last().expect("a node without ids is pruned"); + best = Some((depth + 1, newest)); + children = &node.children; + } + best + } +} + +/// Picks the bases for an outgoing announce stream, and assigns Announce IDs on +/// versions that have them. Only lite-07 keeps the live set, since nothing earlier +/// can name a base. +/// +/// One trie over the live paths by segment, and one over the live hop chains from the +/// last hop back, find the longest shared head and tail. A base is used only when it +/// encodes smaller than the literal. +#[derive(Debug)] +pub(crate) struct AnnounceEncoder { + version: Version, + live: Live, + heads: Trie, + tails: Trie, +} + +/// A hop chain from the last hop back. +fn reversed(hops: &Hops) -> impl Iterator { + hops.as_slice().iter().rev() +} + +impl AnnounceEncoder { + pub fn new(version: Version) -> Self { + Self { + version, + live: Live::default(), + heads: Trie::default(), + tails: Trie::default(), + } + } + + /// An ANNOUNCE_START: its id (on versions that assign one) and its wire form. + pub fn start(&mut self, suffix: PathOwned, hops: Hops) -> (Option, PathRef<'static>, HopsRef) { + if !self.version.has_announce_id() { + return (None, PathRef::literal(suffix), HopsRef::literal(hops)); + } + + let id = self.live.next; + self.live.next += 1; + + if !self.version.has_announce_compression() { + return (Some(id), PathRef::literal(suffix), HopsRef::literal(hops)); + } + + // Distances count back from the id this START takes. + let path = self.path_ref(&suffix, id); + let chain = self.hops_ref(&hops, id); + self.heads.insert(suffix.parts(), id); + self.tails.insert(reversed(&hops), id); + self.live.entries.insert(id, Entry { suffix, hops }); + (Some(id), path, chain) + } + + /// An ANNOUNCE_UPDATE for a live `id`: its wire hop chain. + pub fn update(&mut self, id: u64, hops: Hops) -> HopsRef { + if !self.version.has_announce_compression() { + return HopsRef::literal(hops); + } + // The decoder resolves against the chain this replaces, so it is a valid base. + let chain = self.hops_ref(&hops, self.live.next); + let entry = self.live.entries.get_mut(&id).expect("update for a live id"); + self.tails.remove(reversed(&entry.hops), id); + self.tails.insert(reversed(&hops), id); + entry.hops = hops; + chain + } + + /// An ANNOUNCE_END for a live `id`. + pub fn end(&mut self, id: u64) { + if !self.version.has_announce_compression() { + return; + } + let entry = self.live.entries.remove(&id).expect("end for a live id"); + self.heads.remove(entry.suffix.parts(), id); + self.tails.remove(reversed(&entry.hops), id); + } + + fn size>(&self, value: &T) -> usize { + let mut sizer = Sizer::default(); + value + .encode(&mut sizer, self.version) + .expect("sizing an encodable value"); + sizer.size + } + + /// The wire form of `suffix`, sized from its parts so only the winner is built. + fn path_ref(&self, suffix: &PathOwned, next: u64) -> PathRef<'static> { + let literal = || PathRef::literal(suffix.clone()); + let Some((keep, id)) = self.heads.longest(suffix.parts()) else { + return literal(); + }; + // Where the segments after `keep` start, sharing the suffix's buffer. + let s = suffix.as_str(); + let start = s.match_indices('/').nth(keep - 1).map_or(s.len(), |(i, _)| i + 1); + let rest = suffix.slice_from(start).to_owned(); + + let base = next - id; + let keep = keep as u64; + let size = self.size(&base) + self.size(&keep) + self.size(&rest); + match size < self.size(&0u64) * 2 + self.size(suffix) { + true => PathRef { base, keep, rest }, + false => literal(), + } + } + + /// The wire form of `hops`, sized from its parts so only the winner is built. + fn hops_ref(&self, hops: &Hops, next: u64) -> HopsRef { + let literal = || HopsRef::literal(hops.clone()); + let Some((keep, id)) = self.tails.longest(reversed(hops)) else { + return literal(); + }; + let head = &hops.as_slice()[..hops.len() - keep]; + + let base = next - id; + let keep = keep as u64; + let chain = + |hops: &[Hop]| self.size(&(hops.len() as u64)) + hops.iter().map(|hop| self.size(hop)).sum::(); + let size = self.size(&base) + chain(head) + self.size(&keep); + match size < self.size(&0u64) * 2 + chain(hops.as_slice()) { + true => HopsRef { + base, + literal: Hops::try_from(head.to_vec()).expect("a prefix of a valid chain is valid"), + keep, + }, + false => literal(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VERSION: Version = Version::Lite07; + + fn hops(ids: &[u64]) -> Hops { + Hops::try_from(ids.iter().map(|id| Hop::from_wire(*id).unwrap()).collect::>()).unwrap() + } + + fn path(base: u64, keep: u64, rest: &str) -> PathRef<'static> { + PathRef { + base, + keep, + rest: Path::new(rest).to_owned(), + } + } + + fn chain(base: u64, literal: &[u64], keep: u64) -> HopsRef { + HopsRef { + base, + literal: hops(literal), + keep, + } + } + + fn literal(suffix: &str, ids: &[u64]) -> (PathRef<'static>, HopsRef) { + (path(0, 0, suffix), chain(0, ids, 0)) + } + + #[test] + fn resolves_a_path_head_and_hop_tail() { + let mut decoder = AnnounceDecoder::default(); + let (p, h) = literal("pid/private/channel_1/health-1", &[1, 2, 3]); + decoder.start(p, h).unwrap(); + + // Three leading segments of the latest START, and its last two hops. + let (suffix, chain) = decoder.start(path(1, 3, "health-2"), chain(1, &[7], 2)).unwrap(); + assert_eq!(suffix.as_str(), "pid/private/channel_1/health-2"); + assert_eq!(chain, hops(&[7, 2, 3])); + } + + #[test] + fn keeps_the_whole_path_and_chain() { + let mut decoder = AnnounceDecoder::default(); + let (p, h) = literal("a/b", &[1, 2]); + decoder.start(p, h).unwrap(); + // A full keep with an empty rest names a route above the base's: not a + // duplicate, since the rest extends it. + let (suffix, chain) = decoder.start(path(1, 2, "c"), chain(1, &[], 2)).unwrap(); + assert_eq!(suffix.as_str(), "a/b/c"); + assert_eq!(chain, hops(&[1, 2])); + // An update copying its own full chain. + let (suffix, chain) = decoder.update(0, chain_ref(2, 2)).unwrap(); + assert_eq!(suffix.as_str(), "a/b"); + assert_eq!(chain, hops(&[1, 2])); + } + + fn chain_ref(base: u64, keep: u64) -> HopsRef { + chain(base, &[], keep) + } + + #[test] + fn a_new_stream_has_no_base() { + let mut decoder = AnnounceDecoder::default(); + assert!(matches!( + decoder.start(path(1, 0, "a"), chain(0, &[], 0)), + Err(Error::ProtocolViolation) + )); + let mut decoder = AnnounceDecoder::default(); + assert!(matches!( + decoder.start(path(0, 0, "a"), chain(1, &[], 0)), + Err(Error::ProtocolViolation) + )); + } + + #[test] + fn rejects_a_retired_or_unassigned_base() { + let mut decoder = AnnounceDecoder::default(); + let (p, h) = literal("a", &[1]); + decoder.start(p, h).unwrap(); + let (p, h) = literal("b", &[2]); + decoder.start(p, h).unwrap(); + decoder.end(0).unwrap(); + + // Id 0 is two back, and retired. + assert!(matches!( + decoder.start(path(2, 1, "x"), chain(0, &[], 0)), + Err(Error::ProtocolViolation) + )); + // Three back was never assigned. + assert!(matches!( + decoder.start(path(3, 0, "x"), chain(0, &[], 0)), + Err(Error::ProtocolViolation) + )); + // Id 1 is still live, so reusing it works after the END. + let (suffix, _) = decoder.start(path(1, 1, "x"), chain(1, &[], 1)).unwrap(); + assert_eq!(suffix.as_str(), "b/x"); + } + + #[test] + fn rejects_a_keep_longer_than_the_base() { + let mut decoder = AnnounceDecoder::default(); + let (p, h) = literal("a/b", &[1]); + decoder.start(p, h).unwrap(); + assert!(matches!( + decoder.start(path(1, 3, "x"), chain(0, &[], 0)), + Err(Error::ProtocolViolation) + )); + assert!(matches!( + decoder.update(0, chain_ref(1, 2)), + Err(Error::ProtocolViolation) + )); + } + + #[test] + fn rejects_a_resolved_chain_breaking_the_hop_rules() { + let mut decoder = AnnounceDecoder::default(); + let (p, h) = literal("a", &[1, 2]); + decoder.start(p, h).unwrap(); + // A literal hop the kept tail repeats. + assert!(matches!( + decoder.start(path(0, 0, "b"), chain(1, &[2], 1)), + Err(Error::ProtocolViolation) + )); + + // Anonymous hops may repeat across the two halves. + let (p, h) = literal("c", &[0]); + decoder.start(p, h).unwrap(); + let (_, resolved) = decoder.start(path(0, 0, "d"), chain(1, &[0], 1)).unwrap(); + assert_eq!(resolved, hops(&[0, 0])); + + // And the resolved chain is still capped. + // MAX_HOPS entries. + let full: Vec = (1..=32).collect(); + let (p, h) = literal("e", &full); + decoder.start(p, h).unwrap(); + assert!(matches!( + decoder.start(path(0, 0, "f"), chain(1, &[100], 32)), + Err(Error::ProtocolViolation) + )); + } + + #[test] + fn rejects_a_resolved_path_too_deep() { + let mut decoder = AnnounceDecoder::default(); + let deep = vec!["x"; Path::MAX_PARTS].join("/"); + let (p, h) = literal(&deep, &[]); + decoder.start(p, h).unwrap(); + assert!(matches!( + decoder.start(path(1, Path::MAX_PARTS as u64, "y"), chain(0, &[], 0)), + Err(Error::ProtocolViolation) + )); + } + + /// Encode a message with `encoder`, decode it with `decoder`, and check that the + /// resolved values match the input. + fn start(encoder: &mut AnnounceEncoder, decoder: &mut AnnounceDecoder, suffix: &str, ids: &[u64]) -> (u64, usize) { + let (id, wire_path, wire_hops) = encoder.start(Path::new(suffix).to_owned(), hops(ids)); + let size = encoder.size(&wire_path) + encoder.size(&wire_hops); + let (got_path, got_hops) = decoder.start(wire_path, wire_hops).unwrap(); + assert_eq!(got_path.as_str(), suffix); + assert_eq!(got_hops, hops(ids)); + (id.unwrap(), size) + } + + #[test] + fn encoder_output_decodes_to_its_input() { + let mut encoder = AnnounceEncoder::new(VERSION); + let mut decoder = AnnounceDecoder::default(); + + // Several origins behind a shared pair of relays. + let relays = [1u64 << 40, 1u64 << 41]; + let mut ids = Vec::new(); + for n in 0..40u64 { + let origin = 1000 + n % 4; + let suffix = format!("pid{}/private/channel_{}/stream-health-{}", n % 4, n % 7, n); + let (id, _) = start(&mut encoder, &mut decoder, &suffix, &[origin, relays[0], relays[1]]); + ids.push(id); + // Retire every third one so bases come and go. + if n % 3 == 0 { + let id = ids.remove(0); + encoder.end(id); + decoder.end(id).unwrap(); + } + } + + // Updates resolve against the chain they replace. + for id in ids.iter().copied().take(5) { + let chain = hops(&[2000, relays[1]]); + let wire = encoder.update(id, chain.clone()); + let (_, got) = decoder.update(id, wire).unwrap(); + assert_eq!(got, chain); + } + + // And a later START still matches what the encoder remembers. + start( + &mut encoder, + &mut decoder, + "pid0/private/channel_0/x", + &[1000, relays[0], relays[1]], + ); + } + + #[test] + fn encoder_picks_the_longest_shared_head_and_tail() { + let mut encoder = AnnounceEncoder::new(VERSION); + let mut decoder = AnnounceDecoder::default(); + start(&mut encoder, &mut decoder, "a/b/c", &[1u64 << 50, 1u64 << 51]); + // Shares a byte prefix with `a/b/d`, but no second segment. + start(&mut encoder, &mut decoder, "a/b-z", &[3]); + + let (_, wire_path, wire_hops) = encoder.start(Path::new("a/b/d").to_owned(), hops(&[5, 1u64 << 51])); + assert_eq!(wire_path, path(2, 2, "d")); + assert_eq!(wire_hops, chain(2, &[5], 1)); + } + + #[test] + fn encoder_sends_literal_when_nothing_is_shared() { + let mut encoder = AnnounceEncoder::new(VERSION); + let mut decoder = AnnounceDecoder::default(); + start(&mut encoder, &mut decoder, "a", &[1]); + let (_, wire_path, wire_hops) = encoder.start(Path::new("b").to_owned(), hops(&[2])); + assert_eq!(wire_path, PathRef::literal(Path::new("b"))); + assert_eq!(wire_hops, HopsRef::literal(hops(&[2]))); + } + + #[test] + fn lite06_assigns_ids_without_bases() { + let mut encoder = AnnounceEncoder::new(Version::Lite06); + let (id, _, _) = encoder.start(Path::new("a/b").to_owned(), hops(&[1])); + assert_eq!(id, Some(0)); + let (id, wire_path, wire_hops) = encoder.start(Path::new("a/c").to_owned(), hops(&[1])); + assert_eq!(id, Some(1)); + assert_eq!(wire_path, PathRef::literal(Path::new("a/c"))); + assert_eq!(wire_hops, HopsRef::literal(hops(&[1]))); + encoder.end(0); + assert_eq!(encoder.update(1, hops(&[1])), HopsRef::literal(hops(&[1]))); + } + + /// A compressed stream pinned byte for byte: `js/net/src/lite/announce.test.ts` + /// decodes the same hex, so a codec change on either side breaks both. + const GOLDEN: &str = "001600000a726f6f6d2f612f63616d000251116222000000000d0102036d696301017333010000020a00010180004444010000010101000d02010162020180005555010000"; + + fn golden() -> Vec { + use crate::fuzz::Announced; + vec![ + Announced::Start(Path::new("room/a/cam").to_owned(), hops(&[0x1111, 0x2222])), + Announced::Start(Path::new("room/a/mic").to_owned(), hops(&[0x3333, 0x2222])), + Announced::Update(0, hops(&[0x4444, 0x2222])), + Announced::End(1), + Announced::Start(Path::new("room/b").to_owned(), hops(&[0x5555, 0x2222])), + ] + } + + #[test] + fn golden_stream_is_pinned() { + let encoded = crate::fuzz::encode_announces(&golden(), true); + let hex: String = encoded.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(hex, GOLDEN); + assert_eq!(crate::fuzz::decode_announces(&encoded, true), golden()); + } + + /// The literal lite-07 stream `js/net` writes for the same announcements, which + /// never picks a base. + const JS_LITERAL: &str = "001600000a726f6f6d2f612f63616d000251116222000000001600000a726f6f6d2f612f6d6963000273336222000000020c0000028000444462220000000101010014000006726f6f6d2f620002800055556222000000"; + + #[test] + fn js_literal_stream_decodes() { + let bytes: Vec = (0..JS_LITERAL.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&JS_LITERAL[i..i + 2], 16).unwrap()) + .collect(); + assert_eq!(crate::fuzz::decode_announces(&bytes, true), golden()); + } +} diff --git a/rs/moq-net/src/lite/mod.rs b/rs/moq-net/src/lite/mod.rs index bafa81b58c..566f2c5b8a 100644 --- a/rs/moq-net/src/lite/mod.rs +++ b/rs/moq-net/src/lite/mod.rs @@ -5,6 +5,7 @@ //! Specification: [] mod announce; +mod compress; mod datagram; mod fetch; mod goaway; @@ -26,6 +27,7 @@ mod track; mod version; pub use announce::*; +pub(crate) use compress::*; #[allow(unused_imports)] pub use datagram::*; #[allow(unused_imports)] diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index c0ec29e3c2..668ca43a1b 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -581,8 +581,8 @@ struct AnnounceRun { // Lite06+: announce ids. Every `active` we send implicitly assigns the next // per-stream ordinal, and `ended` references the id instead of repeating the // path. Only announces that actually hit the wire get an id (filtered ones - // were never seen by the peer). - next_announce_id: u64, + // were never seen by the peer). Lite07 also picks compression bases here. + encoder: lite::AnnounceEncoder, // The routes the peer currently holds, keyed by the suffix under the requested // prefix. The value is the announce id on versions that assign them. live: HashMap>, @@ -603,7 +603,7 @@ impl AnnounceRun { prefix, self_origin, version, - next_announce_id: 0, + encoder: lite::AnnounceEncoder::new(version), live: HashMap::new(), phase: AnnouncePhase::Init, } @@ -648,14 +648,22 @@ impl AnnounceRun { Some((hops, cost)) } - /// The next announce id, on versions that assign them. - fn assign_id(&mut self) -> Option { - if !self.version.has_announce_id() { - return None; - } - let id = self.next_announce_id; - self.next_announce_id += 1; - Some(id) + /// Start advertising `suffix`, recording its announce id. + fn start( + &mut self, + stream: &mut Stream, + suffix: crate::PathOwned, + hops: Hops, + cost: crate::origin::Cost, + ) -> Result<(), Error> { + let (id, wire, hops) = self.encoder.start(suffix.clone(), hops); + self.live.insert(suffix, id); + stream.writer.buffer(&lite::AnnounceBroadcast::Active { + suffix: wire, + hops, + cost, + })?; + Ok(()) } /// Retract the peer's advertisement for `suffix`, if it holds one. @@ -671,7 +679,10 @@ impl AnnounceRun { }; tracing::debug!(route = %absolute, "unannounce"); match id { - Some(id) => stream.writer.buffer(&lite::AnnounceBroadcast::EndedId { id })?, + Some(id) => { + self.encoder.end(id); + stream.writer.buffer(&lite::AnnounceBroadcast::EndedId { id })? + } // An ended announce doesn't need hops; the receiver matches on path only. None => stream.writer.buffer(&lite::AnnounceBroadcast::Ended { suffix, @@ -749,11 +760,7 @@ impl AnnounceRun { }; stream.writer.buffer(&ok)?; for (suffix, hops, cost) in initial { - let id = self.assign_id(); - self.live.insert(suffix.clone(), id); - stream - .writer - .buffer(&lite::AnnounceBroadcast::Active { suffix, hops, cost })?; + self.start(stream, suffix, hops, cost)?; } } _ => { @@ -818,12 +825,18 @@ impl AnnounceRun { Some(&id) if lite::restart_supported(self.version) => { tracing::debug!(route = %absolute, "reannounce"); match id { - Some(id) => stream - .writer - .buffer(&lite::AnnounceBroadcast::Restart { id, hops, cost })?, - None => stream - .writer - .buffer(&lite::AnnounceBroadcast::Active { suffix, hops, cost })?, + Some(id) => { + let hops = self.encoder.update(id, hops); + stream + .writer + .buffer(&lite::AnnounceBroadcast::Restart { id, hops, cost })? + } + // lite-05: a duplicate ANNOUNCE, which assigns no id. + None => stream.writer.buffer(&lite::AnnounceBroadcast::Active { + suffix: lite::PathRef::literal(suffix), + hops: lite::HopsRef::literal(hops), + cost, + })?, } } // Pre-restart versions have no way to update a live @@ -831,11 +844,7 @@ impl AnnounceRun { Some(_) => {} None => { tracing::debug!(route = %absolute, "announce"); - let id = self.assign_id(); - self.live.insert(suffix.clone(), id); - stream - .writer - .buffer(&lite::AnnounceBroadcast::Active { suffix, hops, cost })?; + self.start(stream, suffix, hops, cost)?; } }, // The chain must not be forwarded (reflected, or full): retract @@ -1684,9 +1693,11 @@ mod announce_test { let mut slice = &buf[..]; let mut msgs = Vec::new(); while !slice.is_empty() { - msgs.push(own( - lite::AnnounceBroadcast::decode(&mut slice, VERSION).expect("announce message") - )); + msgs.push( + lite::AnnounceBroadcast::decode(&mut slice, VERSION) + .expect("announce message") + .into_owned(), + ); } self.cursor += buf.len(); msgs @@ -1699,24 +1710,6 @@ mod announce_test { } } - /// Re-own a decoded message so it can outlive the decode buffer. - fn own(msg: lite::AnnounceBroadcast<'_>) -> lite::AnnounceBroadcast<'static> { - match msg { - lite::AnnounceBroadcast::Active { suffix, hops, cost } => lite::AnnounceBroadcast::Active { - suffix: suffix.to_owned(), - hops, - cost, - }, - lite::AnnounceBroadcast::Ended { suffix, hops } => lite::AnnounceBroadcast::Ended { - suffix: suffix.to_owned(), - hops, - }, - lite::AnnounceBroadcast::EndedId { id } => lite::AnnounceBroadcast::EndedId { id }, - lite::AnnounceBroadcast::Restart { id, hops, cost } => lite::AnnounceBroadcast::Restart { id, hops, cost }, - lite::AnnounceBroadcast::Skipped => lite::AnnounceBroadcast::Skipped, - } - } - struct Harness { /// Held for the whole test: dropping the origin producer retracts every /// route under it, which would end the announce loop. @@ -1769,8 +1762,8 @@ mod announce_test { assert_eq!(wire.take_ok().active, 1, "expected one initial announce"); match wire.take_announces().as_slice() { [lite::AnnounceBroadcast::Active { suffix, hops, cost }] => { - assert_eq!(suffix.as_str(), "cam"); - assert_eq!(hops, &pub_hops()); + assert_eq!(suffix.rest.as_str(), "cam"); + assert_eq!(hops, &lite::HopsRef::literal(pub_hops())); assert_eq!(*cost, crate::origin::Cost::new(7)); } other => panic!("expected the initial announce, got {other:?}"), @@ -1796,7 +1789,7 @@ mod announce_test { .unwrap(); settle().await; match h.wire.take_announces().as_slice() { - [lite::AnnounceBroadcast::Active { suffix, .. }] => assert_eq!(suffix.as_str(), "mic"), + [lite::AnnounceBroadcast::Active { suffix, .. }] => assert_eq!(suffix.rest.as_str(), "mic"), other => panic!("expected an announce, got {other:?}"), } @@ -1821,7 +1814,7 @@ mod announce_test { settle().await; match h.wire.take_announces().as_slice() { [lite::AnnounceBroadcast::Restart { id: 0, hops, cost }] => { - assert_eq!(hops, &pub_hops()); + assert_eq!(hops, &lite::HopsRef::literal(pub_hops())); assert_eq!(*cost, crate::origin::Cost::new(3)); } other => panic!("expected a restart, got {other:?}"), @@ -1872,7 +1865,7 @@ mod announce_test { let mut wire = Wire { writes, cursor: 0 }; assert_eq!(wire.take_ok().active, 1, "only the clean route is announced"); match wire.take_announces().as_slice() { - [lite::AnnounceBroadcast::Active { suffix, .. }] => assert_eq!(suffix.as_str(), "local"), + [lite::AnnounceBroadcast::Active { suffix, .. }] => assert_eq!(suffix.rest.as_str(), "local"), other => panic!("expected the clean announce, got {other:?}"), } task.abort(); @@ -1888,7 +1881,7 @@ mod announce_test { .unwrap(); settle().await; match h.wire.take_announces().as_slice() { - [lite::AnnounceBroadcast::Active { suffix, .. }] => assert_eq!(suffix.as_str(), "mic"), + [lite::AnnounceBroadcast::Active { suffix, .. }] => assert_eq!(suffix.rest.as_str(), "mic"), other => panic!("expected ANNOUNCE_START, got {other:?}"), } h.assert_idle(); diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 4d66fe92a4..20987dbbc7 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -163,12 +163,13 @@ impl Subscriber { ) -> Result<(), Error> { match announce { lite::AnnounceBroadcast::Active { suffix, hops, cost } => { - let path = prefix.join(&suffix); - if self.version.has_announce_id() { + let (suffix, hops) = match self.version.has_announce_id() { // Every `active` assigns the next ordinal, even ones we drop locally. - run.announced_by_id.insert(run.next_announce_id, path.clone()); - run.next_announce_id += 1; - } + true => run.decoder.start(suffix, hops)?, + // Nothing references an announcement here, so there are no bases. + false => (suffix.rest.into_owned(), hops.literal), + }; + let path = prefix.join(&suffix); if lite::restart_supported(self.version) && !self.version.has_announce_id() && run.announced.contains(&path) @@ -204,18 +205,15 @@ impl Subscriber { lite::AnnounceBroadcast::EndedId { id } => { // Resolve and retire the id; an unknown or already-retired id is a // protocol violation. - let Some(path) = run.announced_by_id.remove(&id) else { - return Err(Error::ProtocolViolation); - }; + let path = prefix.join(&run.decoder.end(id)?); tracing::debug!(broadcast = %self.log_path(&path), "unannounced"); run.announced.retire(&path); } lite::AnnounceBroadcast::Restart { id, hops, cost } => { // Resolve the id; it stays live (the replacement reuses it). An unknown // or retired id is a protocol violation. - let Some(path) = run.announced_by_id.get(&id).cloned() else { - return Err(Error::ProtocolViolation); - }; + let (suffix, hops) = run.decoder.update(id, hops)?; + let path = prefix.join(&suffix); self.restart_announce( path, hops, @@ -1104,11 +1102,9 @@ struct PrefixRun { announced: Announced, // Lite06+: announce ids. Each received `active` implicitly assigns the next // per-stream ordinal; `ended`/`restart` reference it instead of repeating the - // path. Tracked even for announces we drop locally (reflected loops), since - // the sender doesn't know we dropped them. We never send a restart ourselves, - // but a peer may. - next_announce_id: u64, - announced_by_id: HashMap, + // path, and lite-07 bases name it too. Tracked even for announces we drop + // locally (reflected loops), since the sender doesn't know we dropped them. + decoder: lite::AnnounceDecoder, } impl AnnouncePrefix { @@ -1194,8 +1190,7 @@ impl AnnouncePrefix { responder_origin, link_cost, announced: Announced::default(), - next_announce_id: 0, - announced_by_id: HashMap::new(), + decoder: lite::AnnounceDecoder::default(), }; // Lite01/02 send the initial set as one ANNOUNCE_INIT message, so they diff --git a/rs/moq-net/src/lite/version.rs b/rs/moq-net/src/lite/version.rs index 72e4ffa30b..3c81f2a994 100644 --- a/rs/moq-net/src/lite/version.rs +++ b/rs/moq-net/src/lite/version.rs @@ -20,9 +20,10 @@ pub enum Version { /// Lite-07. Adds the hidden opt-in to ANNOUNCE_REQUEST: without it, a route with /// a `.`-prefixed segment below the requested prefix is left out. SUBSCRIBE_END /// carries the number of group streams opened, replacing SUBSCRIBE_DROP. - /// The wire format is still work-in-progress, so it is advertised over ALPN as - /// `moq-lite-07-wip` and only when explicitly requested; the default version sets - /// leave it out. + /// ANNOUNCE_START and ANNOUNCE_UPDATE may copy a path head or hop-chain tail from a + /// live announcement on the same stream. The wire format is still work-in-progress, + /// so it is advertised over ALPN as `moq-lite-07-wip` and only when explicitly + /// requested; the default version sets leave it out. Lite07, } @@ -194,6 +195,17 @@ impl Version { } } + /// Whether ANNOUNCE_START and ANNOUNCE_UPDATE may copy a path head or hop-chain tail + /// from a live announcement on the same stream. Added in lite-07. + #[allow(clippy::match_like_matches_macro)] + pub fn has_announce_compression(self) -> bool { + // Match form so future versions default forward (CLAUDE.md convention). + match self { + Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 => false, + _ => true, + } + } + /// Whether announcements carry the route cost: the marginal cost of pulling /// the broadcast via this route, accumulated per link. Added in lite-06. /// Older versions carry nothing, so a received route stays at zero and ranks diff --git a/rs/moq-net/src/path/mod.rs b/rs/moq-net/src/path/mod.rs index 70f0b342c4..21acabb44e 100644 --- a/rs/moq-net/src/path/mod.rs +++ b/rs/moq-net/src/path/mod.rs @@ -150,7 +150,7 @@ impl<'a> Path<'a> { } // A copy of this path skipping the first `n` bytes, reusing the shared buffer when possible. - fn slice_from(&'a self, n: usize) -> Path<'a> { + pub(crate) fn slice_from(&'a self, n: usize) -> Path<'a> { match &self.0 { Repr::Borrowed(s) => Path(Repr::Borrowed(&s[n..])), Repr::Shared { buf, start } => Path(Repr::Shared { diff --git a/rs/moq-tokio/tests/broadcast.rs b/rs/moq-tokio/tests/broadcast.rs index 3a0ddbacae..203cfaae4c 100644 --- a/rs/moq-tokio/tests/broadcast.rs +++ b/rs/moq-tokio/tests/broadcast.rs @@ -724,16 +724,29 @@ async fn next_announce(announcements: &mut moq_net::announce::Consumer) -> moq_n #[tracing_test::traced_test] #[tokio::test] async fn broadcast_moq_lite_06_announce_lifecycle() { + announce_lifecycle_test("moq-lite-06").await; +} + +/// The same lifecycle on lite-07, where every path shares its head with a live one, +/// so the announcements after the first copy it from a base that the retractions +/// keep moving. +#[tracing_test::traced_test] +#[tokio::test] +async fn broadcast_moq_lite_07_announce_lifecycle() { + announce_lifecycle_test("moq-lite-07-wip").await; +} + +async fn announce_lifecycle_test(version: &str) { let pub_origin = moq_tokio::origin::spawn(); // Announced before the client connects, so it rides the initial set. - let first = pub_origin.create_broadcast("first").expect("create broadcast"); + let first = pub_origin.create_broadcast("room/first").expect("create broadcast"); first.announce(Default::default()).expect("create broadcast"); let mut server_config = moq_tokio::listen::Config::default(); server_config.bind = Some("[::]:0".parse().unwrap()); server_config.tls.generate = vec!["localhost".into()]; - server_config.version = vec!["moq-lite-06".parse().unwrap()]; + server_config.version = vec![version.parse().unwrap()]; let server = server_config.init(Default::default()).expect("init server"); let mut server = server.listen().await.expect("failed to listen"); let addr = server.local_addr().expect("local addr"); @@ -744,7 +757,7 @@ async fn broadcast_moq_lite_06_announce_lifecycle() { let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); - client_config.version = vec!["moq-lite-06".parse().unwrap()]; + client_config.version = vec![version.parse().unwrap()]; let client = client_config.init(Default::default()).expect("init client"); let url: url::Url = format!("moqt://localhost:{}", addr.port()).parse().unwrap(); @@ -764,28 +777,28 @@ async fn broadcast_moq_lite_06_announce_lifecycle() { // The initial set: "first" was announced before the session existed. let update = next_announce(&mut announcements).await; - assert_eq!(update.prefix.as_str(), "first"); + assert_eq!(update.prefix.as_str(), "room/first"); assert!(update.kind.is_active(), "expected initial announce"); // A live announce after the initial set. - let second = pub_origin.create_broadcast("second").expect("create broadcast"); + let second = pub_origin.create_broadcast("room/second").expect("create broadcast"); second.announce(Default::default()).expect("create broadcast"); let update = next_announce(&mut announcements).await; - assert_eq!(update.prefix.as_str(), "second"); + assert_eq!(update.prefix.as_str(), "room/second"); assert!(update.kind.is_active(), "expected live announce"); // Unannounce: retracted by announce id on the wire. Dropping the announcement // retracts the route; the broadcast's own end is independent. second.finish(); let update = next_announce(&mut announcements).await; - assert_eq!(update.prefix.as_str(), "second"); + assert_eq!(update.prefix.as_str(), "room/second"); assert!(!update.kind.is_active(), "expected retraction"); // Re-announce the same path: a fresh announce assigning a fresh id. - let _second = pub_origin.create_broadcast("second").expect("create broadcast"); + let _second = pub_origin.create_broadcast("room/second").expect("create broadcast"); _second.announce(Default::default()).expect("create broadcast"); let update = next_announce(&mut announcements).await; - assert_eq!(update.prefix.as_str(), "second"); + assert_eq!(update.prefix.as_str(), "room/second"); assert!(update.kind.is_active(), "expected re-announce"); // Replace the route at "first": retract the original (retiring its announce @@ -793,19 +806,19 @@ async fn broadcast_moq_lite_06_announce_lifecycle() { // Await the retraction first so the events cannot coalesce away. first.finish(); let update = next_announce(&mut announcements).await; - assert_eq!(update.prefix.as_str(), "first"); + assert_eq!(update.prefix.as_str(), "room/first"); assert!(!update.kind.is_active(), "expected the replaced retraction"); - let _replacement = pub_origin.create_broadcast("first").expect("create replacement"); + let _replacement = pub_origin.create_broadcast("room/first").expect("create replacement"); _replacement.announce(Default::default()).expect("create replacement"); let update = next_announce(&mut announcements).await; - assert_eq!(update.prefix.as_str(), "first"); + assert_eq!(update.prefix.as_str(), "room/first"); assert!(update.kind.is_active(), "expected the replacement announce"); // A sentinel proves no stray event for "first" snuck in behind the replacement. - let _sentinel = pub_origin.create_broadcast("sentinel").expect("create broadcast"); + let _sentinel = pub_origin.create_broadcast("room/sentinel").expect("create broadcast"); _sentinel.announce(Default::default()).expect("create broadcast"); let update = next_announce(&mut announcements).await; - assert_eq!(update.prefix.as_str(), "sentinel"); + assert_eq!(update.prefix.as_str(), "room/sentinel"); assert!(update.kind.is_active(), "expected sentinel announce"); drop(connection); From df392d8e5917aff7b22b5641c6c61a2b48822efe Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 18:31:22 -0700 Subject: [PATCH 20/45] fix(relay): bind every listener in Relay::load so tests read ports back (#4215) Co-authored-by: Claude Opus 5.5 --- doc/bin/relay/index.md | 5 +- rs/moq-cli/src/auth.rs | 21 +---- rs/moq-relay/src/internal.rs | 70 +++++++------- rs/moq-relay/src/relay.rs | 28 +++--- rs/moq-relay/tests/auth_lifetime.rs | 43 +++------ rs/moq-relay/tests/cluster_unknown.rs | 24 +---- rs/moq-relay/tests/embed.rs | 87 ++++------------- rs/moq-relay/tests/hidden_cluster.rs | 29 +++--- rs/moq-relay/tests/runtime_uring.rs | 41 +++----- rs/moq-relay/tests/runtime_workers.rs | 23 +---- rs/moq-relay/tests/session_revalidate.rs | 44 +++------ rs/moq-relay/tests/shutdown_signal.rs | 38 ++------ rs/moq-tokio/src/server.rs | 114 +++++++++++++++++------ 13 files changed, 229 insertions(+), 338 deletions(-) diff --git a/doc/bin/relay/index.md b/doc/bin/relay/index.md index 0d449cb0bd..969ab37397 100644 --- a/doc/bin/relay/index.md +++ b/doc/bin/relay/index.md @@ -66,8 +66,9 @@ let web = relay.web().routes().route("/hello", get(|| async { "hello" })); relay.with_web(web).run().await?; ``` -`Relay::load` binds QUIC and web sockets. Read their actual addresses with -`quic_addr()` and `web_addrs()`, including ports assigned for `:0`. Clone +`Relay::load` binds every socket, so a taken port fails there. Read the actual +addresses with `quic_addr()`, `tcp_addr()`, `web_addrs()`, and +`internal().addr()`, including ports assigned for `:0`. Clone `ready()` before spawning `run`, then await `ready.wait()` when startup must finish before other workers begin. `config()` returns the resolved settings; `cluster().id()` returns the chosen origin ID. `with_listeners()` registers an diff --git a/rs/moq-cli/src/auth.rs b/rs/moq-cli/src/auth.rs index 6eeced4201..3061bbc1ea 100644 --- a/rs/moq-cli/src/auth.rs +++ b/rs/moq-cli/src/auth.rs @@ -823,30 +823,19 @@ mod tests { request.query = Some("jwt=secret".into()); let _reg = sessions.register(request); - let listen = std::net::TcpListener::bind("127.0.0.1:0") - .expect("probe bind") - .local_addr() - .expect("probe addr"); let mut internal_config = moq_relay::internal::Config::default(); - internal_config.listen = Some(listen); + internal_config.listen = Some("127.0.0.1:0".parse().unwrap()); let internal = moq_relay::internal::Internal::new(internal_config, moq_tokio::moq_net::stats::Registry::disabled()) - .with_sessions(sessions); + .with_sessions(sessions) + .bind() + .expect("bind internal listener"); + let listen = internal.addr().expect("internal listener is configured"); tokio::spawn(async move { let _ = internal.run().await; }); let url = format!("http://{listen}"); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); - while reqwest::Client::new() - .get(format!("{url}/health")) - .send() - .await - .is_err() - { - assert!(std::time::Instant::now() < deadline, "internal listener never came up"); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } run(&["moq", "auth", "revalidate", "--internal-url", &url, "--id", "abc"]) .await diff --git a/rs/moq-relay/src/internal.rs b/rs/moq-relay/src/internal.rs index c3e576a8a8..fa97f3dfc8 100644 --- a/rs/moq-relay/src/internal.rs +++ b/rs/moq-relay/src/internal.rs @@ -89,6 +89,8 @@ pub struct Internal { health: moq_tokio::accept::Health, listeners: Vec, uring: Vec, + listener: Option, + addr: Option, } #[derive(Clone)] @@ -122,9 +124,31 @@ impl Internal { health, listeners, uring: Vec::new(), + listener: None, + addr: None, } } + /// Bind the configured listener now, so [`addr`](Self::addr) reports an ephemeral port before serving. + pub fn bind(mut self) -> anyhow::Result { + if let Some(listen) = self.config.listen + && self.listener.is_none() + { + let listener = moq_tokio::bind::tcp(listen).context("failed to bind internal listener")?; + let addr = listener + .local_addr() + .context("failed to resolve internal bind address")?; + self.addr = Some(addr); + self.listener = Some(listener); + } + Ok(self) + } + + /// The bound address after [`bind`](Self::bind) or [`crate::Relay::load`], if configured. + pub fn addr(&self) -> Option { + self.addr + } + /// Report other listeners' accept health at `/metrics`. /// /// Takes an iterator so the accessors feed it directly, however many listeners @@ -220,18 +244,7 @@ impl Internal { /// resolves), so it drops cleanly into a `select!` as a disabled no-op - /// mirroring how the relay treats other optional services. pub async fn serve(self, app: Router) -> anyhow::Result<()> { - let listener = self.bind()?; - self.serve_bound(app, listener).await - } - - pub(crate) fn bind(&self) -> anyhow::Result> { - self.config - .listen - .map(|listen| moq_tokio::bind::tcp(listen).context("failed to bind internal listener")) - .transpose() - } - - pub(crate) async fn serve_bound(self, app: Router, listener: Option) -> anyhow::Result<()> { + let Internal { listener, health, .. } = self.bind()?; let Some(listener) = listener else { std::future::pending::<()>().await; return Ok(()); @@ -241,7 +254,7 @@ impl Internal { // that single top-level layer, matching `Web::serve` / `Cluster::run`. // No accept-time work: the ops router never hands a connection to qmux, so // capturing a descriptor per health check would spend one for nothing. - crate::listener::server(listener, self.health, DefaultAcceptor::new())? + crate::listener::server(listener, health, DefaultAcceptor::new())? .serve(app.into_make_service()) .await?; Ok(()) @@ -803,32 +816,23 @@ mod tests { /// and forking both the socket options and the disabled-listener contract. #[tokio::test] async fn serve_hosts_merged_routes_alongside_the_defaults() { - // A throwaway bind picks a free port, released before `serve` claims it - // for real (`bind::tcp` sets SO_REUSEADDR, and nothing ever connected). - let listen = std::net::TcpListener::bind("127.0.0.1:0") - .expect("probe bind") - .local_addr() - .expect("probe addr"); - - let internal = Internal::new(Config { listen: Some(listen) }, moq_net::stats::Registry::disabled()); + let listen = Some("127.0.0.1:0".parse().unwrap()); + let internal = Internal::new(Config { listen }, moq_net::stats::Registry::disabled()) + .bind() + .expect("bind internal listener"); + let addr = internal.addr().expect("internal listener is configured"); let app = internal .routes() .merge(Router::new().route("/embedder", get(async || "embedded\n"))); let server = tokio::spawn(internal.serve(app)); - // `serve` binds inside the task, so poll rather than assume it is up the - // instant the spawn returns. let client = reqwest::Client::new(); - let url = format!("http://{listen}"); - let mut embedder = None; - for _ in 0..200 { - if let Ok(res) = client.get(format!("{url}/embedder")).send().await { - embedder = Some(res); - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - let embedder = embedder.expect("internal listener never accepted a connection"); + let url = format!("http://{addr}"); + let embedder = client + .get(format!("{url}/embedder")) + .send() + .await + .expect("embedder request"); assert_eq!(embedder.status(), reqwest::StatusCode::OK); assert_eq!(embedder.text().await.expect("embedder body"), "embedded\n"); diff --git a/rs/moq-relay/src/relay.rs b/rs/moq-relay/src/relay.rs index ba04e67570..082ab27978 100644 --- a/rs/moq-relay/src/relay.rs +++ b/rs/moq-relay/src/relay.rs @@ -71,7 +71,7 @@ impl Ready { pub struct Relay { ready: tokio::sync::watch::Sender, config: Config, - server: moq_tokio::Server, + server: moq_tokio::Listener, client: moq_tokio::Client, auth: auth::Auth, /// The sessions the embedder decides, until it takes them. `None` when the @@ -107,10 +107,10 @@ impl Relay { /// Assemble a relay from its configuration: bind the listeners, resolve /// auth, and build the cluster with its cache and stats attached. /// - /// This performs the side effects of starting up (binding sockets, reading - /// key material, spawning the cache governor), so a returned `Relay` is - /// ready to serve; nothing accepts a connection until [`Self::run`] drives - /// it. + /// This performs the side effects of starting up (binding every socket, + /// reading key material, spawning the cache governor), so a returned `Relay` + /// reports its ephemeral ports and a taken port fails here; no session is + /// admitted until [`Self::run`] drives it. pub async fn load(mut config: Config) -> anyhow::Result { config.resolve()?; let resolved_config = config.clone(); @@ -274,6 +274,10 @@ impl Relay { .with_versions(server_versions) .with_sessions(sessions.clone()) .bind()?; + // `bind`, not `listen`: the TCP/Unix accept loops handshake as soon as + // they run, and `load` is not yet willing to take a session. `run` + // starts them on its first accept. + let server = server.bind().await.context("failed to bind listeners")?; // Internal (ops) listener (plain HTTP, opt-in via `--internal-listen`) for // /metrics + /health + /nodes, separate from the customer-facing web server. No-op @@ -284,7 +288,8 @@ impl Relay { .with_cluster(&cluster) .with_sessions(sessions.clone()) .with_listeners(web.accept_health()) - .with_listeners(server.accept_health()); + .with_listeners(server.accept_health()) + .bind()?; // Bound but not yet serving: registering here (rather than after the // threads start) is what gives every worker a series from the first // scrape, including one that is about to fail setup. @@ -352,6 +357,11 @@ impl Relay { self.web.addrs() } + /// The bound plain TCP (qmux) address, or `None` when `listen.tcp.bind` is unset. + pub fn tcp_addr(&self) -> Option { + self.server.tcp_local_addr() + } + /// The client used to dial cluster peers. Already handed to [`Self::cluster`]; /// clone it for your own outbound dials so they share the connection config. pub fn client(&self) -> &moq_tokio::Client { @@ -505,10 +515,6 @@ impl Relay { .context("failed to start the io_uring QUIC workers")?; } - // Bind the shared TCP/Unix and optional internal sockets before reporting - // readiness, so an unavailable port cannot leave a falsely ready relay. - let server = server.listen().await.context("failed to bind listeners")?; - let internal_listener = internal.bind()?; ready.send_replace(true); #[cfg(unix)] @@ -616,7 +622,7 @@ impl Relay { let result = tokio::select! { Err(err) = started.run() => Err(err).context("cluster failed"), Err(err) = web.serve(web_routes) => Err(err).context("web server failed"), - Err(err) = internal.serve_bound(internal_routes, internal_listener) => Err(err).context("internal server failed"), + Err(err) = internal.serve(internal_routes) => Err(err).context("internal server failed"), Err(err) = serve_shared => Err(err).context("server failed"), Err(err) = quic_workers => Err(err).context("QUIC workers failed"), err = uring_failed => Err(err).context("io_uring QUIC workers failed"), diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index a4e421c8ad..63b2aa18a4 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -9,7 +9,6 @@ //! The last tests swap the server for an in-process decider answering //! `Admissions`, and prove the lease it drives reaches the session the same way. -use std::net::TcpListener; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; @@ -125,23 +124,6 @@ fn build_auth(url: url::Url) -> moq_relay::auth::Auth { .expect("auth init") } -/// Wait for a TCP listener to become dialable, or panic. -async fn wait_for_listener(port: u16) { - let deadline = std::time::Instant::now() + Duration::from_secs(5); - while tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_err() { - assert!( - std::time::Instant::now() < deadline, - "relay listener never became ready on port {port}" - ); - tokio::time::sleep(Duration::from_millis(25)).await; - } -} - -fn free_port() -> u16 { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - probe.local_addr().expect("local addr").port() -} - /// Stand up the relay's accept loop on a plain-TCP qmux listener and return the /// port plus an abort handle. async fn spawn_relay(auth: moq_relay::auth::Auth) -> (u16, tokio::task::JoinHandle<()>) { @@ -155,12 +137,12 @@ async fn spawn_relay_with( cluster: cluster::Cluster, ) -> (u16, tokio::task::JoinHandle<()>) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let port = free_port(); let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); let server = config.init(Default::default()).expect("server init"); let mut server = server.listen().await.expect("listen"); + let port = server.tcp_local_addr().expect("TCP listener is configured").port(); let handle = tokio::spawn(async move { let mut id = 0; @@ -173,7 +155,6 @@ async fn spawn_relay_with( } }); - wait_for_listener(port).await; (port, handle) } @@ -181,7 +162,6 @@ async fn spawn_relay_with( /// port plus an abort handle. async fn spawn_ws_relay(auth: moq_relay::auth::Auth) -> (u16, tokio::task::JoinHandle<()>) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let port = free_port(); let cluster = cluster::Cluster::new(cluster::Options::default()).expect("cluster init"); // Stream listeners bind lazily, so this server never opens a socket; only @@ -196,14 +176,16 @@ async fn spawn_ws_relay(auth: moq_relay::auth::Auth) -> (u16, tokio::task::JoinH let mut web_config = web::Config::default(); web_config.ws = true; - web_config.http.listen = Some(format!("127.0.0.1:{port}").parse().expect("parse listen")); - let web = web::Web::new(auth, cluster, certificates, web_config); + web_config.http.listen = Some("127.0.0.1:0".parse().expect("parse listen")); + let web = web::Web::new(auth, cluster, certificates, web_config) + .bind() + .expect("bind web listener"); + let port = web.addrs().http.expect("HTTP listener is configured").port(); let handle = tokio::spawn(async move { let _ = web.run().await; }); - wait_for_listener(port).await; (port, handle) } @@ -1023,26 +1005,25 @@ async fn a_fixed_lease_still_expires() { #[tokio::test] async fn a_relay_without_an_auth_source_is_decided_by_the_embedder() { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let config = |port: u16| { + let config = || { let mut config = Config::default(); - config.listen.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); + 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 }; - let untaken = Relay::load(config(free_port())).await.expect("load relay"); + let untaken = Relay::load(config()).await.expect("load relay"); let err = untaken.run().await.expect_err("nobody can authenticate"); assert!(err.to_string().contains("nobody can authenticate"), "{err}"); - let port = free_port(); - let mut relay = Relay::load(config(port)).await.expect("load relay"); + 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"); assert!(relay.admissions().is_none(), "taken once"); let decider = Decider::spawn(admissions, grant(Duration::from_secs(3600))); let trigger = relay.shutdown_trigger().clone(); let running = tokio::spawn(relay.run()); - wait_for_listener(port).await; let (pub_session, sub_session) = connect_and_round_trip(&room_url("tcp", port)).await; assert_eq!(decider.seen.lock().unwrap().len(), 2, "one admission per session"); diff --git a/rs/moq-relay/tests/cluster_unknown.rs b/rs/moq-relay/tests/cluster_unknown.rs index 9ad71cdde3..5409b85818 100644 --- a/rs/moq-relay/tests/cluster_unknown.rs +++ b/rs/moq-relay/tests/cluster_unknown.rs @@ -2,7 +2,7 @@ //! The relay records that publisher as `Hop::UNKNOWN`; reflected cluster paths //! must not replace it while gossiping around a redundant mesh. -use std::{net::TcpListener, time::Duration}; +use std::time::Duration; use moq_relay::{Config, Relay}; use url::Url; @@ -10,24 +10,15 @@ use url::Url; const TIMEOUT: Duration = Duration::from_secs(10); const PATH: &str = "opalin/cell-clumsy-octopus/cameras/left.hang"; -fn free_tcp_port() -> u16 { - TcpListener::bind("127.0.0.1:0") - .expect("bind probe") - .local_addr() - .expect("local addr") - .port() -} - async fn spawn_relay( id: u64, connect: Vec, cluster_version: Option, ) -> (u16, tokio::task::JoinHandle<()>) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let port = free_tcp_port(); let mut config = Config::default(); - config.listen.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse bind")); + config.listen.tcp.bind = Some("127.0.0.1:0".parse().expect("parse bind")); config.connect.bind = Some("127.0.0.1:0".parse().expect("parse client bind")); config.connect.tls.insecure = Some(true); config.connect.version.extend(cluster_version); @@ -38,20 +29,13 @@ async fn spawn_relay( config.cluster.id = Some(id); config.cluster.connect = connect.into_iter().map(moq_relay::cluster::Peer::new).collect(); + // `load` binds the TCP listener, so the port is ours before anyone dials it. let relay = Relay::load(config).await.expect("relay load"); + let port = relay.tcp_addr().expect("TCP listener is configured").port(); let handle = tokio::spawn(async move { let _ = relay.run().await; }); - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { - break; - } - assert!(std::time::Instant::now() < deadline, "relay {id} never became ready"); - tokio::time::sleep(Duration::from_millis(25)).await; - } - (port, handle) } diff --git a/rs/moq-relay/tests/embed.rs b/rs/moq-relay/tests/embed.rs index e65b5577e9..bd8b4bfbb8 100644 --- a/rs/moq-relay/tests/embed.rs +++ b/rs/moq-relay/tests/embed.rs @@ -9,8 +9,6 @@ #![cfg(feature = "_quic")] -#[cfg(target_os = "linux")] -use std::net::UdpSocket; use std::net::{SocketAddr, TcpListener}; use std::time::Duration; @@ -19,23 +17,6 @@ use moq_tokio::moq_net; const TIMEOUT: Duration = Duration::from_secs(10); -fn free_tcp_port() -> u16 { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - port -} - -/// Only used by the Linux-only worker/uring tests below; without the gate the -/// macOS test build fails `-D warnings` on dead code. -#[cfg(target_os = "linux")] -fn free_udp_port() -> u16 { - let probe = UdpSocket::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - port -} - fn certificate(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) { let key = rcgen::KeyPair::generate().expect("keypair"); let params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).expect("cert params"); @@ -59,19 +40,6 @@ fn client() -> moq_tokio::Client { config.init(Default::default()).expect("client init") } -async fn wait_for_http(port: u16) { - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { - return; - } - if std::time::Instant::now() >= deadline { - panic!("relay http listener never became ready on port {port}"); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } -} - async fn assert_owner_stopped(quic: SocketAddr, http: SocketAddr) { assert!( tokio::net::TcpStream::connect(http).await.is_err(), @@ -109,17 +77,17 @@ 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; - let http = config.web.http.listen.expect("http listener configured"); let relay = Relay::load(config.clone()).await.expect("load relay"); let quic = relay.quic_addr().expect("quic listener bound"); - assert_eq!(relay.web_addrs().http, Some(http)); + let http = relay.web_addrs().http.expect("http listener bound"); assert_eq!( relay.config().quic.max_streams, Some(moq_tokio::quic::DEFAULT_MAX_STREAMS) ); assert_eq!(relay.cluster().id(), relay.cluster().origin.hop().id()); - // Pin the replacement to the same ports, including a `:0` first bind. + // Pin the replacement to the same ports the `:0` first binds got. config.listen.bind = Some(moq_tokio::listen::Bind::Addr(quic)); + config.web.http.listen = Some(http); // The application handles: in-process workers publish into the origin the // QUIC sessions see, and the trigger stops the owner from any task. Both @@ -138,8 +106,6 @@ async fn embed_and_stop(mut config: Config) { .route("/plain-post", axum::routing::post(|| async { "plain" })); let running = tokio::spawn(relay.with_web(web).run()); ready.wait().await.expect("relay ready"); - - wait_for_http(http.port()).await; assert!(!running.is_finished(), "the relay stopped while serving"); let body = reqwest::get(format!("http://127.0.0.1:{}/embedded", http.port())) @@ -287,7 +253,6 @@ async fn embed_and_stop(mut config: Config) { assert_eq!(replacement.addr(), Some(quic), "replacement bound a different address"); let trigger = replacement.shutdown_trigger().clone(); let replacing = tokio::spawn(replacement.run()); - wait_for_http(http.port()).await; let health = reqwest::get(format!("http://127.0.0.1:{}/health", http.port())) .await .expect("replacement health") @@ -304,55 +269,39 @@ fn http_and_quic(cert: &std::path::Path, key: &std::path::Path, quic_bind: Strin config.listen.bind = Some(quic_bind.parse().unwrap()); config.listen.tls.cert = vec![cert.to_path_buf()]; config.listen.tls.key = vec![key.to_path_buf()]; - config.web.http.listen = Some(format!("127.0.0.1:{}", free_tcp_port()).parse().expect("parse http")); + config.web.http.listen = Some("127.0.0.1:0".parse().expect("parse http")); config.web.ws = false; public_auth(&mut config); config } -/// A late TCP bind failure must close readiness without reporting success. +/// An occupied TCP port fails `load`, before anything could report readiness. #[tokio::test] -async fn tcp_bind_failure_does_not_report_ready() { +async fn tcp_bind_failure_fails_load() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); let occupied = TcpListener::bind("127.0.0.1:0").expect("reserve TCP port"); let mut config = http_and_quic(&cert, &key, "127.0.0.1:0".into()); config.listen.tcp.bind = Some(occupied.local_addr().expect("reserved address")); - let relay = Relay::load(config).await.expect("load relay before TCP bind"); - let ready = relay.ready(); - let running = tokio::spawn(relay.run()); - - let result = tokio::time::timeout(TIMEOUT, ready.wait()) + let error = Relay::load(config) .await - .expect("readiness never resolved"); - assert!(result.is_err(), "failed TCP bind reported readiness"); - let error = running - .await - .expect("run panicked") - .expect_err("run accepted an occupied TCP port"); + .err() + .expect("load accepted an occupied TCP port"); assert!(error.to_string().contains("failed to bind listeners"), "{error:#}"); } -/// An occupied internal port must fail before the relay reports readiness. +/// An occupied internal port fails `load`, before anything could report readiness. #[tokio::test] -async fn internal_bind_failure_does_not_report_ready() { +async fn internal_bind_failure_fails_load() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); let occupied = TcpListener::bind("127.0.0.1:0").expect("reserve internal port"); let mut config = http_and_quic(&cert, &key, "127.0.0.1:0".into()); config.internal.listen = Some(occupied.local_addr().expect("reserved address")); - let relay = Relay::load(config).await.expect("load relay before internal bind"); - let ready = relay.ready(); - let running = tokio::spawn(relay.run()); - - let result = tokio::time::timeout(TIMEOUT, ready.wait()) - .await - .expect("readiness never resolved"); - assert!(result.is_err(), "failed internal bind reported readiness"); - let error = running + let error = Relay::load(config) .await - .expect("run panicked") - .expect_err("run accepted an occupied internal port"); + .err() + .expect("load accepted an occupied internal port"); assert!( error.to_string().contains("failed to bind internal listener"), "{error:#}" @@ -373,7 +322,7 @@ async fn shared_tokio_custom_route_and_quic() { async fn worker_tokio_custom_route_and_quic() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let mut config = http_and_quic(&cert, &key, format!("127.0.0.1:{}", free_udp_port())); + let mut config = http_and_quic(&cert, &key, "127.0.0.1:0".into()); config.runtime.workers = Some(2); config.runtime.pin = false; embed_and_stop(config).await; @@ -395,7 +344,7 @@ async fn uring_custom_route_and_quic() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let mut config = http_and_quic(&cert, &key, format!("127.0.0.1:{}", free_udp_port())); + let mut config = http_and_quic(&cert, &key, "127.0.0.1:0".into()); config.runtime.workers = Some(2); config.runtime.pin = false; config.runtime.io_uring = true; @@ -408,10 +357,10 @@ async fn embedded_listener_health_reaches_metrics() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); let mut config = http_and_quic(&cert, &key, "127.0.0.1:0".into()); - config.internal.listen = Some(format!("127.0.0.1:{}", free_tcp_port()).parse().unwrap()); + config.internal.listen = Some("127.0.0.1:0".parse().unwrap()); config.drain_timeout = Duration::ZERO; - let internal = config.internal.listen.unwrap(); let relay = Relay::load(config).await.expect("load relay"); + let internal = relay.internal().addr().expect("internal listener bound"); let ready = relay.ready(); let trigger = relay.shutdown_trigger().clone(); let health = moq_tokio::accept::Health::new("embedded"); diff --git a/rs/moq-relay/tests/hidden_cluster.rs b/rs/moq-relay/tests/hidden_cluster.rs index 2132607289..e49437910f 100644 --- a/rs/moq-relay/tests/hidden_cluster.rs +++ b/rs/moq-relay/tests/hidden_cluster.rs @@ -1,7 +1,6 @@ //! A cluster peer that predates the hidden opt-in still discovers the relay's //! `.`-named broadcasts, so a mixed-version mesh keeps `.internal/origins`. -use std::net::TcpListener; use std::time::Duration; use moq_relay::cluster::{self, Peer}; @@ -28,21 +27,16 @@ where .expect("test thread panicked"); } -/// A stream-only moq server on a free loopback TCP port, speaking only `version`. -fn bind_free_tcp_server(version: moq_net::Version) -> (u16, moq_tokio::Server) { - for _ in 0..20 { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - - let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); - config.version = vec![version]; - if let Ok(server) = config.init(Default::default()) { - return (port, server); - } - } - panic!("could not bind a free TCP port after 20 attempts"); +/// A stream-only moq server listening on an ephemeral loopback TCP port, +/// speaking only `version`, and that port. +async fn listen_tcp(version: moq_net::Version) -> (u16, moq_tokio::Listener) { + let mut config = moq_tokio::listen::Config::default(); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); + config.version = vec![version]; + let server = config.init(Default::default()).expect("server init"); + let listener = server.listen().await.expect("listen"); + let port = listener.tcp_local_addr().expect("TCP listener is configured").port(); + (port, listener) } #[test] @@ -58,11 +52,10 @@ async fn old_peer_keeps_hidden_paths(version: moq_net::Version) { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); tokio::time::timeout(TEST_TIMEOUT, async { - let (port, server) = bind_free_tcp_server(version); + let (port, mut server) = listen_tcp(version).await; let peer_origin = moq_tokio::origin::spawn(); let mut discovered = peer_origin.consume().with_hidden(true).announced(); let accept = tokio::spawn(async move { - let mut server = server.listen().await.expect("listen"); let request = server.accept().await.expect("cluster dial"); let session = request.with_subscriber(peer_origin).ok().await.expect("accept"); session.closed().await; diff --git a/rs/moq-relay/tests/runtime_uring.rs b/rs/moq-relay/tests/runtime_uring.rs index 6fbebee5c8..0d8be349f9 100644 --- a/rs/moq-relay/tests/runtime_uring.rs +++ b/rs/moq-relay/tests/runtime_uring.rs @@ -7,7 +7,7 @@ //! floor (GitHub-hosted CI), where it skips loudly. #![cfg(all(target_os = "linux", feature = "_uring"))] -use std::net::{SocketAddr, UdpSocket}; +use std::net::SocketAddr; use std::time::Duration; use moq_relay::{Config, Relay}; @@ -28,15 +28,6 @@ fn supported() -> bool { } } -/// A UDP port nothing is bound to. Every worker binds the same port, so this -/// cannot be `:0`. -fn free_udp_port() -> u16 { - let probe = UdpSocket::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - port -} - /// A CA on disk plus a certificate it signed, for the mTLS test. Returns the /// root, the client's certificate, and its key. fn signed_client(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) { @@ -78,9 +69,9 @@ fn certificate(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf /// A relay config serving QUIC from io_uring workers. Pinning is off because a /// CI container may restrict which cores it may run on. -fn uring_config(cert: &std::path::Path, key: &std::path::Path, port: u16) -> Config { +fn uring_config(cert: &std::path::Path, key: &std::path::Path) -> Config { let mut config = Config::default(); - config.listen.bind = Some(format!("127.0.0.1:{port}").parse().unwrap()); + config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); config.listen.tls.cert = vec![cert.to_path_buf()]; config.listen.tls.key = vec![key.to_path_buf()]; config.runtime.workers = Some(WORKERS); @@ -118,11 +109,8 @@ async fn uring_workers_serve_webtransport_and_raw_quic() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let relay = Relay::load(uring_config(&cert, &key, port)).await.expect("load relay"); - let expected: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); - assert_eq!(relay.addr(), Some(expected), "workers bound a different address"); + let relay = Relay::load(uring_config(&cert, &key)).await.expect("load relay"); + let port = relay.addr().expect("workers bound an address").port(); // The stock loop serves everything: the uring workers own QUIC, the shared // runtime owns auth and supervision. @@ -209,10 +197,9 @@ async fn uring_workers_report_link_facts() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let relay = Relay::load(uring_config(&cert, &key, port)).await.expect("load relay"); + let relay = Relay::load(uring_config(&cert, &key)).await.expect("load relay"); let local = relay.addr().expect("bound address"); + let port = local.port(); let sessions = relay.sessions().clone(); let running = tokio::spawn(relay.run()); @@ -278,9 +265,7 @@ async fn uring_workers_publish_their_certificate_fingerprint() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let relay = Relay::load(uring_config(&cert, &key, free_udp_port())) - .await - .expect("load relay"); + let relay = Relay::load(uring_config(&cert, &key)).await.expect("load relay"); // The relay's own web listener needs TLS; its router does not, and the // handler reads the same certificate handle either way. @@ -319,14 +304,13 @@ async fn an_mtls_client_authenticates_without_a_token() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); let (root, client_cert, client_key) = signed_client(dir.path()); - let port = free_udp_port(); - - let mut config = uring_config(&cert, &key, port); + let mut config = uring_config(&cert, &key); config.auth.public = Vec::new(); config.auth.url = Some(spawn_auth_server(mtls_only()).await); config.listen.tls.root = vec![root]; let relay = Relay::load(config).await.expect("load relay"); + let port = relay.addr().expect("workers bound an address").port(); let running = tokio::spawn(relay.run()); let client = || { @@ -414,11 +398,10 @@ async fn uring_workers_write_qlog_traces() { let (cert, key) = certificate(dir.path()); let traces = dir.path().join("qlog"); std::fs::create_dir(&traces).expect("create qlog dir"); - let port = free_udp_port(); - - let mut config = uring_config(&cert, &key, port); + let mut config = uring_config(&cert, &key); config.quic.qlog = Some(traces.clone()); let relay = Relay::load(config).await.expect("load relay"); + let port = relay.addr().expect("workers bound an address").port(); let running = tokio::spawn(relay.run()); // A real session, so a trace covers a handshake and application data diff --git a/rs/moq-relay/tests/runtime_workers.rs b/rs/moq-relay/tests/runtime_workers.rs index 7f6040ec1d..97cce1fd36 100644 --- a/rs/moq-relay/tests/runtime_workers.rs +++ b/rs/moq-relay/tests/runtime_workers.rs @@ -6,7 +6,6 @@ //! a build without one refuses `runtime.workers` at load. #![cfg(all(target_os = "linux", feature = "_quic"))] -use std::net::{SocketAddr, UdpSocket}; use std::time::Duration; use moq_relay::{Config, Relay}; @@ -15,17 +14,6 @@ use moq_tokio::moq_net; const TIMEOUT: Duration = Duration::from_secs(10); const WORKERS: u16 = 4; -/// A UDP port nothing is bound to. -/// -/// Every worker binds the same port, so this cannot be `:0`: each would pick an -/// ephemeral port of its own and they would not form a group. -fn free_udp_port() -> u16 { - let probe = UdpSocket::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - port -} - /// A self-signed certificate on disk. Workers refuse `listen.tls.generate`, /// since each would generate one of its own and serve a different identity. fn certificate(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) { @@ -43,9 +31,9 @@ fn certificate(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf /// /// Pinning is off because a CI container may restrict which cores it may run on, /// and none of these tests are about placement. -fn worker_config(cert: &std::path::Path, key: &std::path::Path, port: u16, workers: u16) -> Config { +fn worker_config(cert: &std::path::Path, key: &std::path::Path, workers: u16) -> Config { let mut config = Config::default(); - config.listen.bind = Some(format!("127.0.0.1:{port}").parse().unwrap()); + config.listen.bind = Some("127.0.0.1:0".parse().unwrap()); config.listen.tls.cert = vec![cert.to_path_buf()]; config.listen.tls.key = vec![key.to_path_buf()]; config.runtime.workers = Some(workers); @@ -82,13 +70,10 @@ async fn workers_serve_quic_and_share_one_origin() { let dir = tempfile::tempdir().expect("tempdir"); let (cert, key) = certificate(dir.path()); - let port = free_udp_port(); - - let config = worker_config(&cert, &key, port, WORKERS); + let config = worker_config(&cert, &key, WORKERS); let relay = Relay::load(config).await.expect("load relay"); - let expected: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); - assert_eq!(relay.addr(), Some(expected), "workers bound a different address"); + let port = relay.addr().expect("workers bound an address").port(); // The owner keeps the worker group; aborting this task is what joins them. let running = tokio::spawn(relay.run()); diff --git a/rs/moq-relay/tests/session_revalidate.rs b/rs/moq-relay/tests/session_revalidate.rs index 5d777ae0ba..77225c7802 100644 --- a/rs/moq-relay/tests/session_revalidate.rs +++ b/rs/moq-relay/tests/session_revalidate.rs @@ -5,7 +5,6 @@ //! an authority: the server's next word is what kicks, reties, or keeps the //! session. -use std::net::TcpListener; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime}; @@ -113,22 +112,6 @@ fn build_auth(url: url::Url) -> moq_relay::auth::Auth { .expect("auth init") } -async fn wait_for_listener(port: u16) { - let deadline = std::time::Instant::now() + Duration::from_secs(5); - while tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_err() { - assert!( - std::time::Instant::now() < deadline, - "listener never became ready on port {port}" - ); - tokio::time::sleep(Duration::from_millis(25)).await; - } -} - -fn free_port() -> u16 { - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - probe.local_addr().expect("local addr").port() -} - struct Fixture { url: url::Url, internal: url::Url, @@ -148,19 +131,18 @@ impl Fixture { async fn spawn(auth: moq_relay::auth::Auth, websocket: bool) -> Self { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let sessions = moq_relay::session::Registry::new(); - let internal_port = free_port(); - let internal_addr: std::net::SocketAddr = format!("127.0.0.1:{internal_port}").parse().unwrap(); let mut internal_config = internal::Config::default(); - internal_config.listen = Some(internal_addr); + internal_config.listen = Some("127.0.0.1:0".parse().unwrap()); let internal = internal::Internal::new(internal_config, moq_net::stats::Registry::disabled()) - .with_sessions(sessions.clone()); + .with_sessions(sessions.clone()) + .bind() + .expect("bind internal listener"); + let internal_addr = internal.addr().expect("internal listener is configured"); tokio::spawn(async move { let _ = internal.run().await; }); - wait_for_listener(internal_port).await; let (url, handle) = if websocket { - let port = free_port(); let cluster = cluster::Cluster::new(cluster::Options::default()).expect("cluster init"); let mut server_config = moq_tokio::listen::Config::default(); server_config.bind = Some("[::]:0".parse().unwrap()); @@ -171,19 +153,22 @@ impl Fixture { .certificates(); let mut web_config = web::Config::default(); web_config.ws = true; - web_config.http.listen = Some(format!("127.0.0.1:{port}").parse().expect("parse listen")); - let web = web::Web::new(auth, cluster, certificates, web_config).with_sessions(sessions.clone()); + web_config.http.listen = Some("127.0.0.1:0".parse().expect("parse listen")); + let web = web::Web::new(auth, cluster, certificates, web_config) + .with_sessions(sessions.clone()) + .bind() + .expect("bind web listener"); + let port = web.addrs().http.expect("HTTP listener is configured").port(); let handle = tokio::spawn(async move { let _ = web.run().await; }); - wait_for_listener(port).await; (format!("ws://127.0.0.1:{port}").parse().unwrap(), handle) } else { - let port = free_port(); let mut config = moq_tokio::listen::Config::default(); - config.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); + config.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); let server = config.init(Default::default()).expect("server init"); let mut server = server.listen().await.expect("listen"); + let port = server.tcp_local_addr().expect("TCP listener is configured").port(); let cluster = cluster::Cluster::new(cluster::Options::default()).expect("cluster init"); let sessions = sessions.clone(); let handle = tokio::spawn(async move { @@ -198,13 +183,12 @@ impl Fixture { }); } }); - wait_for_listener(port).await; (format!("tcp://127.0.0.1:{port}").parse().unwrap(), handle) }; Self { url, - internal: format!("http://127.0.0.1:{internal_port}").parse().unwrap(), + internal: format!("http://{internal_addr}").parse().unwrap(), sessions, _relay: handle, } diff --git a/rs/moq-relay/tests/shutdown_signal.rs b/rs/moq-relay/tests/shutdown_signal.rs index 23cf66e264..3f45135fd0 100644 --- a/rs/moq-relay/tests/shutdown_signal.rs +++ b/rs/moq-relay/tests/shutdown_signal.rs @@ -14,7 +14,7 @@ #![cfg(unix)] -use std::{net::TcpListener, time::Duration}; +use std::time::Duration; use moq_relay::{Config, Relay, auth}; @@ -51,10 +51,9 @@ async fn sigint_drains_sessions_before_exiting_inner() { let _interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()).expect("register SIGINT"); - let (port, config) = relay_config(); - let relay = Relay::load(config).await.expect("load relay"); + let relay = Relay::load(relay_config()).await.expect("load relay"); + let port = relay.tcp_addr().expect("TCP listener bound").port(); let run = tokio::spawn(relay.run()); - wait_listening(port).await; let mut client_config = moq_tokio::connect::Config::default(); client_config.tls.insecure = Some(true); @@ -106,37 +105,16 @@ async fn sigint_drains_sessions_before_exiting_inner() { ); } -/// A stream-only relay on a free loopback TCP port, fully public, with a short -/// drain window. Returns the port and the config to hand [`Relay::load`]. -fn relay_config() -> (u16, Config) { - // The listener is bound by `Relay::run`, not here, so this leaves the usual - // probe/bind gap; on loopback it is not worth retrying around. - let probe = TcpListener::bind("127.0.0.1:0").expect("bind probe"); - let port = probe.local_addr().expect("local addr").port(); - drop(probe); - +/// A stream-only relay on an ephemeral loopback TCP port, fully public, with a +/// short drain window, to hand [`Relay::load`]. +fn relay_config() -> Config { // Fully public auth: any no-JWT stream client gets the whole root. let mut auth = auth::Config::default(); auth.public = vec![moq_auth::Pattern::all()]; let mut config = Config::default(); - config.listen.tcp.bind = Some(format!("127.0.0.1:{port}").parse().expect("parse addr")); + config.listen.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); config.auth = auth; config.drain_timeout = DRAIN_TIMEOUT; - - (port, config) -} - -async fn wait_listening(port: u16) { - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - if tokio::net::TcpStream::connect(("127.0.0.1", port)).await.is_ok() { - break; - } - assert!( - std::time::Instant::now() < deadline, - "relay never became ready on port {port}" - ); - tokio::time::sleep(Duration::from_millis(25)).await; - } + config } diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index dacd68c671..c06beaf474 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -484,32 +484,63 @@ impl Server { health } + /// Bind whatever is still unbound and hand back the [`Listener`], without + /// accepting. + /// + /// Same terminal bind as [`listen`](Self::listen), including ephemeral ports + /// via [`Listener::tcp_local_addr`]. Stream accept loops stay stopped until + /// [`Listener::accept`], so nothing is read off the socket before the caller + /// is ready to take sessions. [`listen`](Self::listen) is this plus starting + /// those loops immediately. + /// + /// A bind failure is the error, not a silent `None` from a later accept. It + /// leaves nothing bound: the partially built `Listener` drops here, closing + /// whatever it opened. Build a fresh `Server` from the (cloneable) config to try + /// again. + // `mut` is only needed to bind the stream listeners, which a QUIC-only build has none of. + #[allow(unused_mut)] + pub async fn bind(mut self) -> crate::Result { + #[cfg(any(feature = "tcp", all(feature = "uds", unix)))] + self.streams.bind().await?; + Ok(Listener { server: self }) + } + /// Start serving: bind whatever is still unbound and hand back the /// [`Listener`] to accept sessions from. /// /// Terminal, and that is the point: it consumes the `Server`, so the builders /// above cannot run afterwards and every session is served the configuration /// this call captured. The QUIC socket is bound by [`crate::listen::Config::init`], but - /// the stream (`tcp`/`unix`) listeners need a runtime, so they bind here. + /// the stream (`tcp`/`unix`) listeners need a runtime, so they bind here, and + /// their accept loops start here too. Use [`bind`](Self::bind) to learn the + /// port without accepting yet. /// /// A bind failure is the error, not a silent `None` from a later accept. It /// leaves nothing bound: the partially built `Listener` drops here, closing /// whatever it opened. Build a fresh `Server` from the (cloneable) config to try /// again. - // `mut` is only needed to bind the stream listeners, which a QUIC-only build has none of. - #[allow(unused_mut)] - pub async fn listen(mut self) -> crate::Result { + pub async fn listen(self) -> crate::Result { + #[cfg(not(any(feature = "tcp", all(feature = "uds", unix))))] + { + self.bind().await + } #[cfg(any(feature = "tcp", all(feature = "uds", unix)))] { - // The stream listeners offer a wider version set than the server's own - // (see `stream_versions`), against the same configuration. - let server = self.moq.clone().with_versions(self.streams.versions.clone()); - self.streams.start(&server).await?; + let mut listener = self.bind().await?; + // Accept immediately, so a dial can finish its handshake before the + // caller polls `accept`. `bind` leaves that until the first accept. + let server = listener + .server + .moq + .clone() + .with_versions(listener.server.streams.versions.clone()); + listener.server.streams.serve(&server); + Ok(listener) } - Ok(Listener { server: self }) } - /// The body of [`Listener::accept`]; the listeners are already running. + /// The body of [`Listener::accept`]. Stream accept loops start here if + /// [`Server::bind`] left them stopped; [`Server::listen`] already started them. #[cfg(any( feature = "noq", feature = "iroh", @@ -518,6 +549,14 @@ impl Server { all(feature = "uds", unix) ))] async fn accept_next(&mut self) -> Option { + // A `bind` (rather than `listen`) leaves the stream loops stopped so an + // embedder can read the port before it is willing to take sessions. + // Starting them here, on the first accept, is that moment. + #[cfg(any(feature = "tcp", all(feature = "uds", unix)))] + { + let server = self.moq.clone().with_versions(self.streams.versions.clone()); + self.streams.serve(&server); + } loop { // The QUIC endpoint address, reported as a QUIC session's local side. #[cfg(feature = "noq")] @@ -825,10 +864,11 @@ impl StreamBind { /// The stream (`tcp`/`unix`) listeners owned by a [`Server`]. /// -/// Bound by [`Server::listen`] (they need a runtime), after which each runs an -/// accept loop in its own task and feeds completed [`Request`]s back over a channel. -/// The tasks own their listeners and are stopped when the [`Listener`] closes or -/// drops, so bound sockets don't linger. +/// Bound by [`Server::bind`] (they need a runtime). [`Server::listen`] then starts +/// each accept loop; a bind-only listener starts them on the first +/// [`Listener::accept`] instead. Each loop feeds completed [`Request`]s back over +/// a channel. The tasks own their listeners and are stopped when the [`Listener`] +/// closes or drops, so bound sockets don't linger. #[cfg(any(feature = "tcp", all(feature = "uds", unix)))] struct StreamListeners { binds: Vec, @@ -839,7 +879,10 @@ struct StreamListeners { versions: moq_net::Versions, #[cfg(all(feature = "uds", unix))] unix_allow: Option, - /// The address the TCP listener bound, once [`Self::start`] has run. + /// Bound sockets whose accept loops have not started. Empty once [`Self::serve`] + /// runs, and when nothing was configured. + pending: Vec, + /// The address the TCP listener bound, once [`Self::bind`] has run. #[cfg(feature = "tcp")] tcp_local_addr: Option, rx: Option>, @@ -863,6 +906,7 @@ impl StreamListeners { versions, #[cfg(all(feature = "uds", unix))] unix_allow, + pending: Vec::new(), #[cfg(feature = "tcp")] tcp_local_addr: None, rx: None, @@ -870,21 +914,17 @@ impl StreamListeners { } } - /// Bind every configured listener and spawn its accept loop. - /// - /// Called once, from [`Server::listen`]. Everything binds before anything is - /// spawned, so a failure part-way drops the listeners already opened and frees - /// their sockets there and then, rather than leaving accept loops to be aborted - /// at some later point. + /// Bind every configured listener without accepting. /// - /// `server` is the configuration each accepted session handshakes against, - /// already fixed by the time this runs. - async fn start(&mut self, server: &moq_net::Server) -> crate::Result<()> { + /// Everything binds before [`Self::serve`] starts a loop, so a failure + /// part-way drops the listeners already opened and frees their sockets there + /// and then, rather than leaving accept loops to be aborted later. + async fn bind(&mut self) -> crate::Result<()> { if self.binds.is_empty() { return Ok(()); } - let mut bound = Vec::with_capacity(self.binds.len()); + let mut pending = Vec::with_capacity(self.binds.len()); for (bind, health) in self.binds.drain(..).zip(self.health.iter().cloned()) { let alpns = self.versions.alpns(); match bind { @@ -900,7 +940,7 @@ impl StreamListeners { let local = listener.local_addr()?; tracing::info!(addr = %local, "listening (tcp)"); self.tcp_local_addr = Some(local); - bound.push(BoundListener::Tcp(listener)); + pending.push(BoundListener::Tcp(listener)); } #[cfg(all(feature = "uds", unix))] StreamBind::Unix(path) => { @@ -912,13 +952,26 @@ impl StreamListeners { // directory or uid/gid/pid allowlist is the access gate. listener.set_mode(0o666)?; tracing::info!(path = %path.display(), allow = ?self.unix_allow, "listening (unix)"); - bound.push(BoundListener::Unix(listener)); + pending.push(BoundListener::Unix(listener)); } } } + self.pending = pending; + Ok(()) + } + + /// Spawn an accept loop for every listener [`Self::bind`] opened. + /// + /// No-op when nothing is waiting: either nothing was configured, or the loops + /// are already running. `server` is the configuration each accepted session + /// handshakes against, fixed by the time this runs. + fn serve(&mut self, server: &moq_net::Server) { + if self.pending.is_empty() { + return; + } let (tx, rx) = tokio::sync::mpsc::channel(16); - for listener in bound { + for listener in self.pending.drain(..) { let task = match listener { #[cfg(feature = "tcp")] BoundListener::Tcp(listener) => spawn_tcp_loop(listener, server.clone(), tx.clone()), @@ -927,9 +980,7 @@ impl StreamListeners { }; self.tasks.push(task); } - self.rx = Some(rx); - Ok(()) } /// Yield the next stream [`Request`], or `None` if no listener is running: @@ -944,6 +995,9 @@ impl StreamListeners { /// Stop every accept loop and wait until its listener has released the socket. async fn shutdown(&mut self) { self.binds.clear(); + // Drop sockets that were bound but never accepted, so the address frees + // even when `run` never started the loops. + self.pending.clear(); self.rx = None; for task in self.tasks.drain(..) { task.abort(); From 50313f2b290cc12a44ff91472a39de2362974c6a Mon Sep 17 00:00:00 2001 From: "moq-bot[bot]" <186640430+moq-bot[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:35:09 -0700 Subject: [PATCH 21/45] chore: release (#4199) Co-authored-by: moq-bot[bot] <186640430+moq-bot[bot]@users.noreply.github.com> --- Cargo.lock | 54 +++++++++++++++++------------------ Cargo.toml | 38 ++++++++++++------------ rs/hang/CHANGELOG.md | 6 ++++ rs/hang/Cargo.toml | 2 +- rs/libmoq/CHANGELOG.md | 6 ++++ rs/libmoq/Cargo.toml | 2 +- rs/moq-archive/CHANGELOG.md | 6 ++++ rs/moq-archive/Cargo.toml | 2 +- rs/moq-audio/CHANGELOG.md | 6 ++++ rs/moq-audio/Cargo.toml | 2 +- rs/moq-auth/CHANGELOG.md | 6 ++++ rs/moq-auth/Cargo.toml | 2 +- rs/moq-binary/CHANGELOG.md | 6 ++++ rs/moq-binary/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-e2ee/CHANGELOG.md | 6 ++++ rs/moq-e2ee/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-hls/CHANGELOG.md | 6 ++++ rs/moq-hls/Cargo.toml | 2 +- rs/moq-json/CHANGELOG.md | 6 ++++ rs/moq-json/Cargo.toml | 2 +- rs/moq-loc/CHANGELOG.md | 6 ++++ rs/moq-loc/Cargo.toml | 2 +- rs/moq-mux/CHANGELOG.md | 6 ++++ rs/moq-mux/Cargo.toml | 2 +- rs/moq-net/CHANGELOG.md | 10 +++++++ rs/moq-net/Cargo.toml | 2 +- rs/moq-nvenc/CHANGELOG.md | 6 ++++ rs/moq-nvenc/Cargo.toml | 2 +- rs/moq-relay/CHANGELOG.md | 6 ++++ rs/moq-relay/Cargo.toml | 2 +- rs/moq-room/CHANGELOG.md | 6 ++++ rs/moq-room/Cargo.toml | 2 +- rs/moq-rtc/CHANGELOG.md | 6 ++++ rs/moq-rtc/Cargo.toml | 2 +- rs/moq-rtmp/CHANGELOG.md | 6 ++++ rs/moq-rtmp/Cargo.toml | 2 +- rs/moq-srt/CHANGELOG.md | 6 ++++ rs/moq-srt/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 +- rs/moq-transcode/CHANGELOG.md | 6 ++++ rs/moq-transcode/Cargo.toml | 2 +- rs/moq-uring/CHANGELOG.md | 6 ++++ rs/moq-uring/Cargo.toml | 2 +- rs/moq-video/CHANGELOG.md | 6 ++++ rs/moq-video/Cargo.toml | 2 +- 56 files changed, 239 insertions(+), 73 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9276b6998b..73b7247c95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2835,7 +2835,7 @@ dependencies = [ [[package]] name = "hang" -version = "0.21.5" +version = "0.21.6" dependencies = [ "anyhow", "bytes", @@ -3884,7 +3884,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmoq" -version = "0.6.5" +version = "0.6.6" dependencies = [ "anyhow", "bytes", @@ -4155,7 +4155,7 @@ dependencies = [ [[package]] name = "moq-archive" -version = "0.0.5" +version = "0.0.6" dependencies = [ "async-trait", "bytes", @@ -4172,7 +4172,7 @@ dependencies = [ [[package]] name = "moq-audio" -version = "0.1.4" +version = "0.1.5" dependencies = [ "block2 0.6.2", "bytes", @@ -4203,7 +4203,7 @@ dependencies = [ [[package]] name = "moq-auth" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "aws-lc-rs", @@ -4251,7 +4251,7 @@ dependencies = [ [[package]] name = "moq-binary" -version = "0.1.4" +version = "0.1.5" dependencies = [ "bytes", "kio 0.6.0", @@ -4263,7 +4263,7 @@ dependencies = [ [[package]] name = "moq-boy" -version = "0.5.5" +version = "0.5.6" dependencies = [ "anyhow", "boytacean", @@ -4283,7 +4283,7 @@ dependencies = [ [[package]] name = "moq-cli" -version = "0.12.5" +version = "0.12.6" dependencies = [ "anyhow", "axum", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "moq-e2ee" -version = "0.0.5" +version = "0.0.6" dependencies = [ "aws-lc-rs", "base64 0.23.1", @@ -4340,7 +4340,7 @@ dependencies = [ [[package]] name = "moq-ffi" -version = "0.4.5" +version = "0.4.6" dependencies = [ "bytes", "getrandom 0.4.3", @@ -4374,7 +4374,7 @@ dependencies = [ [[package]] name = "moq-gst" -version = "0.4.5" +version = "0.4.6" dependencies = [ "anyhow", "bytes", @@ -4393,7 +4393,7 @@ dependencies = [ [[package]] name = "moq-hls" -version = "0.5.5" +version = "0.5.6" dependencies = [ "axum", "bytes", @@ -4417,7 +4417,7 @@ dependencies = [ [[package]] name = "moq-json" -version = "0.5.2" +version = "0.5.3" dependencies = [ "bytes", "criterion", @@ -4434,7 +4434,7 @@ dependencies = [ [[package]] name = "moq-loc" -version = "0.2.13" +version = "0.2.14" dependencies = [ "bytes", "moq-net", @@ -4452,7 +4452,7 @@ dependencies = [ [[package]] name = "moq-mux" -version = "0.10.5" +version = "0.10.6" dependencies = [ "anyhow", "base64 0.23.1", @@ -4489,7 +4489,7 @@ version = "0.20.0" [[package]] name = "moq-net" -version = "0.3.4" +version = "0.3.5" dependencies = [ "arrayvec", "bytes", @@ -4588,7 +4588,7 @@ dependencies = [ [[package]] name = "moq-nvenc" -version = "0.1.1" +version = "0.1.2" dependencies = [ "cudarc", "libloading 0.9.0", @@ -4606,7 +4606,7 @@ dependencies = [ [[package]] name = "moq-relay" -version = "0.15.5" +version = "0.15.6" dependencies = [ "anyhow", "axum", @@ -4649,7 +4649,7 @@ dependencies = [ [[package]] name = "moq-room" -version = "0.2.5" +version = "0.2.6" dependencies = [ "kio 0.6.0", "moq-auth", @@ -4663,7 +4663,7 @@ dependencies = [ [[package]] name = "moq-rtc" -version = "0.3.5" +version = "0.3.6" dependencies = [ "aws-lc-rs", "axum", @@ -4683,7 +4683,7 @@ dependencies = [ [[package]] name = "moq-rtmp" -version = "0.3.5" +version = "0.3.6" dependencies = [ "anyhow", "byteorder", @@ -4731,7 +4731,7 @@ dependencies = [ [[package]] name = "moq-srt" -version = "0.3.5" +version = "0.3.6" dependencies = [ "bytes", "futures", @@ -4747,7 +4747,7 @@ dependencies = [ [[package]] name = "moq-stats" -version = "0.2.5" +version = "0.2.6" dependencies = [ "futures", "moq-json", @@ -4762,7 +4762,7 @@ dependencies = [ [[package]] name = "moq-tokio" -version = "0.19.16" +version = "0.19.17" dependencies = [ "anyhow", "bytes", @@ -4815,7 +4815,7 @@ dependencies = [ [[package]] name = "moq-transcode" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "bytes", @@ -4834,7 +4834,7 @@ dependencies = [ [[package]] name = "moq-uring" -version = "0.0.6" +version = "0.0.7" dependencies = [ "anyhow", "bytes", @@ -4891,7 +4891,7 @@ dependencies = [ [[package]] name = "moq-video" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "ash", diff --git a/Cargo.toml b/Cargo.toml index bed70b2450..5f9dcd4431 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,7 +121,7 @@ dispatch2 = "0.3.1" flate2 = { version = "1.1", default-features = false } futures = "0.3" getrandom = { version = "0.4", features = ["wasm_js"] } -hang = { version = "0.21.5", path = "rs/hang" } +hang = { version = "0.21.6", path = "rs/hang" } hex = "0.4" # HMAC-SHA256 for the mDNS membership proofs (moq-tokio's `mdns` feature). hmac = "0.13" @@ -139,16 +139,16 @@ loom = { version = "0.7.2", features = ["futures"] } # DNS-SD advertisement and browsing for LAN peer discovery (moq-tokio's `mdns` feature). # `async` awaits the event channel instead of blocking a thread on it. mdns-sd = { version = "0.21", features = ["async"] } -moq-audio = { version = "0.1.4", path = "rs/moq-audio", default-features = false } -moq-auth = { version = "0.1.2", path = "rs/moq-auth" } -moq-binary = { version = "0.1.4", path = "rs/moq-binary" } +moq-audio = { version = "0.1.5", path = "rs/moq-audio", default-features = false } +moq-auth = { version = "0.1.3", path = "rs/moq-auth" } +moq-binary = { version = "0.1.5", path = "rs/moq-binary" } moq-flate = { version = "0.2.0", path = "rs/moq-flate" } -moq-hls = { version = "0.5.5", path = "rs/moq-hls", default-features = false } -moq-json = { version = "0.5.2", path = "rs/moq-json" } -moq-loc = { version = "0.2.13", path = "rs/moq-loc" } +moq-hls = { version = "0.5.6", path = "rs/moq-hls", default-features = false } +moq-json = { version = "0.5.3", path = "rs/moq-json" } +moq-loc = { version = "0.2.14", path = "rs/moq-loc" } moq-msf = { version = "0.5.0", path = "rs/moq-msf" } -moq-mux = { version = "0.10.5", path = "rs/moq-mux" } -moq-net = { version = "0.3.4", path = "rs/moq-net" } +moq-mux = { version = "0.10.6", path = "rs/moq-mux" } +moq-net = { version = "0.3.5", path = "rs/moq-net" } # The MoQ fork of noq (moq-dev/noq). iroh keeps upstream noq, so a build with the # iroh feature carries both stacks. moq-noq-proto = { version = "1.3.1", default-features = false } @@ -156,25 +156,25 @@ moq-noq-udp = "1.3.1" # NVENC bindings, forked from ViliamVadocz/nvidia-video-codec-sdk to dlopen the # driver at runtime. Compiles on any platform (macOS included) but only actually # used by moq-video on Linux. -moq-nvenc = { version = "0.1.1", path = "rs/moq-nvenc" } +moq-nvenc = { version = "0.1.2", path = "rs/moq-nvenc" } moq-pattern = { version = "0.1.0", path = "rs/moq-pattern" } -moq-relay = { version = "0.15.5", path = "rs/moq-relay", default-features = false } -moq-rtc = { version = "0.3.5", path = "rs/moq-rtc" } -moq-rtmp = { version = "0.3.5", path = "rs/moq-rtmp" } +moq-relay = { version = "0.15.6", path = "rs/moq-relay", default-features = false } +moq-rtc = { version = "0.3.6", path = "rs/moq-rtc" } +moq-rtmp = { version = "0.3.6", path = "rs/moq-rtmp" } moq-sock = { version = "0.1.0", path = "rs/moq-sock" } -moq-srt = { version = "0.3.5", path = "rs/moq-srt" } -moq-stats = { version = "0.2.5", path = "rs/moq-stats" } -moq-tokio = { version = "0.19.16", path = "rs/moq-tokio", default-features = false } +moq-srt = { version = "0.3.6", path = "rs/moq-srt" } +moq-stats = { version = "0.2.6", path = "rs/moq-stats" } +moq-tokio = { version = "0.19.17", 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. # VAAPI is opt-in everywhere; its decoder is hardware-validated, while its # encoder is not yet. -moq-transcode = { version = "0.1.4", path = "rs/moq-transcode", default-features = false } +moq-transcode = { version = "0.1.5", path = "rs/moq-transcode", default-features = false } # default-features off (the noq backend) so the consumer picks which QUIC # stack the io_uring path compiles; cargo features are additive, so a default-on # backend could not be opted out of. -moq-uring = { version = "0.0.6", path = "rs/moq-uring", default-features = false } +moq-uring = { version = "0.0.7", path = "rs/moq-uring", default-features = false } # In-tree fork of `v4l` with the videodev2.h bindings checked in, so moq-video's # `capture` and `v4l2` need no libclang or kernel headers. Linux only; an empty # stub elsewhere. @@ -187,7 +187,7 @@ moq-vaapi = "0.1.0" # `features = ["capture"]` to a consumer that ships in those bindings pulls the # whole device graph into every one of them. Codec features are independent of # that argument, so moq-ffi and libmoq opt NVIDIA, OpenH264, and VAAPI back in. -moq-video = { version = "0.1.4", path = "rs/moq-video", default-features = false } +moq-video = { version = "0.1.5", path = "rs/moq-video", default-features = false } nix = { version = "0.31.3", features = ["net", "socket", "uio"] } # Upstream noq-proto, only for iroh's controller factory types. noq-proto = { version = "1.2", default-features = false } diff --git a/rs/hang/CHANGELOG.md b/rs/hang/CHANGELOG.md index c547539b83..8eb3f06886 100644 --- a/rs/hang/CHANGELOG.md +++ b/rs/hang/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.21.6](https://github.com/moq-dev/moq/compare/hang-v0.21.5...hang-v0.21.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-json + ## [0.21.5](https://github.com/moq-dev/moq/compare/hang-v0.21.4...hang-v0.21.5) - 2026-09-25 ### Other diff --git a/rs/hang/Cargo.toml b/rs/hang/Cargo.toml index 2113563ee7..3a91b6cc15 100644 --- a/rs/hang/Cargo.toml +++ b/rs/hang/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.21.5" +version = "0.21.6" edition = "2024" rust-version.workspace = true diff --git a/rs/libmoq/CHANGELOG.md b/rs/libmoq/CHANGELOG.md index 8a8498ced7..729971df95 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.6](https://github.com/moq-dev/moq/compare/libmoq-v0.6.5...libmoq-v0.6.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-video, moq-json, hang, moq-mux, moq-tokio, moq-audio + ## [0.6.5](https://github.com/moq-dev/moq/compare/libmoq-v0.6.4...libmoq-v0.6.5) - 2026-09-25 ### Other diff --git a/rs/libmoq/Cargo.toml b/rs/libmoq/Cargo.toml index 118272a410..1333199898 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.5" +version = "0.6.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-archive/CHANGELOG.md b/rs/moq-archive/CHANGELOG.md index a1cfbf4762..32ca63360b 100644 --- a/rs/moq-archive/CHANGELOG.md +++ b/rs/moq-archive/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.6](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.5...moq-archive-v0.0.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.0.5](https://github.com/moq-dev/moq/compare/moq-archive-v0.0.4...moq-archive-v0.0.5) - 2026-09-25 ### Other diff --git a/rs/moq-archive/Cargo.toml b/rs/moq-archive/Cargo.toml index 538837409d..99608653d3 100644 --- a/rs/moq-archive/Cargo.toml +++ b/rs/moq-archive/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.5" +version = "0.0.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-audio/CHANGELOG.md b/rs/moq-audio/CHANGELOG.md index a2c05831c2..1f1528d686 100644 --- a/rs/moq-audio/CHANGELOG.md +++ b/rs/moq-audio/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.5](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.4...moq-audio-v0.1.5) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.1.4](https://github.com/moq-dev/moq/compare/moq-audio-v0.1.3...moq-audio-v0.1.4) - 2026-09-25 ### Fixed diff --git a/rs/moq-audio/Cargo.toml b/rs/moq-audio/Cargo.toml index 67de1334a0..8e2dd4c426 100644 --- a/rs/moq-audio/Cargo.toml +++ b/rs/moq-audio/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.4" +version = "0.1.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-auth/CHANGELOG.md b/rs/moq-auth/CHANGELOG.md index 4cb4d45f86..b765d35fab 100644 --- a/rs/moq-auth/CHANGELOG.md +++ b/rs/moq-auth/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.3](https://github.com/moq-dev/moq/compare/moq-auth-v0.1.2...moq-auth-v0.1.3) - 2026-09-26 + +### Other + +- *(auth)* wait on the recorded request instead of a fixed sleep ([#4194](https://github.com/moq-dev/moq/pull/4194)) + ## [0.1.2](https://github.com/moq-dev/moq/compare/moq-auth-v0.1.1...moq-auth-v0.1.2) - 2026-09-25 ### Fixed diff --git a/rs/moq-auth/Cargo.toml b/rs/moq-auth/Cargo.toml index 6be26b37a5..9c65a292ea 100644 --- a/rs/moq-auth/Cargo.toml +++ b/rs/moq-auth/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley"] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.2" +version = "0.1.3" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-binary/CHANGELOG.md b/rs/moq-binary/CHANGELOG.md index 122a0ff8ca..74f69c0200 100644 --- a/rs/moq-binary/CHANGELOG.md +++ b/rs/moq-binary/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.5](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.4...moq-binary-v0.1.5) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.1.4](https://github.com/moq-dev/moq/compare/moq-binary-v0.1.3...moq-binary-v0.1.4) - 2026-09-25 ### Other diff --git a/rs/moq-binary/Cargo.toml b/rs/moq-binary/Cargo.toml index 85dd3a5d71..f4b4ea44fb 100644 --- a/rs/moq-binary/Cargo.toml +++ b/rs/moq-binary/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.4" +version = "0.1.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-boy/CHANGELOG.md b/rs/moq-boy/CHANGELOG.md index 3bfe971696..bedc2bcc32 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.6](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.5...moq-boy-v0.5.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-video, moq-json, hang, moq-mux, moq-tokio, moq-audio + ## [0.5.5](https://github.com/moq-dev/moq/compare/moq-boy-v0.5.4...moq-boy-v0.5.5) - 2026-09-25 ### Other diff --git a/rs/moq-boy/Cargo.toml b/rs/moq-boy/Cargo.toml index 1b260e84e4..d2407abfc2 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.5" +version = "0.5.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-cli/CHANGELOG.md b/rs/moq-cli/CHANGELOG.md index 40cbbbf6c9..55370f5586 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.12.6](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.5...moq-cli-v0.12.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-video, moq-auth, hang, moq-mux, moq-tokio, moq-audio, moq-hls, moq-relay, moq-rtc, moq-rtmp, moq-srt, moq-transcode + ## [0.12.5](https://github.com/moq-dev/moq/compare/moq-cli-v0.12.4...moq-cli-v0.12.5) - 2026-09-25 ### Other diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index 032ef22831..a1739c6a86 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.12.5" +version = "0.12.6" 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-e2ee/CHANGELOG.md b/rs/moq-e2ee/CHANGELOG.md index 676857bed3..4172aa7f0b 100644 --- a/rs/moq-e2ee/CHANGELOG.md +++ b/rs/moq-e2ee/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.6](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.5...moq-e2ee-v0.0.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.0.5](https://github.com/moq-dev/moq/compare/moq-e2ee-v0.0.4...moq-e2ee-v0.0.5) - 2026-09-25 ### Other diff --git a/rs/moq-e2ee/Cargo.toml b/rs/moq-e2ee/Cargo.toml index fb37ee65ab..e87619e285 100644 --- a/rs/moq-e2ee/Cargo.toml +++ b/rs/moq-e2ee/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.5" +version = "0.0.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-ffi/CHANGELOG.md b/rs/moq-ffi/CHANGELOG.md index ca2afe7700..9d52f99f9e 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.6](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.5...moq-ffi-v0.4.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-video, moq-json, hang, moq-mux, moq-tokio, moq-audio + ## [0.4.5](https://github.com/moq-dev/moq/compare/moq-ffi-v0.4.4...moq-ffi-v0.4.5) - 2026-09-25 ### Added diff --git a/rs/moq-ffi/Cargo.toml b/rs/moq-ffi/Cargo.toml index 6e3d76ae70..7b822275cc 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.5" +version = "0.4.6" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-gst/CHANGELOG.md b/rs/moq-gst/CHANGELOG.md index 71a0d63210..5461b5432c 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.6](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.5...moq-gst-v0.4.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux, moq-tokio + ## [0.4.5](https://github.com/moq-dev/moq/compare/moq-gst-v0.4.4...moq-gst-v0.4.5) - 2026-09-25 ### Other diff --git a/rs/moq-gst/Cargo.toml b/rs/moq-gst/Cargo.toml index 2e81769e45..40439c0c0b 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.5" +version = "0.4.6" edition = "2024" rust-version.workspace = true publish = true diff --git a/rs/moq-hls/CHANGELOG.md b/rs/moq-hls/CHANGELOG.md index c4767cc0d5..967b0c349e 100644 --- a/rs/moq-hls/CHANGELOG.md +++ b/rs/moq-hls/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.6](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.5...moq-hls-v0.5.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.5.5](https://github.com/moq-dev/moq/compare/moq-hls-v0.5.4...moq-hls-v0.5.5) - 2026-09-25 ### Other diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml index 3a95a6abef..090727281d 100644 --- a/rs/moq-hls/Cargo.toml +++ b/rs/moq-hls/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.5" +version = "0.5.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-json/CHANGELOG.md b/rs/moq-json/CHANGELOG.md index 8e6ac917dc..eb07288e02 100644 --- a/rs/moq-json/CHANGELOG.md +++ b/rs/moq-json/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.3](https://github.com/moq-dev/moq/compare/moq-json-v0.5.2...moq-json-v0.5.3) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.5.2](https://github.com/moq-dev/moq/compare/moq-json-v0.5.1...moq-json-v0.5.2) - 2026-09-25 ### Other diff --git a/rs/moq-json/Cargo.toml b/rs/moq-json/Cargo.toml index 847ae81c4a..62944fcdb3 100644 --- a/rs/moq-json/Cargo.toml +++ b/rs/moq-json/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.5.2" +version = "0.5.3" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-loc/CHANGELOG.md b/rs/moq-loc/CHANGELOG.md index c7cd3ed095..247d789404 100644 --- a/rs/moq-loc/CHANGELOG.md +++ b/rs/moq-loc/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.14](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.13...moq-loc-v0.2.14) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.2.13](https://github.com/moq-dev/moq/compare/moq-loc-v0.2.12...moq-loc-v0.2.13) - 2026-09-25 ### Other diff --git a/rs/moq-loc/Cargo.toml b/rs/moq-loc/Cargo.toml index 1c07c79750..11d260f4be 100644 --- a/rs/moq-loc/Cargo.toml +++ b/rs/moq-loc/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.13" +version = "0.2.14" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index 1d5b8a25c4..bf54e97eb3 100644 --- a/rs/moq-mux/CHANGELOG.md +++ b/rs/moq-mux/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.10.6](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.5...moq-mux-v0.10.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-json, hang, moq-binary, moq-loc + ## [0.10.5](https://github.com/moq-dev/moq/compare/moq-mux-v0.10.4...moq-mux-v0.10.5) - 2026-09-25 ### Added diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index 88d5e3da40..f3a46c917e 100644 --- a/rs/moq-mux/Cargo.toml +++ b/rs/moq-mux/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.10.5" +version = "0.10.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-net/CHANGELOG.md b/rs/moq-net/CHANGELOG.md index 7fc73f3d32..607907fe5a 100644 --- a/rs/moq-net/CHANGELOG.md +++ b/rs/moq-net/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.5](https://github.com/moq-dev/moq/compare/moq-net-v0.3.4...moq-net-v0.3.5) - 2026-09-26 + +### Added + +- *(net)* count lite-07 group streams in SUBSCRIBE_END ([#4118](https://github.com/moq-dev/moq/pull/4118)) + +### Fixed + +- *(net)* accept EXPIRES in SUBSCRIBE_OK, PUBLISH_OK, and REQUEST_OK ([#4195](https://github.com/moq-dev/moq/pull/4195)) + ## [0.3.4](https://github.com/moq-dev/moq/compare/moq-net-v0.3.3...moq-net-v0.3.4) - 2026-09-25 ### Fixed diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index a064f2beca..7af52d3da3 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/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.4" +version = "0.3.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-nvenc/CHANGELOG.md b/rs/moq-nvenc/CHANGELOG.md index 3c433c654f..b5fd76cb57 100644 --- a/rs/moq-nvenc/CHANGELOG.md +++ b/rs/moq-nvenc/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.2](https://github.com/moq-dev/moq/compare/moq-nvenc-v0.1.1...moq-nvenc-v0.1.2) - 2026-09-26 + +### Fixed + +- *(nvenc)* commit rate changes and cleanup only once the driver accepts ([#4146](https://github.com/moq-dev/moq/pull/4146)) + ## [0.1.1](https://github.com/moq-dev/moq/compare/moq-nvenc-v0.1.0...moq-nvenc-v0.1.1) - 2026-09-25 ### Fixed diff --git a/rs/moq-nvenc/Cargo.toml b/rs/moq-nvenc/Cargo.toml index 4674d790c1..1f5d5643db 100644 --- a/rs/moq-nvenc/Cargo.toml +++ b/rs/moq-nvenc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "moq-nvenc" -version = "0.1.1" +version = "0.1.2" edition = "2021" license = "MIT" rust-version.workspace = true diff --git a/rs/moq-relay/CHANGELOG.md b/rs/moq-relay/CHANGELOG.md index 77d7452de9..f0ad948636 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.15.6](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.5...moq-relay-v0.15.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-auth, moq-tokio, moq-uring, moq-stats + ## [0.15.5](https://github.com/moq-dev/moq/compare/moq-relay-v0.15.4...moq-relay-v0.15.5) - 2026-09-25 ### Other diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 8ea12937ab..1dc1a0abcb 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.15.5" +version = "0.15.6" 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-room/CHANGELOG.md b/rs/moq-room/CHANGELOG.md index 3f3b26d5f6..b28e108eae 100644 --- a/rs/moq-room/CHANGELOG.md +++ b/rs/moq-room/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.6](https://github.com/moq-dev/moq/compare/moq-room-v0.2.5...moq-room-v0.2.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-auth, moq-json + ## [0.2.5](https://github.com/moq-dev/moq/compare/moq-room-v0.2.4...moq-room-v0.2.5) - 2026-09-25 ### Other diff --git a/rs/moq-room/Cargo.toml b/rs/moq-room/Cargo.toml index cca9dff114..2b0df1e037 100644 --- a/rs/moq-room/Cargo.toml +++ b/rs/moq-room/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.5" +version = "0.2.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtc/CHANGELOG.md b/rs/moq-rtc/CHANGELOG.md index 5fb7b1d958..e96565dce7 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.6](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.5...moq-rtc-v0.3.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.3.5](https://github.com/moq-dev/moq/compare/moq-rtc-v0.3.4...moq-rtc-v0.3.5) - 2026-09-25 ### Other diff --git a/rs/moq-rtc/Cargo.toml b/rs/moq-rtc/Cargo.toml index d0ede4ab36..ea23c0717f 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.5" +version = "0.3.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-rtmp/CHANGELOG.md b/rs/moq-rtmp/CHANGELOG.md index 97da0d525e..b5fb3d8b25 100644 --- a/rs/moq-rtmp/CHANGELOG.md +++ b/rs/moq-rtmp/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.6](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.5...moq-rtmp-v0.3.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, hang, moq-mux + ## [0.3.5](https://github.com/moq-dev/moq/compare/moq-rtmp-v0.3.4...moq-rtmp-v0.3.5) - 2026-09-25 ### Other diff --git a/rs/moq-rtmp/Cargo.toml b/rs/moq-rtmp/Cargo.toml index 9d538c0c00..1335fb73aa 100644 --- a/rs/moq-rtmp/Cargo.toml +++ b/rs/moq-rtmp/Cargo.toml @@ -8,7 +8,7 @@ repository = "https://github.com/moq-dev/moq" # src/rml/LICENSE and applies to that module regardless of which option you pick. license = "MIT OR Apache-2.0" -version = "0.3.5" +version = "0.3.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-srt/CHANGELOG.md b/rs/moq-srt/CHANGELOG.md index 035ee8269b..eeb7c8ce93 100644 --- a/rs/moq-srt/CHANGELOG.md +++ b/rs/moq-srt/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.6](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.5...moq-srt-v0.3.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-mux + ## [0.3.5](https://github.com/moq-dev/moq/compare/moq-srt-v0.3.4...moq-srt-v0.3.5) - 2026-09-25 ### Other diff --git a/rs/moq-srt/Cargo.toml b/rs/moq-srt/Cargo.toml index 7c881c0673..a6ed2225bc 100644 --- a/rs/moq-srt/Cargo.toml +++ b/rs/moq-srt/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.5" +version = "0.3.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-stats/CHANGELOG.md b/rs/moq-stats/CHANGELOG.md index da4c8aa3da..35cb95084d 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.6](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.5...moq-stats-v0.2.6) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-json + ## [0.2.5](https://github.com/moq-dev/moq/compare/moq-stats-v0.2.4...moq-stats-v0.2.5) - 2026-09-25 ### Other diff --git a/rs/moq-stats/Cargo.toml b/rs/moq-stats/Cargo.toml index 8647abedf2..3df8fb6278 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.5" +version = "0.2.6" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-tokio/CHANGELOG.md b/rs/moq-tokio/CHANGELOG.md index ed16002c1b..9feb940d26 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.17](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.16...moq-tokio-v0.19.17) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.19.16](https://github.com/moq-dev/moq/compare/moq-tokio-v0.19.15...moq-tokio-v0.19.16) - 2026-09-25 ### Fixed diff --git a/rs/moq-tokio/Cargo.toml b/rs/moq-tokio/Cargo.toml index 23f1008a84..14e0a53689 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.16" +version = "0.19.17" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-transcode/CHANGELOG.md b/rs/moq-transcode/CHANGELOG.md index 1e5792541a..ee90a8c0b6 100644 --- a/rs/moq-transcode/CHANGELOG.md +++ b/rs/moq-transcode/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.5](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.4...moq-transcode-v0.1.5) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net, moq-video, hang, moq-mux + ## [0.1.4](https://github.com/moq-dev/moq/compare/moq-transcode-v0.1.3...moq-transcode-v0.1.4) - 2026-09-25 ### Other diff --git a/rs/moq-transcode/Cargo.toml b/rs/moq-transcode/Cargo.toml index 0cde22ea92..f58c3ec02d 100644 --- a/rs/moq-transcode/Cargo.toml +++ b/rs/moq-transcode/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.4" +version = "0.1.5" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-uring/CHANGELOG.md b/rs/moq-uring/CHANGELOG.md index beea4c6ba5..80c3577805 100644 --- a/rs/moq-uring/CHANGELOG.md +++ b/rs/moq-uring/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.7](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.6...moq-uring-v0.0.7) - 2026-09-26 + +### Other + +- updated the following local packages: moq-net + ## [0.0.6](https://github.com/moq-dev/moq/compare/moq-uring-v0.0.5...moq-uring-v0.0.6) - 2026-09-25 ### Other diff --git a/rs/moq-uring/Cargo.toml b/rs/moq-uring/Cargo.toml index 35163a9583..10fce20851 100644 --- a/rs/moq-uring/Cargo.toml +++ b/rs/moq-uring/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.0.6" +version = "0.0.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-video/CHANGELOG.md b/rs/moq-video/CHANGELOG.md index 137a3e22f5..3914d39499 100644 --- a/rs/moq-video/CHANGELOG.md +++ b/rs/moq-video/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.1.5](https://github.com/moq-dev/moq/compare/moq-video-v0.1.4...moq-video-v0.1.5) - 2026-09-26 + +### Fixed + +- *(nvenc)* commit rate changes and cleanup only once the driver accepts ([#4146](https://github.com/moq-dev/moq/pull/4146)) + ## [0.1.4](https://github.com/moq-dev/moq/compare/moq-video-v0.1.3...moq-video-v0.1.4) - 2026-09-25 ### Other diff --git a/rs/moq-video/Cargo.toml b/rs/moq-video/Cargo.toml index 968a7c1fcb..a2b4fe45ee 100644 --- a/rs/moq-video/Cargo.toml +++ b/rs/moq-video/Cargo.toml @@ -5,7 +5,7 @@ authors = ["Luke Curley "] repository = "https://github.com/moq-dev/moq" license = "MIT OR Apache-2.0" -version = "0.1.4" +version = "0.1.5" edition = "2024" rust-version.workspace = true From b99cad9d5c869cf911e6c380a06a65f5a4ef8fd4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 19:04:35 -0700 Subject: [PATCH 22/45] fix(net): end a track with its session's error when the session dies (#4120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 Co-authored-by: Jakub Migdał Co-authored-by: Grok 4.7 --- js/net/src/error.ts | 25 ++ js/net/src/ietf/adapter.ts | 23 +- js/net/src/ietf/connection.ts | 2 +- js/net/src/ietf/publisher.test.ts | 3 +- js/net/src/ietf/subscriber.ts | 28 +- js/net/src/integration.test.ts | 99 ++++++- js/net/src/lite/connection.ts | 8 +- js/net/src/lite/subscriber.ts | 31 ++- js/net/src/mock.ts | 26 +- quest/m1/README.md | 1 - quest/m1/session-close.md | 1 - quest/m1/session-death-error.md | 55 ---- quest/m1/track-tail-interop.md | 5 - rs/moq-net/src/ietf/session.rs | 18 +- rs/moq-net/src/ietf/subscriber.rs | 142 ++++++++-- rs/moq-net/src/lite/session.rs | 4 + rs/moq-net/src/lite/subscriber.rs | 133 ++++++++- rs/moq-net/src/model/resume.rs | 193 ++++++++----- rs/moq-net/src/model/track.rs | 63 ++++- .../tests/subscription_end_integrity.rs | 263 ++++++++++++++++++ rs/moq-net/tests/support/mock.rs | 136 ++++++--- .../tests/subscription_end_integrity.rs | 188 +++++++++++++ 22 files changed, 1199 insertions(+), 248 deletions(-) delete mode 100644 quest/m1/session-death-error.md create mode 100644 rs/moq-net/tests/subscription_end_integrity.rs create mode 100644 rs/moq-tokio/tests/subscription_end_integrity.rs diff --git a/js/net/src/error.ts b/js/net/src/error.ts index 912fae64cd..04ad2b84c2 100644 --- a/js/net/src/error.ts +++ b/js/net/src/error.ts @@ -424,6 +424,31 @@ export function fromClose(info: WebTransportCloseInfo): Session | null { return new Session(code, { reason: info.reason }); } +/** + * The session's close as the error it ends everything with, carrying the peer's code. A clean + * close code is still an error here: whatever the close cut off did not end. + * + * @internal + */ +export function closeError(quic: WebTransport): Promise { + return quic.closed.then( + (info) => fromClose(info) ?? new Session(SessionCode.Cancel, { reason: info.reason }), + (err: unknown) => error(err), + ); +} + +/** + * Report a failure the session's close caused as the session's own error, which carries the + * peer's close code; any other failure passes through. + * + * @internal + */ +export async function sessionCause(quic: WebTransport | undefined, err: unknown): Promise { + const source = typeof err === "object" && err !== null ? (err as { source?: unknown }).source : undefined; + if (quic && source === "session") return closeError(quic); + return error(err); +} + /** * Coerce an unknown thrown value into an `Error`. * diff --git a/js/net/src/ietf/adapter.ts b/js/net/src/ietf/adapter.ts index fd16a5349d..b51e48d8da 100644 --- a/js/net/src/ietf/adapter.ts +++ b/js/net/src/ietf/adapter.ts @@ -1,4 +1,5 @@ import { Mutex } from "async-mutex"; +import { error, ProtocolViolation } from "../error.ts"; import { Reader, Stream, type Writer } from "../stream.ts"; import * as Varint from "../varint.ts"; import * as Namespace from "./namespace.ts"; @@ -246,6 +247,8 @@ export class ControlStreamAdapter implements Session { * Must be called after construction. Runs until the control stream closes. */ async run(): Promise { + // Why the virtual streams end: undefined only for a GOAWAY, which is not a failure. + let cause: Error | undefined; try { // v16: also accept real bidi streams (for SubscribeNamespace) if (this.version === Version.DRAFT_16) { @@ -254,7 +257,10 @@ export class ControlStreamAdapter implements Session { for (;;) { const done = await this.#reader.done(); - if (done) break; + if (done) { + cause = new ProtocolViolation("control stream closed"); + break; + } const typeId = await this.#reader.u53(); const size = await this.#reader.u16(); @@ -293,8 +299,11 @@ export class ControlStreamAdapter implements Session { break; } } + } catch (err: unknown) { + cause = error(err); + throw err; } finally { - this.close(); + this.close(cause); } } @@ -719,15 +728,19 @@ export class ControlStreamAdapter implements Session { } } - close() { + /** + * Ends every virtual stream: cleanly for a deliberate close, or with `err` when the + * control stream died under them, since every request riding it was cut off. + */ + close(err?: Error) { if (this.#closed) return; this.#closed = true; console.debug("adapter: close() called"); - // Close all virtual streams for (const entry of this.#streams.values()) { try { - entry.controller.close(); + if (err) entry.controller.error(err); + else entry.controller.close(); } catch { // Already closed } diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index e1d22f9bf8..bcbcf01abc 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -141,7 +141,7 @@ export class Connection implements Established { }); this.#solicit = solicit; this.#cluster = cluster; - this.#subscriber = new Subscriber({ session: this.#session, cluster, hidden }); + this.#subscriber = new Subscriber({ session: this.#session, quic, cluster, hidden }); registerWire(this, { consume: (path) => this.#subscriber.consume(path) }); void this.#run(); diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index a16ce4ccbc..76fca819a2 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -639,8 +639,9 @@ test("closing the session ends the unsolicited announce loop", async () => { // The session ends. The origin is untouched: it is shared, and other sessions keep using it. pair.server.close(); + // Ending with the session's error is ending too: the close fails its open streams. await Promise.race([ - loop, + loop.catch(() => undefined), new Promise((_resolve, reject) => setTimeout(() => reject(new Error("the announce loop outlived its session")), STREAM_WAIT), ), diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index b107b0ca23..0294b35e3d 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -2,7 +2,7 @@ import { race, Signal } from "@moq/signals"; import * as announce from "../announced.ts"; import * as broadcast from "../broadcast.ts"; import { BroadcastCache } from "../consume.ts"; -import { controlTimeout, error, ProtocolViolation, reason } from "../error.ts"; +import { closeError, controlTimeout, error, ProtocolViolation, reason, sessionCause } from "../error.ts"; import * as netGroup from "../group.ts"; import { Cost, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; import { hiddenBelow, hooks, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; @@ -97,6 +97,10 @@ function sees(filter: Filter, path: Path.Valid): boolean { export class Subscriber { #session: Session; + // The transport, so a request cut off by the session's close ends with the session's + // error. Optional for tests that drive a bare session. + #quic?: WebTransport; + // The Hop IDs this session declared; see {@link Cluster}. What the peer declared is what // says whether an advertisement carries a hop path, and ours is what a path looping back // to us contains. @@ -140,17 +144,21 @@ export class Subscriber { */ constructor({ session, + quic, cluster, hidden = false, }: { /** The session abstraction for bidi streams and request IDs. */ session: Session; + /** The transport the session runs on. */ + quic?: WebTransport; /** The Hop IDs the SETUP exchange settled (MoQ Cluster). */ cluster?: Cluster.Hops; /** Whether the peer understands the HIDDEN parameter (MoQ Hidden). */ hidden?: boolean; }) { this.#session = session; + this.#quic = quic; this.#cluster = cluster; this.#hidden = hidden; } @@ -479,10 +487,22 @@ export class Subscriber { return consumer; } + // The adapter is gone. If the transport has already closed, that close is the + // error. A still-open transport, such as a GOAWAY drain, has no peer code yet, + // so this does not wait for it. + async #closedSession(): Promise { + const quic = this.#quic; + if (!quic) return new Error("session closed"); + return Promise.race([ + closeError(quic), + new Promise((resolve) => queueMicrotask(() => resolve(new Error("session closed")))), + ]); + } + async #runSubscribe(broadcast: Path.Valid, request: track.Request) { const requestId = await this.#session.nextRequestId(); if (requestId === undefined) { - request.reject(new Error("session closed")); + request.reject(await this.#closedSession()); return; } @@ -533,7 +553,7 @@ export class Subscriber { console.debug(`subscribe ok: id=${requestId} broadcast=${broadcast} track=${request.name}`); } catch (err) { // A control request that timed out is not late content, so it carries its own code. - const e = err instanceof TimeoutError ? controlTimeout(err) : error(err); + const e = err instanceof TimeoutError ? controlTimeout(err) : await sessionCause(this.#quic, err); request.reject(e); console.warn( `subscribe error: id=${requestId} broadcast=${broadcast} track=${request.name} error=${reason(e)}`, @@ -617,7 +637,7 @@ export class Subscriber { stream.close(); console.debug(`subscribe close: id=${requestId} broadcast=${broadcast} track=${request.name}`); } catch (err) { - const e = error(err); + const e = await sessionCause(this.#quic, err); producer.close(e); stream.abort(e); console.warn( diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 3107cb2b14..aaed493344 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -9,7 +9,7 @@ import { connect as connectSession, type Established, } from "./connection/index.ts"; -import { StreamCode, StreamError, TooFarBehind } from "./error.ts"; +import { SessionCode, SessionError, StreamCode, StreamError, TooFarBehind } from "./error.ts"; import * as Ietf from "./ietf/index.ts"; import * as Lite from "./lite/index.ts"; import { createMockTransportPair } from "./mock.ts"; @@ -2218,3 +2218,100 @@ test("a handle serves a request under live/** over the wire", async () => { server.close(); origin.close(); }); + +// The peer's close code a killed session carries. +const DEATH = SessionCode(71); + +/** + * Serve one track with a group left open, kill the publisher's session once the subscriber has + * read into it, and return how the subscriber's track ended. + */ +async function runSessionDeath(protocol: string, version?: number): Promise { + const pair = createMockTransportPair(protocol); + const origin = new OriginProducer(); + + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { version, publish: origin.consume() }), + ]); + + const broadcast = publish(origin, Path.from("test")); + const serving = (async () => { + for (;;) { + const req = await wireOf(broadcast).requested(); + if (!req) break; + req.accept().appendGroup().writeString("head"); + } + })(); + + const remote = wireOf(client).consume(Path.from("test")); + const track = remote.track("video").subscribe(); + const group = await track.recvGroup(); + expect(await group?.readString()).toBe("head"); + + pair.server.close({ closeCode: DEATH, reason: "killed" }); + const closed = await withTimeout(Promise.resolve(track.closed), 2000, "the track never ended"); + + broadcast.close(); + await serving; + remote.close(); + client.close(); + server.close(); + origin.close(); + return closed; +} + +for (const [name, protocol, version] of [ + ["lite draft-03", Lite.ALPN_03, undefined], + ["lite draft-05", Lite.ALPN_05, undefined], + ["ietf draft-14", "", Ietf.Version.DRAFT_14], + ["ietf draft-17", Ietf.ALPN.DRAFT_17, undefined], +] as const) { + test(`integration: ${name} ends a track with its session's error`, async () => { + const closed = await runSessionDeath(protocol, version); + expect(closed).toBeInstanceOf(SessionError); + expect((closed as SessionError).code).toBe(DEATH); + }); +} + +// On lite-05+ the subscribe stream carries responses until its FIN, so a reset of it is how the +// publisher ends a subscription with an error, and the track ends with that error. +test("integration: lite draft-05 ends a track with the publisher's reset", async () => { + const pair = createMockTransportPair(Lite.ALPN_05); + const origin = new OriginProducer(); + + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); + + const broadcast = publish(origin, Path.from("test")); + const served: TrackProducer[] = []; + const serving = (async () => { + for (;;) { + const req = await wireOf(broadcast).requested(); + if (!req) break; + const producer = req.accept(); + producer.appendGroup().writeString("head"); + served.push(producer); + } + })(); + + const remote = wireOf(client).consume(Path.from("test")); + const track = remote.track("video").subscribe(); + const group = await track.recvGroup(); + expect(await group?.readString()).toBe("head"); + + const reset = StreamCode(70); + for (const producer of served) producer.close(new StreamError(reset)); + const closed = await withTimeout(Promise.resolve(track.closed), 2000, "the track never ended"); + expect(closed).toBeInstanceOf(StreamError); + expect((closed as StreamError).code).toBe(reset); + + broadcast.close(); + await serving; + remote.close(); + client.close(); + server.close(); + origin.close(); +}); diff --git a/js/net/src/lite/connection.ts b/js/net/src/lite/connection.ts index 75e681d6f8..4aec63c3a3 100644 --- a/js/net/src/lite/connection.ts +++ b/js/net/src/lite/connection.ts @@ -3,7 +3,7 @@ import type * as announce from "../announced.ts"; import type { Established } from "../connection/established.ts"; import { type Probe, type Stats, transportStats } from "../connection/stats.ts"; import { type Transport, transportOf } from "../connection/transport.ts"; -import { error, fromClose, StreamCode, StreamError } from "../error.ts"; +import { closeError, error, fromClose, StreamCode, StreamError, sessionCause } from "../error.ts"; import { type Hop, randomHop } from "../hop.ts"; import type { Consumer as OriginConsumer } from "../origin.ts"; import type * as Path from "../path.ts"; @@ -161,11 +161,17 @@ export class Connection implements Established { tasks.push(this.#subscriber.runDatagrams()); } + let fatal: Error | undefined; try { await Promise.all(tasks); } catch (err) { console.error("fatal error running connection", err); + // A session-sourced failure is the peer's close, not the raw transport error. + fatal = await sessionCause(this.#quic, err); } finally { + // The session died under every track it was receiving, so they end with its + // error. A deliberate close() already ended them cleanly, which makes this a no-op. + this.#subscriber.close(fatal ?? (await closeError(this.#quic))); this.close(); } } diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 021fe1dae4..eb5aff5569 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -3,7 +3,7 @@ import * as announce from "../announced.ts"; import * as broadcast from "../broadcast.ts"; import type { Probe as ProbeStats } from "../connection/stats.ts"; import { BroadcastCache } from "../consume.ts"; -import { controlTimeout, error, ProtocolViolation, reason, StreamCode, StreamError } from "../error.ts"; +import { controlTimeout, error, ProtocolViolation, reason, StreamCode, StreamError, sessionCause } from "../error.ts"; import * as netGroup from "../group.ts"; import { Cost, type Hop, MAX_HOPS, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts"; import { groupBounds, hiddenBelow, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts"; @@ -548,7 +548,7 @@ export class Subscriber { } catch (err) { // The setup outlived its deadline waiting for the first response: a control // timeout, not content that arrived late. - const e = err instanceof TimeoutError ? controlTimeout(err) : error(err); + const e = err instanceof TimeoutError ? controlTimeout(err) : await sessionCause(this.#quic, err); request.reject(e); this.#subscribes.delete(id); console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`); @@ -571,11 +571,14 @@ export class Subscriber { // // On lite-05+ the publisher sends SUBSCRIBE_START/END/DROP on this stream until // its FIN; older drafts just close it. Either way group streams can still be in - // flight, so the track ends only once the tail is accounted for. + // flight, so the track ends only once the tail is accounted for. A reset rejects + // instead, so the track ends with that error rather than a clean tail. const responses = supportsTrackStream(this.version) ? this.#runResponses(stream, entry) : stream.reader.closed; const closed = responses.then(() => this.#settleTail(entry)); + // A reset that lands after the race below settled is moot; the race observes one before. + closed.catch(() => {}); const subscriptionUpdates = this.version === Version.DRAFT_01 || this.version === Version.DRAFT_02 ? undefined @@ -603,7 +606,7 @@ export class Subscriber { stream.close(); console.debug(`subscribe close: id=${id} broadcast=${broadcast} track=${request.name}`); } catch (err) { - const e = error(err); + const e = await sessionCause(this.#quic, err); producer.close(e); console.warn(`subscribe error: id=${id} broadcast=${broadcast} track=${request.name} error=${reason(e)}`); stream.abort(e); @@ -807,17 +810,11 @@ export class Subscriber { // Reads SUBSCRIBE_START/END/DROP on the subscribe stream until FIN (lite-05+), recording // the range the tail is accounted against. SUBSCRIBE_END declares the track's end right - // away, so a consumer learns it before the last groups arrive. Resolves on FIN or on the - // stream being reset out from under it; rejects only on a response that breaks the range. + // away, so a consumer learns it before the last groups arrive. Resolves on FIN and rejects + // when the stream is reset, so the track ends with the publisher's error rather than cleanly. async #runResponses(stream: Stream, entry: SubscribeEntry): Promise { for (;;) { - let resp: Awaited>; - try { - resp = await decodeSubscribeResponseMaybe(stream.reader, this.version); - } catch { - // Stream closed or reset; nothing more to read. - return; - } + const resp = await decodeSubscribeResponseMaybe(stream.reader, this.version); if (!resp) return; if ("start" in resp) { @@ -1138,11 +1135,15 @@ export class Subscriber { } } - close() { + /** + * Ends every subscribed track: cleanly for a deliberate close, or with `err` when the + * session died, since those tracks were cut off rather than ended. + */ + close(err?: Error) { this.#closed.abort(); for (const { track } of this.#subscribes.values()) { - track.close(); + track.close(err); } this.#subscribes.clear(); diff --git a/js/net/src/mock.ts b/js/net/src/mock.ts index 7bfdc9035f..78fdd6d191 100644 --- a/js/net/src/mock.ts +++ b/js/net/src/mock.ts @@ -12,9 +12,15 @@ import type { SendStream } from "./stream.ts"; const WRITABLE_STRATEGY: QueuingStrategy = { highWaterMark: 256 }; const READABLE_STRATEGY: QueuingStrategy = { highWaterMark: 256 }; -function newStream(): TransformStream { +/** Every stream of a transport pair, so a session close can fail the ones still open. */ +type StreamSet = Set>; + +function newStream(streams: StreamSet): TransformStream { return new TransformStream( { + start(controller) { + streams.add(controller); + }, // Copy each chunk to simulate real WebTransport's kernel-boundary copy. // Without this, Writer's scratch buffer reuse corrupts queued data. transform(chunk, controller) { @@ -57,6 +63,10 @@ export class MockTransport implements WebTransport { // Reference to the peer so we can enqueue streams to them #peer?: MockTransport; + // Shared with the peer: a session close fails every stream either side opened, the way + // a real WebTransport session does. + #streams: StreamSet = new Set(); + constructor( protocol: string, datagramsEnabled = true, @@ -149,6 +159,7 @@ export class MockTransport implements WebTransport { setPeer(peer: MockTransport) { this.#peer = peer; + this.#streams = peer.#streams; } async createBidirectionalStream(options?: WebTransportSendStreamOptions): Promise { @@ -156,8 +167,8 @@ export class MockTransport implements WebTransport { if (!peer) throw new Error("no peer"); // Create two TransformStreams for the two directions - const c2s = newStream(); - const s2c = newStream(); + const c2s = newStream(this.#streams); + const s2c = newStream(this.#streams); // Local side: writes to c2s, reads from s2c const local = { @@ -184,7 +195,7 @@ export class MockTransport implements WebTransport { const peer = this.#peer; if (!peer) throw new Error("no peer"); - const c2s = newStream(); + const c2s = newStream(this.#streams); // Record before handing the peer its end, so a peer that waits for the stream to arrive // always finds it here. @@ -216,6 +227,13 @@ export class MockTransport implements WebTransport { const info = _closeInfo ?? { closeCode: 0, reason: "" }; this.#closeResolve(info); + // Shaped like the WebTransportError a real session close fails its streams with. + const closed = Object.assign(new Error("session closed"), { source: "session", streamErrorCode: null }); + for (const stream of this.#streams) { + stream.error(closed); + } + this.#streams.clear(); + try { this.#bidiController.close(); } catch { diff --git a/quest/m1/README.md b/quest/m1/README.md index 88edaefce0..c0f3270309 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -20,7 +20,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [JS fetch answer](/quest/m1/js-fetch-answer.md) - js/net's lite fetch settles on the publisher's answer, and a JS publisher's miss resets with NotFound - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached -- [Session death error](/quest/m1/session-death-error.md) - a dying session ends its tracks with its own error in Rust and JS, never a clean end, `Dropped`, or `Cancel` - [Dropped sources](/quest/m1/dropped-sources.md) - consumers see the producer's real error on every end path, never `Dropped` - [JS bare FIN](/quest/m1/js-bare-fin.md) - a `@moq/net` subscriber aborts a track whose subscribe stream FINs before its declared end, like Rust - [Track tail interop](/quest/m1/track-tail-interop.md) - a Rust publisher ending a track with a group in flight is read to its end by the JS subscriber, and the reverse, in `just test interop` diff --git a/quest/m1/session-close.md b/quest/m1/session-close.md index a846d5e977..3a297810e6 100644 --- a/quest/m1/session-close.md +++ b/quest/m1/session-close.md @@ -25,5 +25,4 @@ abort does not. No new page. ## Related -- [Session death error](/quest/m1/session-death-error.md) - a session that dies still ends its tracks with its own error - [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` on a broadcast, which is not this session end diff --git a/quest/m1/session-death-error.md b/quest/m1/session-death-error.md deleted file mode 100644 index b4112d5c24..0000000000 --- a/quest/m1/session-death-error.md +++ /dev/null @@ -1,55 +0,0 @@ -# [M] Session death error - -## Goal - -When a session dies, every track it was receiving ends with the session's own -error, in Rust and JS, over moq-lite and IETF. A reader never sees a clean end -for a stream that was cut off, and never a generic `Dropped` or `Cancel` -standing in for the real cause. A publisher's reset of a subscription reaches -the reader as that reset's error. - -## Plan - -Today the cause is lost in several places. Rust: - -- The lite subscriber's serve loop gives back `Error::Dropped` when the - session dies, and `SubscriptionCleanup` aborts the remaining tracks with - `Error::Cancel` (`rs/moq-net/src/lite/subscriber.rs`). `Dropped` here is a - bug. -- The IETF subscriber's `State::drop` aborts every subscription with - `Error::Cancel` (`rs/moq-net/src/ietf/subscriber.rs`). -- Worse, the reader can see a clean end. Once SUBSCRIBE_END has declared - the boundary, a session that dies with a group below it still in flight - leaves `recv_group()` returning `Ok(None)`, skipping that group (#4061). - The abort does reach the track, but the clean-end check wins whenever the - reader polls after the close lands, so the outcome depends on who reads - first. A declared end is only clean once every group below it is - accounted for; an abort before then wins. - -JS: - -- The lite `Subscriber.close()` runs from `Connection.close()` even after a - fatal error and closes every track cleanly (`js/net/src/lite/subscriber.ts`, - `js/net/src/lite/connection.ts`). -- On lite-05 and later, `#drainResponses` swallows a reset of the subscribe - stream and reports it as a clean end. On lite-01 to -04 the error does reach - the track. -- On IETF drafts 14-16, where one control stream carries every request, the - adapter closes every per-request stream cleanly when the session or control - stream dies (`js/net/src/ietf/adapter.ts`), so the tracks end cleanly. Drafts - 17 and later reject with the transport error. - -Thread the session's close error to wherever tracks are torn down, and abort -with it. A deliberate local close of the session is still a close, not an -error. - -Test by killing a session mid-track in both languages and asserting the reader -sees the session's error, and by resetting a subscribe stream on lite-05 and -later. For #4061, pin the deterministic mock repro and the real-QUIC one from -`kidq330/bug/subscription_ends_clean_with_missing_group` -(`subscription_end_integrity` in `moq-net` and `moq-tokio`), with their -controls: a finished track still ends clean while its session lives. - -## Closes - -- [#4061](https://github.com/moq-dev/moq/issues/4061) - close this issue when the quest finishes diff --git a/quest/m1/track-tail-interop.md b/quest/m1/track-tail-interop.md index 0078cdae17..7489cbdfb3 100644 --- a/quest/m1/track-tail-interop.md +++ b/quest/m1/track-tail-interop.md @@ -27,8 +27,3 @@ What stood in the way when the Rust half landed: QUIC on localhost rarely reorders, so this is a smoke check that the end is delivered and clean. The ordering race itself stays in the unit tests. - -## Related - -- [Session death error](/quest/m1/session-death-error.md) - the other way a - track ends wrong diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index f1c7258608..cb0fd4538a 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -239,7 +239,7 @@ where Ok(()) })); - kio::wait(|waiter| { + let res = kio::wait(|waiter| { use std::task::Poll; if let Poll::Ready(err) = waiter.poll_future(adapter_run.as_mut()) { return Poll::Ready(Err::<(), Error>(err)); @@ -261,7 +261,12 @@ where } Poll::Pending }) - .await + .await; + if let Err(err) = &res { + // Every track this session was receiving ends with its error. + subscriber.abort(err); + } + res } _ => { // Send SETUP and keep the stream alive: it is also our GOAWAY channel. @@ -366,7 +371,7 @@ where Ok(()) })); - kio::wait(|waiter| { + let res = kio::wait(|waiter| { use std::task::Poll; if let Poll::Ready(err) = waiter.poll_future(unis.as_mut()) { return Poll::Ready(Err::<(), Error>(err)); @@ -391,7 +396,12 @@ where } Poll::Pending }) - .await + .await; + if let Err(err) = &res { + // Every track this session was receiving ends with its error. + subscriber.abort(err); + } + res } }; diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 342cf6e054..e01227f073 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -152,22 +152,35 @@ struct State { broadcasts: HashMap, } -impl Drop for State { - fn drop(&mut self) { - // The session dispatcher owns this state and can be dropped at any await. - // Active receive tasks abort their own groups. Cancel any head waiting for - // its tail here, along with the track. Ordinary unsubscribe removes its entry. - for (_, track) in self.subscribes.drain() { +impl State { + /// End every active subscription with the error that ended the session. + /// + /// Active receive tasks abort their own groups. Abort any head waiting for its + /// tail here, along with the track. Ordinary unsubscribe removes its entry. + fn abort(&mut self, err: &Error) { + for (_, mut track) in self.subscribes.drain() { + if let Some(request) = track.pending.take() { + request.reject(err.clone()); + } if let Fill::Ready { producer, .. } = &*track.fill.read() { - let _ = producer.clone().abort(Error::Cancel); + let _ = producer.clone().abort(err.clone()); } if let Some(producer) = track.producer { - let _ = producer.abort(Error::Cancel); + let _ = producer.abort(err.clone()); } } } } +impl Drop for State { + fn drop(&mut self) { + // The session dispatcher owns this state and can be dropped at any await. A + // session that ended with an error already aborted these with it; what + // remains was cancelled with the dispatcher. + self.abort(&Error::Cancel); + } +} + /// The head of a joined group, delivered on the subscription's fill fetch stream. /// /// Draft-20's current-group join (section 5.1.6) splits one group across two streams: the @@ -298,6 +311,9 @@ struct Accepted { struct TrackState { producer: Option, + /// The origin request, until SUBSCRIBE_OK accepts it. Abort rejects this: the + /// producer does not exist yet, and dropping the setup task would be `Dropped`. + pending: Option, name: String, alias: Option, @@ -344,6 +360,7 @@ impl TrackState { fn pending(name: String, broadcast: PathOwned, fill: kio::Producer, joining: Option) -> Self { Self { producer: None, + pending: None, name, alias: None, broadcast, @@ -513,6 +530,11 @@ where } } + /// End every active subscription with the error that ended the session. + pub fn abort(&self, err: &Error) { + self.state.lock().abort(err); + } + /// Leave `alias` in the state a cancelled subscription leaves behind: bound to a /// subscription, then retired. /// @@ -641,6 +663,11 @@ where held.broadcast == new.broadcast && held.name == new.name } + /// Take the origin request back out of a subscription that is still setting up. + fn take_pending(&self, request_id: RequestId) -> Option { + self.state.lock().subscribes.get_mut(&request_id)?.pending.take() + } + fn remove_subscribe(&self, request_id: RequestId) -> Option { let mut state = self.state.lock(); let track = state.subscribes.remove(&request_id)?; @@ -1642,6 +1669,19 @@ where tracing::info!(broadcast = %self.origin.absolute(&broadcast_path), track = %request.name(), "subscribe started"); + // Park the origin request where a session abort can reject it. The producer + // does not exist until SUBSCRIBE_OK, and dropping this task would otherwise + // end the track as `Dropped`. + let track_name = request.name().to_owned(); + { + let mut state = self.state.lock(); + let Some(held) = state.subscribes.get_mut(&request_id) else { + request.reject(Error::Cancel); + return; + }; + held.pending = Some(request); + } + // A publisher can be serving before its SUBSCRIBE_OK reaches us, since the data // streams are independent of the request stream. Waiting for the response alone would // miss the local side going away in that window and leave the publisher serving a @@ -1651,9 +1691,10 @@ where enum Setup { Response(Result, Error>), Unused, + /// `abort` already rejected the parked request. + Gone, } - let track_name = request.name().to_owned(); let setup = { let mut response = std::pin::pin!(self.read_subscribe_response(&mut stream)); loop { @@ -1665,7 +1706,15 @@ where if let Poll::Ready(res) = waiter.poll_future(response.as_mut()) { return Poll::Ready(Setup::Response(res)); } - if request.poll_unused(waiter).is_ready() { + let mut state = self.state.lock(); + let Some(pending) = state + .subscribes + .get_mut(&request_id) + .and_then(|held| held.pending.as_mut()) + else { + return Poll::Ready(Setup::Gone); + }; + if pending.poll_unused(waiter).is_ready() { return Poll::Ready(Setup::Unused); } Poll::Pending @@ -1673,23 +1722,36 @@ where .await; match setup { - Setup::Response(res) => break Some((res, request)), + Setup::Response(res) => break Some(res), + Setup::Gone => break None, Setup::Unused => { - if request.reject_unused(Error::Cancel) { + let mut state = self.state.lock(); + let Some(pending) = state + .subscribes + .get_mut(&request_id) + .and_then(|held| held.pending.take()) + else { + break None; + }; + if pending.reject_unused(Error::Cancel) { break None; } + if let Some(held) = state.subscribes.get_mut(&request_id) { + held.pending = Some(pending); + } } } } }; - let Some((response, request)) = setup else { + let Some(response) = setup else { tracing::info!( broadcast = %self.origin.absolute(&broadcast_path), track = %track_name, "subscribe abandoned before it was accepted" ); - // The publisher may already be serving before it answers. + // The publisher may already be serving before it answers. A session abort + // already rejected the parked request; dropping what remains is not a second one. self.remove_subscribe(request_id); self.cancel_subscribe(stream, request_id).await; return; @@ -1700,14 +1762,18 @@ where let accepted = match response { Ok(Some(accepted)) => accepted, Ok(None) => { + if let Some(pending) = self.take_pending(request_id) { + pending.reject(Error::UnexpectedMessage); + } self.remove_subscribe(request_id); - request.reject(Error::UnexpectedMessage); return; } Err(err) => { tracing::debug!(%err, "subscribe response error"); + if let Some(pending) = self.take_pending(request_id) { + pending.reject(err); + } self.remove_subscribe(request_id); - request.reject(err); return; } }; @@ -1723,6 +1789,11 @@ where .with_priority(super::priority::from_wire(priority.unwrap_or(128))); // Declared before the track is released to readers, so a warm cache waiting on // this copy judges itself against where the live feed actually starts. + let Some(request) = self.take_pending(request_id) else { + // Aborted while the answer was in hand. The parked request is already rejected. + self.remove_subscribe(request_id); + return; + }; let request = match live { true => request.resolving_start(), false => request, @@ -2991,6 +3062,45 @@ mod tests { assert_eq!(pending.await.unwrap(), RequestId(11)); } + /// SUBSCRIBE_OK has not accepted the track, so the map holds no producer. + /// Abort still has to reject the parked origin request with the session error. + #[tokio::test] + async fn session_death_rejects_a_subscribe_still_setting_up() { + let broadcast = crate::broadcast::Info::new().produce(); + let mut dynamic = broadcast.dynamic(); + let consumer = broadcast.consume(); + let mut waiting = std::pin::pin!(consumer.track("video").unwrap().subscribe(None)); + assert!(poll!(&mut waiting).is_pending()); + + let mut requested = std::pin::pin!(dynamic.requested_track()); + let std::task::Poll::Ready(Ok(request)) = poll!(&mut requested) else { + panic!("the subscribe did not request a track"); + }; + + let mut state = State::default(); + state.subscribes.insert( + RequestId(1), + TrackState { + pending: Some(request), + ..TrackState::pending( + "video".to_string(), + crate::Path::new("bcast").to_owned(), + kio::Producer::new(Fill::Done), + None, + ) + }, + ); + state.abort(&Error::Session(crate::SessionError::App(7))); + + assert!( + matches!( + poll!(&mut waiting), + std::task::Poll::Ready(Err(Error::Session(crate::SessionError::App(7)))) + ), + "setup was not rejected with the session error" + ); + } + #[tokio::test(start_paused = true)] async fn unknown_track_alias_times_out() { let aliases = TrackAliases::default(); diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 0d00e5a29d..7417c05c2c 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -230,6 +230,10 @@ where { pub(crate) fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { let res = std::task::ready!(self.poll_protocol(waiter)); + if let Err(err) = &res { + // Every track this session was receiving ends with its error. + self.subscriber.abort(err); + } match &res { Err(Error::Transport(_)) => { tracing::info!("session terminated"); diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 20987dbbc7..11fcce659f 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -70,6 +70,10 @@ pub(super) struct Subscriber { // assigned id stays local and is never written into a hop chain. session_origin: crate::Hop, subscribes: Lock>, + /// Why this session ended, once it has. A track still waiting on TRACK_INFO is + /// not in [`Self::subscribes`], so dropping its request reads this instead of + /// becoming [`Error::Dropped`]. + ended: Lock>, next_id: Arc, version: Version, /// The peer's advertised SETUP (lite-05+), set when its Setup stream is read. @@ -107,6 +111,7 @@ impl Subscriber { self_origin, session_origin: config.peer_hop.unwrap_or(crate::Hop::UNKNOWN), subscribes: Default::default(), + ended: Default::default(), next_id: Default::default(), version: config.version, peer_setup: config.peer_setup, @@ -116,6 +121,20 @@ impl Subscriber { } } + /// Record the error that ended the session. The first one wins: a later cancel + /// from dropping the driver must not replace it. + fn note_end(&self, err: &Error) { + let mut slot = self.ended.lock(); + if slot.is_none() { + *slot = Some(err.clone()); + } + } + + /// The recorded session end, or [`Error::Dropped`] when nothing recorded one. + fn end_reason(&self) -> Error { + self.ended.lock().clone().unwrap_or(Error::Dropped) + } + /// Reject a new request once the peer has sent a GOAWAY: it told us to stop /// opening streams on this session (existing subscriptions keep flowing). fn check_going_away(&self) -> Result<(), Error> { @@ -460,20 +479,28 @@ impl Subscriber { // poll returning Ready. struct SubscriptionCleanup(Lock>); -impl Drop for SubscriptionCleanup { - fn drop(&mut self) { - // Group machines own their cancellation cleanup independently. This records - // session cancellation as the track's terminal state. +impl SubscriptionCleanup { + /// End every active subscription with the session's error. Group machines own + /// their cleanup independently; this records the track's terminal state. + fn abort(&self, err: &Error) { for (_, entry) in self.0.lock().drain() { - let _ = entry.producer.abort(Error::Cancel); + let _ = entry.producer.abort(err.clone()); } } } +impl Drop for SubscriptionCleanup { + fn drop(&mut self) { + // A session that ended with an error already aborted these with it; what + // remains was cancelled with the driver. + self.abort(&Error::Cancel); + } +} + pub(super) struct SubscriberDriver { subscriber: Subscriber, - /// Aborts whatever is still subscribed when the driver is dropped. - _cleanup: SubscriptionCleanup, + /// Aborts whatever is still subscribed when the session ends. + cleanup: SubscriptionCleanup, /// One machine per permitted prefix. Only an error ends the session; a /// prefix finishing cleanly (publisher FIN) just retires. prefixes: Vec>, @@ -497,7 +524,7 @@ impl SubscriberDriver { Self { prefixes, - _cleanup: SubscriptionCleanup(subscriber.subscribes.clone()), + cleanup: SubscriptionCleanup(subscriber.subscribes.clone()), uni: UniAccept::new(subscriber.clone()), bandwidth: Some(RecvBandwidth::new(subscriber.clone())), datagrams: Some(DatagramRecv::new(subscriber.clone())), @@ -506,6 +533,14 @@ impl SubscriberDriver { } } + /// End every active subscription with the error that ended the session. + pub fn abort(&self, err: &Error) { + // Before the subscribe map, so a TRACK_INFO request dropped with this driver + // rejects with `err` rather than `Dropped`. + self.subscriber.note_end(err); + self.cleanup.abort(err); + } + pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { let mut i = 0; while i < self.prefixes.len() { @@ -547,6 +582,14 @@ impl SubscriberDriver { } } +impl Drop for SubscriberDriver { + fn drop(&mut self) { + // `abort` already recorded a session error when the driver failed. A driver + // dropped without that still has to name an end before its setup machines drop. + self.subscriber.note_end(&Error::Cancel); + } +} + /// Accepts incoming uni streams (GROUP data plus the peer's SETUP) and drives /// each as a child machine. Resolves only on a transport error. struct UniAccept { @@ -1405,6 +1448,51 @@ mod tests { ); } + /// A lite-05 subscribe still waiting on TRACK_INFO is not in the subscribe map. + /// Dropping that machine must reject the origin request with the session's error. + #[tokio::test] + async fn session_death_rejects_a_track_waiting_for_info() { + let subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), + session: SinkSession::default(), + origin: origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + recv_bandwidth: None, + version: VERSION, + peer_setup: Default::default(), + peer_hop: None, + cost: None, + going_away: Default::default(), + }); + + let broadcast = crate::broadcast::Info::new().produce(); + let mut dynamic = broadcast.dynamic(); + let consumer = broadcast.consume(); + let mut waiting = std::pin::pin!(consumer.track("video").unwrap().subscribe(None)); + assert!( + futures::poll!(waiting.as_mut()).is_pending(), + "waiting on the publisher" + ); + let request = dynamic.requested_track().now_or_never().unwrap().expect("request"); + + let run = TrackServeRun::new( + TrackServe { + subscriber: subscriber.clone(), + path: Path::new("room").to_owned(), + name: "video".to_string(), + }, + request, + ); + let death = Error::Session(crate::SessionError::App(7)); + subscriber.note_end(&death); + drop(run); + + let ended = waiting.now_or_never().expect("subscribe must end"); + assert!( + matches!(ended, Err(Error::Session(crate::SessionError::App(7)))), + "waiting track did not end with the session error" + ); + } + /// `establish` puts exactly one SUBSCRIBE on the wire, and the id is registered /// before any of it reaches the transport. /// @@ -3088,8 +3176,8 @@ impl Establish { return Poll::Ready(Ok(self.activate(stream))); } EstablishState::WaitOk { stream } => { - if self.closed.poll_closed(&mut cx).is_ready() { - return Poll::Ready(Err(Error::Dropped)); + if let Poll::Ready(err) = self.closed.poll_closed(&mut cx) { + return Poll::Ready(Err(Error::from_transport(err))); } let resp = ready!(stream.reader.poll_decode::(&mut cx))?; if !matches!(resp, lite::SubscribeResponse::Ok(_)) { @@ -3224,6 +3312,20 @@ impl kio::Task for TrackServeRun { } } +impl Drop for TrackServeRun { + fn drop(&mut self) { + // Still waiting on TRACK_INFO: the request never reached the subscribe map, + // so the driver's abort cannot see it. Reject with the session's error. + let TrackRunState::Info { request, .. } = &mut self.state else { + return; + }; + let Some(request) = request.take() else { + return; + }; + request.reject(self.serve.subscriber.end_reason()); + } +} + /// Opens a TRACK stream, reads the single TRACK_INFO, and maps it to the /// model's [`track::Info`]. Lite05+ only. Bails if the session dies meanwhile. struct TrackInfoFetch { @@ -3272,8 +3374,8 @@ impl TrackInfoFetch { self.state = TrackInfoState::Read { stream }; } TrackInfoState::Read { stream } => { - if self.closed.poll_closed(&mut cx).is_ready() { - return Poll::Ready(Err(Error::Dropped)); + if let Poll::Ready(err) = self.closed.poll_closed(&mut cx) { + return Poll::Ready(Err(Error::from_transport(err))); } let info = ready!(stream.reader.poll_decode::(&mut cx))?; // The publisher FINs after TRACK_INFO; FIN our side too and let the @@ -3565,9 +3667,10 @@ impl ServeLoop { } } - // (5) The session died: hand the track back for another route. - if self.closed.poll_closed(&mut cx).is_ready() { - return Poll::Ready(ServeEnd::GiveBack(Error::Dropped)); + // (5) The session died: hand the track back for another route, with + // the session's error in case none takes it. + if let Poll::Ready(err) = self.closed.poll_closed(&mut cx) { + return Poll::Ready(ServeEnd::GiveBack(Error::from_transport(err))); } return Poll::Pending; diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index ee081f1474..0d9b8ba038 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1405,8 +1405,8 @@ impl SegmentSub { } /// Move an active cursor into terminal retention and mark the segment done. - fn complete(&mut self, count: Option) { - let previous = std::mem::replace(&mut self.sub, SubState::Done(count)); + fn complete(&mut self, end: Result) { + let previous = std::mem::replace(&mut self.sub, SubState::Done(end)); if let SubState::Active(sub) = previous { self.terminal = Some(*sub); } @@ -1449,23 +1449,22 @@ enum SubState { /// Live cursor over the underlying track. Boxed: the subscriber dwarfs the other /// variants, and every segment holds this enum. Active(Box), - /// The underlying track ended: `Some` with the group count when it finished - /// cleanly, `None` when it aborted or was dropped. An abort is deliberately - /// not surfaced: a dead route stalls the logical track until the next switch - /// replaces it. - Done(Option), + /// The underlying track ended: `Ok` with the group count when it finished + /// cleanly, `Err` with the cause when it aborted or was dropped. An abort only + /// surfaces once no switch can follow: until then a dead route stalls the + /// logical track for the next switch to replace. + Done(Result), } /// A live subscription spliced across every segment of a logical track. /// /// Reads switch between the underlying [`track::Subscriber`]s at the segment /// boundaries. A segment's session failing does not error the subscription; it -/// stalls until [`Producer::switch`] provides a replacement, or ends cleanly once -/// the producer [`finish`](Producer::finish)es and the final segment completes. -/// The producer itself going away without a terminal state ends the track as its -/// final segment's track ended, once the remaining segments drain: cleanly if it -/// finished, [`Error::Dropped`] if it died. With nobody left to splice a -/// replacement, a stall would never end. +/// stalls until [`Producer::switch`] provides a replacement. Once the producer +/// [`finish`](Producer::finish)es, or goes away without a terminal state, no +/// replacement can follow, so the track ends as its final segment's track ended +/// once the remaining segments drain: cleanly if it finished, with its error if it +/// died. With nobody left to splice a replacement, a stall would never end. pub struct Subscriber { state: kio::Consumer, @@ -1535,7 +1534,7 @@ impl Subscriber { break; } if seg.pruned { - seg.sub = SubState::Done(None); + seg.sub = SubState::Done(Err(Error::Dropped)); seg.terminal = None; seg.parked.clear(); cut -= 1; @@ -1831,7 +1830,8 @@ impl Subscriber { // The copy was asked for the cache's newest group and started past it: its // source judged that group stale, so the older cache is no use to live reads. if start.is_some_and(|start| edge.is_some_and(|edge| start > edge)) { - seg.complete(None); + // Discarded, not finished: there is no group count, same as a dropped segment. + seg.complete(Err(Error::Dropped)); return Poll::Ready(()); } } @@ -1852,7 +1852,7 @@ impl Subscriber { seg.sub = SubState::Active(Box::new(sub)); } // The underlying track was rejected or closed: stall, not error. - Err(_) => seg.sub = SubState::Done(None), + Err(err) => seg.sub = SubState::Done(Err(err)), } } Poll::Ready(()) @@ -1881,18 +1881,17 @@ impl Subscriber { return Poll::Ready(Some(group)); } Ok(None) => { - let count = sub.poll_finished(waiter).map(|res| res.ok()); - let count = match count { - Poll::Ready(count) => count, - Poll::Pending => None, + let end = match sub.poll_finished(waiter) { + Poll::Ready(end) => end, + Poll::Pending => Err(Error::Dropped), }; - seg.complete(count); + seg.complete(end); return Poll::Ready(None); } // A dead segment stalls the logical track rather than erroring; // the next switch resumes it. - Err(_) => { - seg.complete(None); + Err(err) => { + seg.complete(Err(err)); return Poll::Ready(None); } }, @@ -1971,15 +1970,15 @@ impl Subscriber { } // The track ran out at or below the floor: the segment drained. Poll::Ready(Ok(None)) => { - let count = match sub.poll_finished(waiter) { - Poll::Ready(count) => count.ok(), - Poll::Pending => None, + let end = match sub.poll_finished(waiter) { + Poll::Ready(end) => end, + Poll::Pending => Err(Error::Dropped), }; - seg.complete(count); + seg.complete(end); } // A dead segment stalls the logical track rather than erroring; // the next switch resumes it. - Poll::Ready(Err(_)) => seg.complete(None), + Poll::Ready(Err(err)) => seg.complete(Err(err)), Poll::Pending => all_done = false, }, SubState::Pending(_) => all_done = false, @@ -2007,15 +2006,10 @@ impl Subscriber { if let Some(err) = &self.abort { return Poll::Ready(Err(err.clone())); } - if all_done { - if self.finished { - return Poll::Ready(Ok(None)); - } - // The producer is gone without finishing: no takeover can ever resume - // the drained segments, so the track ends as its final segment did. - if self.closed { - return Poll::Ready(self.orphan_end().map(|()| None)); - } + // Finished or gone, no switch can follow: the track ends as its final + // segment did. + if all_done && (self.finished || self.closed) { + return Poll::Ready(self.final_end().map(|()| None)); } return Poll::Pending; } @@ -2028,9 +2022,9 @@ impl Subscriber { /// out exactly once no matter how many routes contributed to it. /// /// Returns `Poll::Ready(Ok(None))` once every segment completed and the - /// producer finished, or was dropped with the final segment's track finished. - /// Returns `Poll::Ready(Err(_))` if the producer aborted, or was dropped and - /// the final segment's track died ([`Error::Dropped`]). + /// producer finished or was dropped, with the final segment's track finished. + /// Returns `Poll::Ready(Err(_))` if the producer aborted, or if it finished or + /// was dropped and the final segment's track died (with that track's error). pub fn poll_recv_group(&mut self, waiter: &kio::Waiter) -> Poll>> { self.poll_sync(waiter); @@ -2134,16 +2128,10 @@ impl Subscriber { if let Some(err) = &self.abort { return Poll::Ready(Err(err.clone())); } - if all_done { - if self.finished { - return Poll::Ready(Ok(None)); - } - // The producer is gone without finishing: no takeover can ever - // resume the drained segments, so the track ends as its final - // segment did rather than stalling forever. - if self.closed { - return Poll::Ready(self.orphan_end().map(|()| None)); - } + // Finished or gone, no switch can follow: the track ends as its final + // segment did rather than stalling forever. + if all_done && (self.finished || self.closed) { + return Poll::Ready(self.final_end().map(|()| None)); } Poll::Pending } @@ -2217,14 +2205,24 @@ impl Subscriber { if let Some(err) = &self.abort { return Poll::Ready(Err(err.clone())); } + // The producer finished, so this channel ends now. A final segment that + // already died is that death: the datagram read above drops a Ready(Err) + // on the floor, and a clean Ok(None) here would hide it. A segment that + // has not activated yet has no end to report. if self.finished { - return Poll::Ready(Ok(None)); + if pending_activation { + return Poll::Ready(Ok(None)); + } + return match ready!(self.poll_final(waiter)) { + Some(end) => Poll::Ready(end.map(|_| None)), + None => Poll::Ready(Ok(None)), + }; } // The producer is gone: no takeover is coming, so the track ends when // its newest segment does, and the way it did. if self.closed && !pending_activation { return match ready!(self.poll_final(waiter)) { - Some(_) => Poll::Ready(Ok(None)), + Some(end) => Poll::Ready(end.map(|_| None)), None => Poll::Ready(Err(Error::Dropped)), }; } @@ -2249,40 +2247,42 @@ impl Subscriber { if !self.finished && !self.closed { return Poll::Pending; } - let end = ready!(self.poll_final(waiter)); - match self.finished { - true => Poll::Ready(Ok(end.unwrap_or(0))), - // The producer is gone without finishing: the track ends as its final - // segment did. - false => Poll::Ready(end.ok_or(Error::Dropped)), + // The track ends as its final segment did, or with nothing spliced, cleanly + // only if it finished. + match ready!(self.poll_final(waiter)) { + Some(end) => Poll::Ready(end), + None if self.finished => Poll::Ready(Ok(0)), + None => Poll::Ready(Err(Error::Dropped)), } } /// Wait for the final segment's track to end: its group count when it - /// finished, `None` when it died or there is no segment. Earlier segments don't + /// finished, its error when it died, and `None` when there is no segment. Earlier segments don't /// decide the end. Only the subscription is resolved here: consuming groups, or /// completing the segment, would steal them from a `recv_group` caller on the /// same subscriber. - fn poll_final(&mut self, waiter: &kio::Waiter) -> Poll> { + fn poll_final(&mut self, waiter: &kio::Waiter) -> Poll>> { let Some(seg) = self.segments.last_mut() else { return Poll::Ready(None); }; ready!(Self::poll_activate(seg, &self.last_prefs, self.min_sequence, waiter)); match &mut seg.sub { - SubState::Done(count) => Poll::Ready(*count), + SubState::Done(end) => Poll::Ready(Some(end.clone())), // Observe only: the cursor may still hold groups, so the read path // completes the segment once it drains. - SubState::Active(sub) => Poll::Ready(ready!(sub.poll_finished(waiter)).ok()), + SubState::Active(sub) => Poll::Ready(Some(ready!(sub.poll_finished(waiter)))), SubState::Pending(_) => unreachable!("poll_activate resolved above"), } } - /// How a logical track whose producer went away without finishing ends, once - /// every segment drained: cleanly if its final segment's track finished, since - /// that is where the content ended, and [`Error::Dropped`] otherwise. - fn orphan_end(&self) -> Result<()> { + /// How a logical track ends once every segment drained and no switch can follow + /// (the producer finished or went away): as its final segment's track ended, since + /// that is where the content ended. A finished track with no segment ends cleanly; + /// an orphaned one with [`Error::Dropped`]. + fn final_end(&self) -> Result<()> { match self.segments.last().map(|seg| &seg.sub) { - Some(SubState::Done(Some(_))) => Ok(()), + Some(SubState::Done(end)) => end.clone().map(|_| ()), + None if self.finished => Ok(()), _ => Err(Error::Dropped), } } @@ -4895,12 +4895,61 @@ mod test { assert_eq!(recv(&mut sub), 0); // The segment dies, then the producer goes away without finish/abort: - // no takeover can ever come, so stalling would hang forever. - track_a.abort(Error::Cancel).unwrap(); + // no takeover can ever come, so stalling would hang forever. The track + // ends with the segment's own error. + track_a.abort(Error::Timeout).unwrap(); drop(producer); let result = sub.recv_group().now_or_never().expect("must not stall forever"); - assert!(matches!(result, Err(Error::Dropped))); + assert!(matches!(result, Err(Error::Timeout))); + } + + /// A finished logical track ends as its final segment did: a segment cut off + /// after the finish (its session died with a group still in flight) is an + /// error, not a clean end. + #[tokio::test] + async fn finished_producer_ends_with_a_dead_final_segment() { + let (mut track_a, consumer_a) = track_pair("a"); + + let mut producer = Producer::new(); + producer.switch(&consumer_a, None).unwrap(); + let mut sub = producer.consume().subscribe(None); + + write_group(&mut track_a, 0, "a0"); + assert_eq!(recv(&mut sub), 0); + + producer.finish().unwrap(); + track_a.abort(Error::Timeout).unwrap(); + + let result = sub.recv_group().now_or_never().expect("must not stall forever"); + assert!(matches!(result, Err(Error::Timeout))); + } + + /// A datagram-only reader of a finished logical track ends with the final + /// segment's error. The datagram poll drops a segment error that is not + /// `Ok(Some)`, so the finished path has to ask the segment itself. + #[tokio::test] + async fn finished_producer_ends_datagrams_with_a_dead_final_segment() { + let (track_a, consumer_a) = track_pair("a"); + + let mut producer = Producer::new(); + producer.switch(&consumer_a, None).unwrap(); + let mut sub = producer.consume().subscribe(None); + + assert!( + kio::wait(|waiter| sub.poll_recv_datagram(waiter)) + .now_or_never() + .is_none(), + "no datagram yet" + ); + + producer.finish().unwrap(); + track_a.abort(Error::Timeout).unwrap(); + + let result = kio::wait(|waiter| sub.poll_recv_datagram(waiter)) + .now_or_never() + .expect("must not stall forever"); + assert!(matches!(result, Err(Error::Timeout))); } #[tokio::test] @@ -4943,12 +4992,12 @@ mod test { ); match clean { true => track_a.finish().unwrap(), - false => track_a.abort(Error::Cancel).unwrap(), + false => track_a.abort(Error::Timeout).unwrap(), } let result = sub.finished().now_or_never().expect("must not stall forever"); match clean { true => assert!(matches!(result, Ok(0))), - false => assert!(matches!(result, Err(Error::Dropped))), + false => assert!(matches!(result, Err(Error::Timeout))), } } } diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index 8ac10f45b9..d4f3216a6d 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -241,6 +241,10 @@ pub(crate) struct TrackState { // The error that caused the track to be aborted, if any. abort: Option, + // Whether the declared end still stands after an abort: every group below it was + // produced and finished before the abort landed. See [`Self::is_complete`]. + settled: bool, + // Active subscriptions, in their own [`kio::Shared`] so a read-only `Consumer` // registers under that lock instead of writing back into the track state. // Kept here (rather than threaded through every handle) so any holder reaches it. @@ -1077,9 +1081,25 @@ impl TrackState { /// until the remaining groups are produced, or until the last producer drops without /// them. Drives the end-of-stream signal from /// the read methods (`recv_group` / `next_group` / `read_frame` return `None`). + /// + /// An abort before the end settled wins over it: a group below the boundary was + /// still open, so the track was cut off rather than ended. fn is_complete(&self) -> bool { - self.final_sequence - .is_some_and(|fin| self.sealed || self.max_sequence.map_or(0, |max| max.saturating_add(1)) >= fin) + // `sealed` is a clean end when the last producer drops. An abort still wins + // unless that end had already settled: a group below it was still open. + let reached = self + .final_sequence + .is_some_and(|fin| self.sealed || self.max_sequence.map_or(0, |max| max.saturating_add(1)) >= fin); + reached && (self.abort.is_none() || self.settled) + } + + /// Whether the declared end is reached and every cached group below it finished, + /// so nothing the end promised is still in flight. + fn is_settled(&self) -> bool { + let Some(fin) = self.final_sequence else { + return false; + }; + self.is_complete() && self.lookup.range(..fin).all(|(_, slot)| slot.group.is_finished()) } /// Where a replacement route should pick this track up: one past the last frame @@ -1172,6 +1192,8 @@ fn commit_abort(mut state: kio::Mut<'_, TrackState>, err: Error) { // Snapshot the frame boundary before the cache it's derived from goes away: an // abort is exactly when a replacement route asks where to resume. state.resume = state.resume_position(); + // Decided before the cache goes: the groups below the end are the evidence. + state.settled = state.is_settled(); state.abort = Some(err); state.clear_cache(); state.datagrams.clear(); @@ -6894,6 +6916,43 @@ mod test { assert!(done.is_none(), "track completes once the boundary is reached"); } + /// An abort before the declared end settled wins over it: the boundary was reached, + /// but a group below it was still open, so the track was cut off rather than ended. + #[tokio::test] + async fn abort_before_the_end_settles_wins() { + let mut producer = track_producer("test", None); + let mut consumer = producer.subscribe(None); + + let head = producer.create_group(group::Info { sequence: 0 }).unwrap(); + head.finish().unwrap(); + let _tail = producer.create_group(group::Info { sequence: 1 }).unwrap(); + producer.finish_at(2).unwrap(); + assert_eq!(consumer.assert_group().sequence, 0); + + producer.abort(Error::Timeout).unwrap(); + let res = consumer.recv_group().now_or_never().expect("should not block"); + assert!(matches!(res, Err(Error::Timeout))); + } + + /// An abort after every group below the declared end finished leaves the end standing. + #[tokio::test] + async fn abort_after_the_end_settles_ends_clean() { + let mut producer = track_producer("test", None); + let mut consumer = producer.subscribe(None); + + for sequence in 0..2 { + let group = producer.create_group(group::Info { sequence }).unwrap(); + group.finish().unwrap(); + } + producer.finish_at(2).unwrap(); + assert_eq!(consumer.assert_group().sequence, 0); + assert_eq!(consumer.assert_group().sequence, 1); + + producer.abort(Error::Timeout).unwrap(); + let res = consumer.recv_group().now_or_never().expect("should not block"); + assert!(matches!(res, Ok(None))); + } + #[tokio::test] async fn recv_group_finishes_without_waiting_for_gaps() { let producer = track_producer("test", None); diff --git a/rs/moq-net/tests/subscription_end_integrity.rs b/rs/moq-net/tests/subscription_end_integrity.rs new file mode 100644 index 0000000000..11ade6b507 --- /dev/null +++ b/rs/moq-net/tests/subscription_end_integrity.rs @@ -0,0 +1,263 @@ +//! A subscription ends cleanly only when every group in its range has been accounted +//! for; a session closing with a group still in flight ends it with an error. +//! +//! moq-lite, Subscribe: "The publisher closes the stream (FIN) only once every group +//! from start to end has been accounted for, either via a Group Stream (completed or +//! reset) or a SUBSCRIBE_DROP message." And Routing: "when the serving session ends, +//! in-flight subscriptions end with it (a reset)". So `recv_group()` may return +//! `Ok(None)` only after that FIN; any other ending is an error. +//! +//! The publisher opens the final group and writes its first frame (the group stream +//! opens), finishes the track (SUBSCRIBE_END goes out), then writes the rest, finishes +//! the group and disconnects with no await in between, so the final group's tail and +//! its FIN never leave. The close lands before the subscriber reads on. The subscriber +//! must then either see every frame or end with an error; it must not end `Ok(None)` +//! short of the final group. +//! +//! Deterministic: paused time, single-threaded runtime, and every "let the session +//! send" step is an idle-advance rather than a race. + +mod support; + +use std::time::Duration; + +use moq_net::{Hop, Timestamp, Version}; +use support::harness::{MockConnectOptions, MockPair, connect_mock}; + +const TIMEOUT: Duration = Duration::from_secs(10); +const HEAD: [&[u8]; 2] = [b"head-a", b"head-b"]; +const TAIL: [&[u8]; 2] = [b"tail-a", b"tail-b"]; + +fn produce_origin(hop: u64) -> moq_net::origin::Producer { + let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::new(Hop::new(hop).unwrap())); + tokio::spawn(support::harness::run(driver)); + producer +} + +/// Let the drivers run until nothing is runnable: under paused time an idle runtime +/// auto-advances the clock, so this returns once every queued write has been sent. +async fn settle() { + tokio::time::sleep(Duration::from_millis(1)).await; +} + +fn expected() -> Vec> { + HEAD.iter().chain(TAIL.iter()).map(|p| p.to_vec()).collect() +} + +/// Publish a two-group track over one mock session, cut the publisher's session with the +/// final group half sent (or keep it up), and return what the subscriber read: the frame +/// payloads and the error it ended with, if any. +async fn round(drop_session: bool) -> (Vec>, Option) { + let publisher = produce_origin(1); + let broadcast = publisher.create_broadcast("bcast").unwrap(); + let track = broadcast.create_track("video", None).unwrap(); + broadcast.announce(Default::default()).unwrap(); + + let subscriber = produce_origin(2); + let mut options = MockConnectOptions::new("moq-lite-05".parse::().unwrap()); + options.server_publish = Some(publisher.consume()); + options.client_subscribe = Some(subscriber.clone()); + let MockPair { client, server, .. } = connect_mock(options).await; + + let consumer = subscriber.consume(); + tokio::time::timeout(TIMEOUT, consumer.routed("bcast")) + .await + .expect("announce timeout") + .expect("routed"); + let remote = tokio::time::timeout(TIMEOUT, consumer.request_broadcast("bcast")) + .await + .expect("resolve timeout") + .expect("broadcast resolves"); + + // The publisher only learns of a subscription once the subscriber polls it, so the + // reader runs concurrently. It reports the head group drained, then waits to be told + // to read on, so the final group is still unread when the session goes away. + let (drained_tx, drained_rx) = tokio::sync::oneshot::channel(); + let (go_tx, go_rx) = tokio::sync::oneshot::channel::<()>(); + + let reader = tokio::spawn(async move { + let subscription = moq_net::track::Subscription::default().with_start(moq_net::track::Position::group(0)); + let mut sub = remote + .track("video") + .unwrap() + .subscribe(subscription) + .await + .expect("subscribe"); + let mut got = Vec::new(); + let mut drained = Some(drained_tx); + let mut go = Some(go_rx); + loop { + let mut group = match sub.recv_group().await { + Ok(Some(group)) => group, + Ok(None) => return (got, None), + Err(err) => return (got, Some(err)), + }; + loop { + match group.read_frame().await { + Ok(Some(frame)) => got.push(frame.payload.to_vec()), + Ok(None) => break, + Err(err) => return (got, Some(err)), + } + } + if let Some(tx) = drained.take() { + let _ = tx.send(()); + let _ = go.take().unwrap().await; + } + } + }); + + tokio::time::timeout(TIMEOUT, track.used()) + .await + .expect("no subscriber appeared") + .unwrap(); + + let mut head = track.append_group().unwrap(); + for payload in HEAD { + head.write_frame(Timestamp::ZERO, payload).unwrap(); + } + head.finish().unwrap(); + tokio::time::timeout(TIMEOUT, drained_rx) + .await + .expect("the head group never arrived") + .expect("reader gone"); + + // The final group opens and its first frame is sent, then the track's end is + // declared: SUBSCRIBE_END reaches the subscriber with the group still open, as it + // does whenever a finish races a group still in flight. + let mut tail = track.append_group().unwrap(); + tail.write_frame(Timestamp::ZERO, TAIL[0]).unwrap(); + track.finish().unwrap(); + settle().await; + + // The rest of the group, its clean end, and the disconnect happen with no await in + // between, so the session has sent none of it when it closes. + tail.write_frame(Timestamp::ZERO, TAIL[1]).unwrap(); + tail.finish().unwrap(); + drop(track); + let server = if drop_session { + drop(server); + None + } else { + Some(server) + }; + + // The close lands before the subscriber reads on, as it does for a subscriber that is + // merely slower than the network. + settle().await; + let _ = go_tx.send(()); + let result = tokio::time::timeout(TIMEOUT, reader) + .await + .expect("the subscription never ended") + .expect("reader panicked"); + drop((client, server, broadcast, publisher, subscriber)); + result +} + +/// The session closes with the final group in flight: the subscription ends with an +/// error, or delivers every frame. +#[tokio::test] +async fn a_subscription_cut_by_the_session_close_does_not_end_clean() { + tokio::time::pause(); + let (got, err) = round(true).await; + assert!( + err.is_some() || got == expected(), + "the subscription ended Ok(None) with {}/{} frames, got {:?} \ + (a subscription that did not deliver every group must end with an error, not as complete)", + got.len(), + expected().len(), + got.iter() + .map(|p| String::from_utf8_lossy(p).into_owned()) + .collect::>(), + ); +} + +/// With the session kept alive the same track arrives whole and ends `Ok(None)`. +#[tokio::test] +async fn a_finished_track_ends_clean_while_its_session_lives() { + tokio::time::pause(); + let (got, err) = round(false).await; + assert!( + err.is_none() && got == expected(), + "session kept alive: got {} frames, err={err:?}", + got.len() + ); +} + +/// Every wire the session-death rule covers: lite before and after the track and +/// subscribe-response streams, and IETF on its shared and per-request control streams. +const DEATH_VERSIONS: &[&str] = &[ + "moq-lite-03", + "moq-lite-05", + "moq-lite-07-wip", + "moq-transport-14", + "moq-transport-17", + "moq-transport-22", +]; + +/// The error the publisher's session is aborted with, carried in the close code. +const DEATH: moq_net::SessionError = moq_net::SessionError::App(7); + +/// Kill the publisher's session with a group still open, and return the error the +/// subscriber's `recv_group` ends with once it drains what arrived. +async fn killed(version: &str) -> Option { + let publisher = produce_origin(1); + let broadcast = publisher.create_broadcast("bcast").unwrap(); + let track = broadcast.create_track("video", None).unwrap(); + broadcast.announce(Default::default()).unwrap(); + + let subscriber = produce_origin(2); + let mut options = MockConnectOptions::new(version.parse::().unwrap()); + options.server_publish = Some(publisher.consume()); + options.client_subscribe = Some(subscriber.clone()); + let MockPair { client, server, .. } = connect_mock(options).await; + + let consumer = subscriber.consume(); + consumer.routed("bcast").await.expect("routed"); + let remote = consumer.request_broadcast("bcast").await.expect("broadcast resolves"); + // Subscribing resolves only once the publisher serves the track, which it does + // only after seeing the subscription, so the reader runs concurrently. + let reader = tokio::spawn(async move { + let subscription = moq_net::track::Subscription::default().with_start(moq_net::track::Position::group(0)); + let mut sub = remote + .track("video") + .unwrap() + .subscribe(subscription) + .await + .expect("subscribe"); + loop { + let mut group = match sub.recv_group().await { + Ok(Some(group)) => group, + Ok(None) => return None, + Err(err) => return Some(err), + }; + // The open group ends however it ends; only the track's end is at stake here. + while let Ok(Some(_)) = group.read_frame().await {} + } + }); + + track.used().await.expect("no subscriber appeared"); + let mut group = track.append_group().unwrap(); + group.write_frame(Timestamp::ZERO, HEAD[0]).unwrap(); + settle().await; + + server.abort(moq_net::Error::Session(DEATH)); + let err = reader.await.expect("reader panicked"); + drop((client, server, group, track, broadcast, publisher, subscriber)); + err +} + +/// A session dying mid-track ends the subscriber's track with the session's own +/// error: not a clean end, and not a generic `Dropped` or `Cancel`. +#[tokio::test] +async fn a_session_death_ends_the_track_with_its_error() { + tokio::time::pause(); + for version in DEATH_VERSIONS { + let err = tokio::time::timeout(TIMEOUT, killed(version)) + .await + .unwrap_or_else(|_| panic!("{version}: the track never ended")); + assert!( + matches!(&err, Some(moq_net::Error::Session(code)) if *code == DEATH), + "{version}: the track ended with {err:?}, not the session's error" + ); + } +} diff --git a/rs/moq-net/tests/support/mock.rs b/rs/moq-net/tests/support/mock.rs index 81fbd49f1c..7043f020d2 100644 --- a/rs/moq-net/tests/support/mock.rs +++ b/rs/moq-net/tests/support/mock.rs @@ -8,7 +8,9 @@ //! //! The mock guarantees that data written and FIN'd on a stream before `close()` //! is readable by the peer, eliminating the Quinn CONNECTION_CLOSE race that -//! plagues real-transport tests. +//! plagues real-transport tests. Like a real CONNECTION_CLOSE, nothing written +//! after it is delivered, and a stream left unfinished fails with the close once +//! its earlier data is read. use std::{ sync::{Arc, Mutex}, @@ -20,24 +22,32 @@ use web_transport_trait::poll; // ── Error ─────────────────────────────────────────────────────────── -/// Error type for mock transport operations. +/// Error type for mock transport operations: a session close or a stream reset, +/// each reporting its code only through its own registry. #[derive(Debug, Clone)] pub struct MockError { - code: Option, + session: Option, + stream: Option, reason: String, } impl MockError { fn closed() -> Self { + Self::session(0, "session closed".into()) + } + + fn session(code: u32, reason: String) -> Self { Self { - code: Some(0), - reason: "session closed".into(), + session: Some(code), + stream: None, + reason, } } fn stream_reset(code: u32) -> Self { Self { - code: Some(code), + session: None, + stream: Some(code), reason: "stream reset".into(), } } @@ -53,11 +63,11 @@ impl std::error::Error for MockError {} impl web_transport_trait::Error for MockError { fn session_error(&self) -> Option<(u32, String)> { - self.code.map(|c| (c, self.reason.clone())) + self.session.map(|c| (c, self.reason.clone())) } fn stream_error(&self) -> Option { - self.code + self.stream } } @@ -68,6 +78,8 @@ enum StreamChunk { Data(Bytes), Fin, Reset(u32), + /// The connection closed before the stream ended; never queued. + Closed, } /// Shared closed-signal state between a paired SendStream and RecvStream. @@ -100,45 +112,66 @@ pub struct MockSendStream { /// Acknowledge the FIN as soon as it is sent, for a stream the peer's transport holds /// back from its application (see [`MockSession::hold_unis`]). ack_fin: bool, + conn: Arc, +} + +impl MockSendStream { + /// Queue a chunk for the peer, unless the connection closed: nothing sent after a + /// CONNECTION_CLOSE reaches the peer. + fn push(&mut self, chunk: StreamChunk) -> Result<(), MockError> { + if let Some(err) = self.conn.error() { + self.tx = None; + return Err(err); + } + let tx = self.tx.as_ref().ok_or_else(MockError::closed)?; + tx.try_push(chunk).map_err(|_| MockError::closed()) + } } impl poll::SendStream for MockSendStream { type Error = MockError; fn poll_write(&mut self, _cx: &mut Context<'_>, buf: &[u8]) -> Poll> { - let Some(tx) = self.tx.as_ref() else { - return Poll::Ready(Err(MockError::closed())); - }; - match tx.try_push(StreamChunk::Data(Bytes::copy_from_slice(buf))) { - Ok(()) => Poll::Ready(Ok(buf.len())), - Err(_) => Poll::Ready(Err(MockError::closed())), - } + Poll::Ready( + self.push(StreamChunk::Data(Bytes::copy_from_slice(buf))) + .map(|()| buf.len()), + ) } fn set_priority(&mut self, _order: u8) {} fn finish(&mut self) -> Result<(), Self::Error> { - if let Some(tx) = self.tx.take() { - let _ = tx.try_push(StreamChunk::Fin); - if self.ack_fin { + if self.tx.is_some() { + // A FIN that never left must not look acknowledged: poll_closed + // trusts this signal ahead of the connection error. + let pushed = self.push(StreamChunk::Fin); + if pushed.is_ok() && self.ack_fin { self.closed.set(Ok(())); } + self.tx = None; + pushed?; } Ok(()) } fn reset(&mut self, code: u32) { - if let Some(tx) = self.tx.take() { - let _ = tx.try_push(StreamChunk::Reset(code)); + if self.tx.is_some() { + let _ = self.push(StreamChunk::Reset(code)); + self.tx = None; } } fn poll_closed(&mut self, cx: &mut Context<'_>) -> Poll> { // Register before checking so a signal racing this poll still wakes it. - self.closed.waiters.register(self.park.hold(cx)); + let waiter = self.park.hold(cx); + self.closed.waiters.register(waiter); + self.conn.waiters.register(waiter); match self.closed.result.lock().unwrap().clone() { Some(result) => Poll::Ready(result), - None => Poll::Pending, + None => match self.conn.error() { + Some(err) => Poll::Ready(Err(err)), + None => Poll::Pending, + }, } } } @@ -147,8 +180,8 @@ impl Drop for MockSendStream { fn drop(&mut self) { // Dropped without an explicit FIN: deliver an implicit one, matching a // sender that went away cleanly. - if let Some(tx) = self.tx.take() { - let _ = tx.try_push(StreamChunk::Fin); + if self.tx.is_some() { + let _ = self.push(StreamChunk::Fin); } } } @@ -165,16 +198,22 @@ pub struct MockRecvStream { /// Shared signal to notify the peer's send-side `poll_closed`. closed: Arc, park: kio::Park, + conn: Arc, } impl MockRecvStream { - /// Pop the next chunk, mapping queue closure to an implicit FIN. + /// Pop the next chunk, mapping queue closure to an implicit FIN. Once everything + /// sent before a CONNECTION_CLOSE is read, an unfinished stream fails with it. fn poll_chunk(&mut self, cx: &mut Context<'_>) -> Poll> { let waiter = self.park.hold(cx); + self.conn.waiters.register(waiter); match self.rx.poll_pop(waiter) { Poll::Ready(Ok(chunk)) => Poll::Ready(Some(chunk)), Poll::Ready(Err(_)) => Poll::Ready(None), - Poll::Pending => Poll::Pending, + Poll::Pending => match self.conn.error() { + Some(_) => Poll::Ready(Some(StreamChunk::Closed)), + None => Poll::Pending, + }, } } } @@ -212,6 +251,10 @@ impl poll::RecvStream for MockRecvStream { self.done = true; Poll::Ready(Err(MockError::stream_reset(code))) } + Some(StreamChunk::Closed) => { + self.done = true; + Poll::Ready(Err(self.conn.error().unwrap_or_else(MockError::closed))) + } } } @@ -236,6 +279,10 @@ impl poll::RecvStream for MockRecvStream { self.done = true; return Poll::Ready(Err(MockError::stream_reset(code))); } + Some(StreamChunk::Closed) => { + self.done = true; + return Poll::Ready(Err(self.conn.error().unwrap_or_else(MockError::closed))); + } } } } @@ -253,7 +300,7 @@ impl Drop for MockRecvStream { // ── Stream pair constructor ───────────────────────────────────────── /// Create a linked (send, recv) stream pair. -fn new_stream_pair() -> (MockSendStream, MockRecvStream) { +fn new_stream_pair(conn: &Arc) -> (MockSendStream, MockRecvStream) { let queue = kio::Queue::new(); let closed = Arc::new(ClosedSignal::default()); @@ -262,6 +309,7 @@ fn new_stream_pair() -> (MockSendStream, MockRecvStream) { closed: closed.clone(), park: kio::Park::default(), ack_fin: false, + conn: conn.clone(), }; let recv = MockRecvStream { rx: queue, @@ -269,6 +317,7 @@ fn new_stream_pair() -> (MockSendStream, MockRecvStream) { done: false, closed, park: kio::Park::default(), + conn: conn.clone(), }; (send, recv) } @@ -287,6 +336,16 @@ struct ConnectionState { waiters: kio::Fan, } +impl ConnectionState { + /// The close every stream and accept fails with, once the connection closed. + fn error(&self) -> Option { + let state = self.close_state.lock().unwrap(); + state + .as_ref() + .map(|(code, reason)| MockError::session(*code, reason.clone())) + } +} + /// Per-side state: stream queues and a reference to the shared connection. struct SessionSide { /// Bidi streams opened by the peer, popped by accept_bi. @@ -370,8 +429,8 @@ impl poll::Session for MockSession { _cx: &mut Context<'_>, ) -> Poll> { // Create two stream pairs: one for each direction. - let (our_send, peer_recv) = new_stream_pair(); - let (peer_send, our_recv) = new_stream_pair(); + let (our_send, peer_recv) = new_stream_pair(&self.side.conn); + let (peer_send, our_recv) = new_stream_pair(&self.side.conn); // Deliver (peer_send, peer_recv) to the peer's accept_bi. match self.side.peer_bidi.try_push((peer_send, peer_recv)) { @@ -381,7 +440,7 @@ impl poll::Session for MockSession { } fn poll_open_uni(&mut self, _cx: &mut Context<'_>) -> Poll> { - let (mut our_send, peer_recv) = new_stream_pair(); + let (mut our_send, peer_recv) = new_stream_pair(&self.side.conn); if let Some(held) = self.side.held.lock().unwrap().as_mut() { our_send.ack_fin = true; @@ -442,10 +501,7 @@ impl poll::Session for MockSession { self.side.conn.waiters.register(self.closed.hold(cx)); let state = self.side.conn.close_state.lock().unwrap().clone(); match state { - Some((code, reason)) => Poll::Ready(MockError { - code: Some(code), - reason, - }), + Some((code, reason)) => Poll::Ready(MockError::session(code, reason)), None => Poll::Pending, } } @@ -485,17 +541,7 @@ impl MockSession { impl MockSession { fn close_error(&self) -> MockError { - self.side - .conn - .close_state - .lock() - .unwrap() - .as_ref() - .map(|(code, reason)| MockError { - code: Some(*code), - reason: reason.clone(), - }) - .unwrap_or_else(MockError::closed) + self.side.conn.error().unwrap_or_else(MockError::closed) } } diff --git a/rs/moq-tokio/tests/subscription_end_integrity.rs b/rs/moq-tokio/tests/subscription_end_integrity.rs new file mode 100644 index 0000000000..e4da46f1a1 --- /dev/null +++ b/rs/moq-tokio/tests/subscription_end_integrity.rs @@ -0,0 +1,188 @@ +//! Over real QUIC, a subscription ends cleanly only when every group in its range has +//! been accounted for; the publisher disconnecting with a group still in flight ends it +//! with an error. +//! +//! moq-lite, Subscribe: "The publisher closes the stream (FIN) only once every group +//! from start to end has been accounted for, either via a Group Stream (completed or +//! reset) or a SUBSCRIBE_DROP message." And Routing: "when the serving session ends, +//! in-flight subscriptions end with it (a reset)". So `recv_group()` may return +//! `Ok(None)` only after that FIN; any other ending is an error. +//! +//! The in-process twin of this test is `moq-net`'s `subscription_end_integrity`, which +//! pins the rule deterministically over the mock transport. This one repeats it over a +//! real QUIC connection, where the publisher's disconnect is a `CONNECTION_CLOSE` that +//! cuts the final group's stream mid-flight. +//! +//! The subscriber drains a head group first (so the subscription is established and +//! only the tail is at stake), then stops reading for [`STALL`] while the publisher +//! writes a 4 MB final group, finishes the track and disconnects, so the group is still +//! queued behind the connection's flow-control window when the session goes away. The +//! subscriber must then either see every frame or end with an error; it must not end +//! `Ok(None)` short of the final group. +//! +//! Whether the publisher should have been able to flush before disconnecting is a +//! separate question (it has nothing to await today); this test is only about the +//! signal the subscriber gets when it could not. + +use std::time::Duration; + +use moq_tokio::moq_net; + +const TIMEOUT: Duration = Duration::from_secs(10); +const FRAMES: usize = 10; +/// Frames are padded so the final group (4 MB of it) cannot fit in the connection's +/// flow-control window while the subscriber is not reading: without unsent bytes at +/// close, the publisher would only ever lose its FIN, and the test would not cover +/// a group cut mid-flight. +const PADDING: usize = 400_000; +/// How long the subscriber stops reading after the head group, so the final group is +/// still queued in the publisher when the session goes away. +const STALL: Duration = Duration::from_millis(300); + +/// One round: a head group the subscriber drains, then a final group, `finish()`, and +/// the publisher's session dropped with no await in between. Returns how many frames +/// the subscriber read and the error its subscription ended with, if any. +async fn round(drop_session: bool) -> (usize, Option) { + let pub_origin = moq_tokio::origin::spawn(); + let broadcast = pub_origin.create_broadcast("test").expect("create broadcast"); + broadcast.announce(Default::default()).expect("announce"); + let track = broadcast.create_track("video", None).expect("create track"); + + let mut server_config = moq_tokio::listen::Config::default(); + server_config.bind = Some("127.0.0.1:0".parse().unwrap()); + server_config.tls.generate = vec!["localhost".into()]; + let mut server = server_config + .init(Default::default()) + .expect("server init") + .listen() + .await + .expect("listen"); + let port = server.local_addr().expect("local addr").port(); + + // The publisher only learns of a subscription once the subscriber polls it, so the + // reader runs concurrently. It tells the publisher when it has drained the head + // group, so the final group is written against an established subscription. + let (drained_tx, drained_rx) = tokio::sync::oneshot::channel(); + + let reader = tokio::spawn(async move { + let mut client_config = moq_tokio::connect::Config::default(); + client_config.tls.insecure = Some(true); + let sub_origin = moq_tokio::origin::spawn(); + let consumer = sub_origin.consume(); + let client = client_config.init(Default::default()).expect("client init"); + let url: url::Url = format!("https://localhost:{port}").parse().expect("parse url"); + let _connection = client + .with_subscriber(sub_origin) + .with_reconnect(false) + .connect(url) + .established() + .await + .expect("client connect"); + + consumer.routed("test").await.expect("routed"); + let remote = consumer.request_broadcast("test").await.expect("broadcast resolves"); + let mut sub = remote.track("video").unwrap().subscribe(None).await.expect("subscribe"); + + let mut got = 0; + let mut drained = Some(drained_tx); + loop { + let mut group = match sub.recv_group().await { + Ok(Some(group)) => group, + Ok(None) => return (got, None), + Err(err) => return (got, Some(err)), + }; + loop { + match group.read_frame().await { + Ok(Some(_)) => got += 1, + Ok(None) => break, + Err(err) => return (got, Some(err)), + } + } + // The head group is in: from here on, only the tail can be lost. Stop reading + // for a moment, so the publisher's final group is still queued behind the + // connection's flow-control window when it disconnects. + if let Some(tx) = drained.take() { + let _ = tx.send(()); + tokio::time::sleep(STALL).await; + } + } + }); + + let request = tokio::time::timeout(TIMEOUT, server.accept()) + .await + .expect("accept timeout") + .expect("no incoming connection"); + let session = request + .with_publisher(&pub_origin) + .ok() + .await + .expect("publisher session"); + + tokio::time::timeout(TIMEOUT, track.used()) + .await + .expect("no subscriber appeared") + .expect("track closed"); + + write_group(&track); + tokio::time::timeout(TIMEOUT, drained_rx) + .await + .expect("the head group never arrived") + .expect("reader gone"); + + // The final group, a clean group end and a clean track end, innermost first and with + // no await in between: the session has sent none of it yet. + write_group(&track); + track.finish().expect("finish track"); + drop(track); + + // The publisher is done, so it disconnects. Nothing it could have awaited first. + let session = if drop_session { + drop(session); + None + } else { + Some(session) + }; + + let result = tokio::time::timeout(TIMEOUT, reader) + .await + .expect("the subscription never ended") + .expect("reader panicked"); + drop((session, broadcast, pub_origin, server)); + result +} + +/// Append one group of [`FRAMES`] padded frames. +fn write_group(track: &moq_net::track::Producer) { + let mut group = track.append_group().expect("append group"); + for _ in 0..FRAMES { + group + .write_frame(moq_net::Timestamp::ZERO, vec![0; PADDING]) + .expect("write frame"); + } + group.finish().expect("finish group"); +} + +/// The publisher disconnects with the final group in flight: the subscription ends +/// with an error, or delivers every frame. +#[tokio::test] +async fn a_subscription_cut_by_the_publisher_disconnecting_does_not_end_clean() { + let (got, err) = round(true).await; + assert!( + err.is_some() || got == 2 * FRAMES, + "the subscription ended Ok(None) with {got}/{} frames \ + (a subscription that did not deliver every group must end with an error, not as complete)", + 2 * FRAMES, + ); +} + +/// With the session kept alive until the subscriber is done, the same track arrives +/// whole and ends `Ok(None)`. +#[tokio::test] +async fn a_finished_track_ends_clean_while_its_session_lives() { + let (got, err) = round(false).await; + assert!( + err.is_none() && got == 2 * FRAMES, + "session kept alive: got {got}/{} frames, err={err:?}", + 2 * FRAMES, + ); +} From 7461eca6be02ea3331894da54eb0c15f7b848a77 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 20:28:58 -0700 Subject: [PATCH 23/45] chore(moq-net): bench lite-06, smoke-run benches nightly, refresh perf quests (#4229) Co-authored-by: Claude Opus 5.5 --- .github/workflows/nightly.yml | 9 +++++++++ quest/m1/README.md | 2 +- quest/m1/cache-expiry-growth.md | 24 ++++++++++++++---------- quest/m1/perf/README.md | 1 - quest/m1/perf/announce-replay.md | 12 +++++++----- quest/m1/perf/group-cost.md | 9 +++++---- quest/m1/perf/lite-route-rescan.md | 29 ----------------------------- rs/moq-net/benches/session.rs | 5 +++-- 8 files changed, 39 insertions(+), 52 deletions(-) delete mode 100644 quest/m1/perf/lite-route-rescan.md diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 62484ea295..d15d3ff914 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -77,6 +77,15 @@ jobs: nix develop --command bun install --frozen-lockfile nix develop --command bun js/net/bench/broadcasts.ts + # Runs each moq-net Criterion routine once, so a bench that panics (an + # unparsable version, a shape that deadlocks) fails here instead of on the + # next manual run. Timing is not compared; nextest never runs a + # `harness = false` target. `fuzz` enables the announce bench, which the + # `'*'` glob otherwise refuses. + - name: moq-net benchmark smoke + if: ${{ !cancelled() }} + run: nix develop --command cargo bench --locked -p moq-net --features fuzz --bench '*' -- --test + - name: Audit if: ${{ !cancelled() }} run: nix develop --command just rs audit diff --git a/quest/m1/README.md b/quest/m1/README.md index c0f3270309..2077fb1231 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -90,7 +90,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Watch worker](/quest/m1/watch-worker.md) - watch playback runs in a worker onto an OffscreenCanvas, so main-thread jank never stalls video or audio - [Closure counters](/quest/m1/closure-counters.md) - a departed node's return never regresses the closure counters a consumer already saw - [RTMP interleaving](/quest/m1/rtmp-interleaving.md) - isolate partial messages before optimizing assembly copies -- [Cache expiry growth](/quest/m1/cache-expiry-growth.md) - with the default pool, relay memory plateaus at the expiry window on moq-transport as on moq-lite +- [Cache expiry growth](/quest/m1/cache-expiry-growth.md) - with the default pool, relay memory plateaus at the expiry window on every version - [Relay memory](/quest/m1/relay-memory.md) - remeasure what an announcement costs after prefix routes - [PoP skipping](/quest/m1/pop-skipping/README.md) - short cold paths for unpopular broadcasts without losing warm backhaul dedup - [Route cost in the JS origin](/quest/m1/route-cost.md) - the browser origin ranks routes by cost and hops like Rust instead of newest-first diff --git a/quest/m1/cache-expiry-growth.md b/quest/m1/cache-expiry-growth.md index 17cac468ed..fc2ea10f9d 100644 --- a/quest/m1/cache-expiry-growth.md +++ b/quest/m1/cache-expiry-growth.md @@ -1,23 +1,27 @@ -# [S] Cached groups expire on the IETF path +# [S] Cached groups expire under the default pool ## Goal With the default cache pool, a relay's memory plateaus once groups start -reaching the expiry window, on moq-transport as on moq-lite, or the cause of -continued growth is found and fixed. +reaching the expiry window, on every version, or the cause of continued +growth is found and fixed. ## Plan In `session_delivery_broadcasts` with 4096 announced broadcasts and the -default unbounded pool (30 s expiry), IETF RSS reached 1.5 GB at 10 s, 3.5 GB at -30 s, and 4.9 GB at 50 s, still climbing past the expiry window. Lite -reached about 0.36 GB by 50 s. With a 16 MiB bounded pool both stay flat, so -the retained bytes are cached groups, not a leak elsewhere. +default unbounded pool (30 s expiry), RSS keeps climbing past the expiry +window on both wires (2026-09-25, Apple M4, sampled every 10 s): IETF +1.9 GB at 30 s and 4.4 GB at 120 s, lite-06 2.3 GB at 30 s and 3.5 GB at +120 s, both oscillating by a gigabyte between samples. With a 16 MiB bounded +pool both stay flat (0.5 GB IETF, 0.4 GB lite), so the retained bytes are +cached groups, not a leak elsewhere. An earlier run saw growth on IETF only, +but lite was then 30x slower per round, so it wrote far fewer groups; compare +by groups written, not by wall time. Reproduce with a focused test first. Unexpired groups at higher throughput, -expiry not running on a path IETF uses, or groups held outside the pool's -accounting would each explain it. Fix what is actually wrong. Consider -whether an unbounded default is the right default for an origin at all. +expiry not running on some path, or groups held outside the pool's accounting +would each explain it. Fix what is actually wrong. Consider whether an +unbounded default is the right default for an origin at all. ## Related diff --git a/quest/m1/perf/README.md b/quest/m1/perf/README.md index c855d6344d..b0b17f4d6f 100644 --- a/quest/m1/perf/README.md +++ b/quest/m1/perf/README.md @@ -43,7 +43,6 @@ row per io_uring worker. - [Open contract](/quest/m1/perf/uring-open-contract.md) - plan concurrent WebTransport opening and cancellation -- [Lite route rescan](/quest/m1/perf/lite-route-rescan.md) - a moq-lite session's per-group cost stops growing with the routes its peer announced - [Announce replay](/quest/m1/perf/announce-replay.md) - the initial announce set replays in linear time, so joins don't slow with the route count - [Group cost](/quest/m1/perf/group-cost.md) - count and cut the allocations and time spent relaying one small group to one viewer - [One enter per turn](/quest/m1/perf/uring-one-enter.md) - a parking turn pays one io_uring_enter, submits flush deferred completions, and SQEs per enter is a counter diff --git a/quest/m1/perf/announce-replay.md b/quest/m1/perf/announce-replay.md index db7f81b036..3a59f0c7cb 100644 --- a/quest/m1/perf/announce-replay.md +++ b/quest/m1/perf/announce-replay.md @@ -13,10 +13,12 @@ The moq-lite publisher's initial replay (`AnnounceRun::init` in for every route it drains: `initial.retain` on Lite05+, `init.contains` on the Lite01/02 init. That is quadratic in the replay size. -Measured with `session_join_broadcasts` (2026-09, one relay): lite join takes -199 µs with 1 announced broadcast, 517 µs with 64, and 8.9 ms with 1024, -about 8.7 µs per route at the top. IETF is 7.2 ms at 1024 with no quadratic -scan; its profile is SipHash hashing and `Path` comparison, so check whether -a faster hasher for path-keyed maps pays off on both. +Measured with `session_join_broadcasts` (2026-09-25, one relay, Apple M4): +lite-06 join takes 128 µs with 1 announced broadcast, 317 µs with 64, and +5.0 ms with 1024, about 4.9 µs per route at the top; lite-07-wip matches. +IETF is 95 µs, 311 µs, and 4.05 ms, with no quadratic scan, so the scan is +the ~1 ms gap at 1024. An earlier Linux profile of the IETF path was SipHash +hashing and `Path` comparison, so check whether a faster hasher for +path-keyed maps pays off on both. Keep the replay's order and its last-update-wins semantics. diff --git a/quest/m1/perf/group-cost.md b/quest/m1/perf/group-cost.md index b558d76b62..cec74b3338 100644 --- a/quest/m1/perf/group-cost.md +++ b/quest/m1/perf/group-cost.md @@ -8,10 +8,11 @@ change to what is delivered. ## Plan -`session_delivery_viewers` (2026-09) spends about 32 µs per viewer per -4-frame, 64-byte group over the in-memory transport, through a publisher -session, a relay origin, and a viewer session. No single function dominates. -The profile spreads it over `kio` waiter registration and parking, +`session_delivery_viewers` spends about 26 µs per viewer per 4-frame, +64-byte group over the in-memory transport, through a publisher session, a +relay origin, and a viewer session: ~420 µs at 16 viewers on every version +(2026-09-25, Apple M4; the 256-viewer point is too noisy on that machine to +quote). No single function dominates. An earlier Linux profile spread it over `kio` waiter registration and parking, `TrackState::evict_expired_scan` (4-5%), `Waiter`'s lazily allocated shared waker (`Once::call`, about 3%, so waiters are created per poll), and malloc/free (10-12%). diff --git a/quest/m1/perf/lite-route-rescan.md b/quest/m1/perf/lite-route-rescan.md deleted file mode 100644 index 6591609912..0000000000 --- a/quest/m1/perf/lite-route-rescan.md +++ /dev/null @@ -1,29 +0,0 @@ -# [S] Lite subscriber polls only routes with a request - -## Goal - -A moq-lite session's per-group cost stops growing with the number of -broadcasts its peer has announced: delivery over a session holding thousands -of announced routes costs what it costs with a handful, as it already does -over moq-transport. - -## Plan - -`Announced::poll_serve` (`rs/moq-net/src/lite/subscriber.rs`) polls every -attached route's `Dynamic::poll_requested_broadcast` on every driver wake, so -each incoming group pays for every route, and each pending poll registers the -driver's waiter on every idle route's list. The IETF subscriber runs one task -per route and only wakes the one that got a request. - -Measured with `cargo bench -p moq-net --bench session` (2026-09, one relay, -16 viewers each watching one broadcast): - -- `session_delivery_broadcasts`: lite 445 µs at 16 announced, 15.2 ms at - 4096; IETF flat around 458 µs. 57% of lite CPU is `WaiterList::register` - under `poll_requested_broadcast`. -- `session_delivery_scale` at 256 publishers x 256 viewers: lite 40 ms, IETF - 16.5 ms, since every viewer session holds all 256 routes. - -Wake only routes with a pending request. A task per route like IETF's or a -ready set both fit; pick by what keeps the driver's fairness rules. Land -with the before/after of those two groups. diff --git a/rs/moq-net/benches/session.rs b/rs/moq-net/benches/session.rs index 42e65e2fd2..b093dc6db1 100644 --- a/rs/moq-net/benches/session.rs +++ b/rs/moq-net/benches/session.rs @@ -28,8 +28,9 @@ use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_m use moq_net::{Hop, Timestamp, Version, broadcast, cache, origin, track}; use support::harness::{MockConnectOptions, MockPair, connect_mock}; -/// Newest lite draft (`moq-lite-07-wip`, opt-in) and newest IETF draft. -const VERSIONS: [&str; 2] = ["moq-lite-07-wip", "moq-transport-22"]; +/// The lite draft production negotiates, the next one (opt-in), and the newest +/// IETF draft. +const VERSIONS: [&str; 3] = ["moq-lite-06", "moq-lite-07-wip", "moq-transport-22"]; /// Frames per group, so per-frame and per-group costs both appear. const FRAMES: usize = 4; From b33e926cad01acfea9b44717043dad6724ca7809 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 20:30:10 -0700 Subject: [PATCH 24/45] chore: bump package versions Patch bumps for unreleased behavior since #3879. Rust stays with release-plz. Go stays at 0.7 because the wrapper additions are compatible and CI owns that patch. Co-Authored-By: Grok --- bun.lock | 20 ++++++++++---------- js/auth/package.json | 2 +- js/binary/package.json | 2 +- js/hang/package.json | 2 +- js/json/package.json | 2 +- js/moq-boy/package.json | 2 +- js/net/package.json | 2 +- js/publish/package.json | 2 +- js/room/package.json | 2 +- js/signals/package.json | 2 +- js/watch/package.json | 2 +- kt/README.md | 2 +- kt/gradle.properties | 2 +- py/moq-rs/pyproject.toml | 2 +- swift/README.md | 2 +- swift/VERSION | 2 +- uv.lock | 2 +- 17 files changed, 26 insertions(+), 26 deletions(-) diff --git a/bun.lock b/bun.lock index 49a1ec1627..4005df50c8 100644 --- a/bun.lock +++ b/bun.lock @@ -74,7 +74,7 @@ }, "js/auth": { "name": "@moq/auth", - "version": "0.2.0", + "version": "0.2.1", "bin": { "moq-auth": "./src/cli.ts", }, @@ -95,7 +95,7 @@ }, "js/binary": { "name": "@moq/binary", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@moq/flate": "workspace:^", "@moq/net": "workspace:^", @@ -138,7 +138,7 @@ }, "js/hang": { "name": "@moq/hang", - "version": "0.5.0", + "version": "0.5.1", "dependencies": { "@kixelated/libavjs-webcodecs-polyfill": "^0.5.5", "@libav.js/variant-opus-af": "^6.10.9", @@ -162,7 +162,7 @@ }, "js/json": { "name": "@moq/json", - "version": "0.4.0", + "version": "0.4.1", "dependencies": { "@moq/flate": "workspace:^", "@moq/net": "workspace:^", @@ -192,7 +192,7 @@ }, "js/moq-boy": { "name": "@moq/boy", - "version": "0.4.0", + "version": "0.4.1", "dependencies": { "@moq/json": "workspace:^", "@moq/net": "workspace:^", @@ -226,7 +226,7 @@ }, "js/net": { "name": "@moq/net", - "version": "0.4.0", + "version": "0.4.1", "dependencies": { "@moq/pattern": "workspace:^", "@moq/qmux": "^0.3.3", @@ -258,7 +258,7 @@ }, "js/publish": { "name": "@moq/publish", - "version": "0.5.0", + "version": "0.5.1", "dependencies": { "@moq/hang": "workspace:^", "@moq/json": "workspace:^", @@ -278,7 +278,7 @@ }, "js/room": { "name": "@moq/room", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@moq/hang": "workspace:^", "@moq/json": "workspace:^", @@ -298,7 +298,7 @@ }, "js/signals": { "name": "@moq/signals", - "version": "0.2.4", + "version": "0.2.5", "devDependencies": { "@types/bun": "^1.4.2", "@types/react": "^19.3.0", @@ -324,7 +324,7 @@ }, "js/watch": { "name": "@moq/watch", - "version": "0.6.0", + "version": "0.6.1", "dependencies": { "@moq/hang": "workspace:^", "@moq/json": "workspace:^", diff --git a/js/auth/package.json b/js/auth/package.json index 45b8056bda..60f2a21c2d 100644 --- a/js/auth/package.json +++ b/js/auth/package.json @@ -1,7 +1,7 @@ { "name": "@moq/auth", "type": "module", - "version": "0.2.0", + "version": "0.2.1", "description": "Media over QUIC - the authorization contract: requests, grants, and the JWT that rides them", "license": "(MIT OR Apache-2.0)", "repository": { diff --git a/js/binary/package.json b/js/binary/package.json index 849e5f012a..fa1f4487fb 100644 --- a/js/binary/package.json +++ b/js/binary/package.json @@ -1,7 +1,7 @@ { "name": "@moq/binary", "type": "module", - "version": "0.2.0", + "version": "0.2.1", "description": "Binary publishing over MoQ tracks: latest-value snapshots, or append-log streams.", "license": "(MIT OR Apache-2.0)", "repository": { diff --git a/js/hang/package.json b/js/hang/package.json index 5b38723514..51bcaf28d6 100644 --- a/js/hang/package.json +++ b/js/hang/package.json @@ -1,7 +1,7 @@ { "name": "@moq/hang", "type": "module", - "version": "0.5.0", + "version": "0.5.1", "description": "WebCodecs-based media format for MoQ", "license": "(MIT OR Apache-2.0)", "repository": { diff --git a/js/json/package.json b/js/json/package.json index 6bb51ecc61..989fa04116 100644 --- a/js/json/package.json +++ b/js/json/package.json @@ -1,7 +1,7 @@ { "name": "@moq/json", "type": "module", - "version": "0.4.0", + "version": "0.4.1", "description": "JSON publishing over MoQ tracks: snapshot/delta (RFC 7396 merge patch) objects, or append-log NDJSON streams.", "license": "(MIT OR Apache-2.0)", "repository": { diff --git a/js/moq-boy/package.json b/js/moq-boy/package.json index 6d29510d5e..51ad777bff 100644 --- a/js/moq-boy/package.json +++ b/js/moq-boy/package.json @@ -1,7 +1,7 @@ { "name": "@moq/boy", "type": "module", - "version": "0.4.0", + "version": "0.4.1", "jsr": false, "description": "MoQ Boy - Crowd-controlled Game Boy streaming via MoQ", "license": "(MIT OR Apache-2.0)", diff --git a/js/net/package.json b/js/net/package.json index bc5620c6f0..ce53b03637 100644 --- a/js/net/package.json +++ b/js/net/package.json @@ -1,7 +1,7 @@ { "name": "@moq/net", "type": "module", - "version": "0.4.0", + "version": "0.4.1", "description": "The networking layer for Media over QUIC: real-time pub/sub with built-in caching, fan-out, and prioritization.", "license": "(MIT OR Apache-2.0)", "repository": { diff --git a/js/publish/package.json b/js/publish/package.json index e9c38f3ea4..f1b4cd30a7 100644 --- a/js/publish/package.json +++ b/js/publish/package.json @@ -1,7 +1,7 @@ { "name": "@moq/publish", "type": "module", - "version": "0.5.0", + "version": "0.5.1", "jsr": false, "description": "Publish Media over QUIC broadcasts", "license": "(MIT OR Apache-2.0)", diff --git a/js/room/package.json b/js/room/package.json index 532c04390a..18381bf6fd 100644 --- a/js/room/package.json +++ b/js/room/package.json @@ -1,7 +1,7 @@ { "name": "@moq/room", "type": "module", - "version": "0.2.0", + "version": "0.2.1", "description": "Headless multi-participant rooms over MoQ: announce-derived roster, local publish, remote watch, and a chat track", "license": "(MIT OR Apache-2.0)", "repository": { diff --git a/js/signals/package.json b/js/signals/package.json index acc547b8f1..cd9387dee7 100644 --- a/js/signals/package.json +++ b/js/signals/package.json @@ -1,7 +1,7 @@ { "name": "@moq/signals", "type": "module", - "version": "0.2.4", + "version": "0.2.5", "description": "Reactive and safe signals", "license": "(MIT OR Apache-2.0)", "repository": { diff --git a/js/watch/package.json b/js/watch/package.json index 9b4a0016eb..7055fb40b0 100644 --- a/js/watch/package.json +++ b/js/watch/package.json @@ -1,7 +1,7 @@ { "name": "@moq/watch", "type": "module", - "version": "0.6.0", + "version": "0.6.1", "jsr": false, "description": "Watch Media over QUIC broadcasts", "license": "(MIT OR Apache-2.0)", diff --git a/kt/README.md b/kt/README.md index e9d4ec3de7..62c509e960 100644 --- a/kt/README.md +++ b/kt/README.md @@ -14,7 +14,7 @@ Most apps want `dev.moq:moq`. Reach for `dev.moq:moq-ffi` directly only if you w ```kotlin ignore // build.gradle.kts dependencies { - implementation("dev.moq:moq:0.5.0") + implementation("dev.moq:moq:0.5.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") } ``` diff --git a/kt/gradle.properties b/kt/gradle.properties index 94109cbaf9..8967643904 100644 --- a/kt/gradle.properties +++ b/kt/gradle.properties @@ -14,7 +14,7 @@ moqffi.version=0.0.0-dev # Wrapper (`dev.moq:moq`) version. Source of truth, bumped by hand. Independent # of the crate: release-kt-lib.yml publishes a new version only when this # changes. Must stay >= the last published wrapper version. -moq.version=0.5.0 +moq.version=0.5.1 # Publishing is disabled by default. CI flips this when MAVEN_CENTRAL_USERNAME # and SIGNING_KEY secrets are configured. See README.md for setup details. diff --git a/py/moq-rs/pyproject.toml b/py/moq-rs/pyproject.toml index 815d863c29..71c3999f46 100644 --- a/py/moq-rs/pyproject.toml +++ b/py/moq-rs/pyproject.toml @@ -14,7 +14,7 @@ name = "moq-rs" # isn't already there (no tag needed; the registry is the gate). # Starts at 0.3.0 to open a new line above the old lockstep 0.2.x releases that # bundled the bindings. -version = "0.5.0" +version = "0.5.1" description = "Media over QUIC: real-time pub/sub with built-in caching, fan-out, and prioritization, with a Pythonic API. Import as `moq`." readme = "README.md" license = "MIT OR Apache-2.0" diff --git a/swift/README.md b/swift/README.md index a953a0c648..766f1ff96e 100644 --- a/swift/README.md +++ b/swift/README.md @@ -16,7 +16,7 @@ The Swift integration ships as two SPM packages, each mirrored to its own repo: ## Install ```swift ignore -.package(url: "https://github.com/moq-dev/moq-swift", from: "0.5.0"), +.package(url: "https://github.com/moq-dev/moq-swift", from: "0.5.1"), ``` SPM resolves `MoqFFI` (and its prebuilt `MoqFFI.xcframework`, attached to the matching `moq-ffi-v*` GitHub Release) transitively. You only depend on `moq-swift`. diff --git a/swift/VERSION b/swift/VERSION index 8f0916f768..4b9fcbec10 100644 --- a/swift/VERSION +++ b/swift/VERSION @@ -1 +1 @@ -0.5.0 +0.5.1 diff --git a/uv.lock b/uv.lock index 5856edeab7..feb20b9b8a 100644 --- a/uv.lock +++ b/uv.lock @@ -509,7 +509,7 @@ source = { editable = "py/moq-ffi" } [[package]] name = "moq-rs" -version = "0.5.0" +version = "0.5.1" source = { editable = "py/moq-rs" } dependencies = [ { name = "moq-ffi" }, From bd0376f2d2e0de6a0c68fe601827ca48d1e8b7dc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 20:50:24 -0700 Subject: [PATCH 25/45] fix(net): await the publisher answer for lite fetches (#4221) Co-authored-by: GPT-6 Co-authored-by: Grok 4.7 --- doc/lib/js/net.md | 2 +- js/net/src/broadcast.ts | 5 +- js/net/src/integration.test.ts | 90 ++++++++++++++++++++++++++++++++++ js/net/src/lite/subscriber.ts | 45 ++++++++++------- quest/m1/README.md | 1 - quest/m1/js-fetch-answer.md | 32 ------------ 6 files changed, 120 insertions(+), 55 deletions(-) delete mode 100644 quest/m1/js-fetch-answer.md diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index f086c11cd9..833961ec74 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -53,7 +53,7 @@ for (;;) { - **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `prefix` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.broadcasts(scope)` is a live `Getter>` of the same covered prefixes for UIs that need the current set. A borrowed `Connection.origin` also exposes `dynamic(prefix, route)` for serving paths on demand. - **Subscriptions** carry a priority, a `Time.Milli` max age, and optional `groups` bounds. Groups arrive out of order and are read frame by frame, with `Error.TooFarBehind` when a reader asks for a frame the group never held and `Error.GroupTooLarge` when a write exceeds the cache budget and aborts the group. - **Track ends**: `close()` ends a track at its live edge, while `finishAt(n)` declares the exclusive end ahead of it and still accepts the groups below. A subscriber reads the end with `final()` or awaits `finished()`. A remote track ends only once every group below its end has arrived or was dropped; one reset before its header arrived is skipped after the subscription's max age on moq-lite (one second without one), or after one second on IETF. -- **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. +- **Datagrams** on moq-lite 05+ and fetch-by-sequence for history. `track.fetchGroup(sequence)` on moq-lite resolves when the publisher sends the first response byte or finishes an empty group. A missing group rejects the fetch with `StreamCode.NotFound`, including every concurrent caller sharing that fetch. - **Errors** live under one namespace: a stream reset throws `Error.Stream` with a `StreamCode`, while a session close gives `Error.Session` with a `SessionCode`. The registries are disjoint, so the same number means different things in each, and 64+ is yours. Named conditions such as `Error.TooFarBehind`, `Error.FrameTooLarge`, and `Error.GroupTooLarge` subclass `Error.Stream`, so one `code` check handles a condition raised here or reported by the peer. IETF streams use their own mapping: cancellation sends CANCELLED, other local failures send INTERNAL\_ERROR, and received codes remain opaque. - **Paths** with `Path.relative` for the cross-broadcast catalog references hang uses. Path patterns (`Path.Pattern`, `Path.Patterns`) are re-exported from [`@moq/pattern`](https://www.npmjs.com/package/@moq/pattern). Literal `Path` stays a coordinate. diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index 41cbeca2ef..32bc42ad8e 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -4,6 +4,7 @@ * @module */ import { type GetPromise, Once, Signal } from "@moq/signals"; +import { NotFound } from "./error.ts"; import type { Consumer as GroupConsumer } from "./group.ts"; import { Route } from "./hop.ts"; import { hooks, type TrackSequence } from "./internal.ts"; @@ -131,7 +132,7 @@ async function fetchGroup( try { for (;;) { const group = await subscriber.recvGroup(); - if (!group) throw new Error(`group not found: ${sequence}`); + if (!group) throw new NotFound(`group ${sequence}`); if (group.sequence === sequence) { // Close the subscription when the returned group finishes, not now: an // in-progress group must keep receiving frames for its lifetime (mirrors @@ -141,7 +142,7 @@ async function fetchGroup( } group.close(); - if (group.sequence > sequence) throw new Error(`group not found: ${sequence}`); + if (group.sequence > sequence) throw new NotFound(`group ${sequence}`); } } catch (err) { subscriber.close(); diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index aaed493344..249f4b1bb9 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -10,6 +10,7 @@ import { type Established, } from "./connection/index.ts"; import { SessionCode, SessionError, StreamCode, StreamError, TooFarBehind } from "./error.ts"; +import { Producer as GroupProducer } from "./group.ts"; import * as Ietf from "./ietf/index.ts"; import * as Lite from "./lite/index.ts"; import { createMockTransportPair } from "./mock.ts"; @@ -868,6 +869,95 @@ test("integration: lite draft-05 fetches a cached group", async () => { server.close(); }); +test.each(["gap", "end"])("integration: lite fetch rejects coalesced misses at %s with NotFound", async (missing) => { + const pair = createMockTransportPair(Lite.ALPN_05); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); + const broadcast = publish(origin, Path.from("test")); + let serving: Promise | undefined; + if (missing === "gap") { + const producer = broadcast.createTrack("video"); + const group = new GroupProducer(1); + producer.writeGroup(group); + group.close(); + } else { + serving = (async () => { + for (;;) { + const request = await wireOf(broadcast).requested(); + if (!request) return; + request.accept().close(); + } + })(); + } + const remote = wireOf(client).consume(Path.from("test")); + try { + const track = remote.track("video"); + const results = await Promise.allSettled([track.fetchGroup(0), track.fetchGroup(0)]); + for (const result of results) { + expect(result.status).toBe("rejected"); + if (result.status !== "rejected") throw new Error("missing group was accepted"); + expect(result.reason).toBeInstanceOf(StreamError); + expect(result.reason.code).toBe(StreamCode.NotFound); + } + if (results[0].status === "rejected" && results[1].status === "rejected") { + expect(results[0].reason).toBe(results[1].reason); + } + } finally { + broadcast.close(); + await serving; + remote.close(); + client.close(); + server.close(); + origin.close(); + } +}); + +test.each(["frame", "FIN"])("integration: lite fetch waits for the publisher's first %s", async (answer) => { + const pair = createMockTransportPair(Lite.ALPN_05); + const origin = new OriginProducer(); + const [client, server] = await Promise.all([ + connect(url, { transport: pair.client }), + accept(pair.server, url, { publish: origin.consume() }), + ]); + const broadcast = publish(origin, Path.from("test")); + const producer = broadcast.createTrack("video"); + const group = producer.appendGroup(); + const remote = wireOf(client).consume(Path.from("test")); + try { + let settled = 0; + const fetch = () => + remote + .track("video") + .fetchGroup(0) + .then((consumer) => { + settled++; + return consumer; + }); + const a = fetch(); + const b = fetch(); + // Let the in-memory peer process the request with no response available yet. + await sleep(0); + expect(settled).toBe(0); + if (answer === "frame") group.writeString("accepted"); + else group.close(); + const consumers = await Promise.all([a, b]); + for (const consumer of consumers) { + expect(await consumer.readString()).toBe(answer === "frame" ? "accepted" : undefined); + consumer.close(); + } + } finally { + group.close(); + broadcast.close(); + remote.close(); + client.close(); + server.close(); + origin.close(); + } +}); + test("integration: lite draft-05 coalesces concurrent fetches of one group", async () => { const enc = new TextEncoder(); const dec = new TextDecoder(); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index eb5aff5569..913abf2998 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -132,7 +132,7 @@ export class Subscriber { // Dedup in-flight one-shot fetches, keyed by [broadcast, track, sequence]. Concurrent (or // repeat, while still open) fetchGroup() calls for the same group share one FETCH stream and // each get an independent mirror; the entry is evicted once the group closes. - #fetches = new Map(); + #fetches = new Map }>(); // The peer's PROBE estimates, written as they arrive (Lite03+ only). #probe?: Signal; @@ -704,7 +704,7 @@ export class Subscriber { // Open a FETCH stream for one group and stream its bare frames into a group, for the // ConsumeBroadcast backing track.Consumer.fetchGroup() (lite-05+). - fetchGroup( + async fetchGroup( broadcast: Path.Valid, track: string, sequence: number, @@ -713,29 +713,37 @@ export class Subscriber { // Coalesce onto a still-open fetch of the same group so we don't open a second FETCH // stream (and re-download it); each caller reads an independent mirror. const key = JSON.stringify([broadcast, track, sequence]); - const existing = this.#fetches.get(key); - if (existing && !existing.isClosed) return Promise.resolve(existing.mirror()); - - // Create and cache the group synchronously (before any await) so a concurrent fetch for - // the same group finds it and coalesces rather than racing to open its own stream. - const group = new netGroup.Producer(sequence); - this.#fetches.set(key, group); - void group.closed.then(() => { - if (this.#fetches.get(key) === group) this.#fetches.delete(key); - }); + let entry = this.#fetches.get(key); + if (!entry || entry.group.isClosed) { + const group = new netGroup.Producer(sequence); + entry = { group, accepted: this.#runFetch(broadcast, track, sequence, options, group) }; + this.#fetches.set(key, entry); + void group.closed.then(() => { + if (this.#fetches.get(key)?.group === group) this.#fetches.delete(key); + }); + } - return this.#runFetch(broadcast, track, sequence, options, group); + // Reserve each caller's mirror before awaiting acceptance so the pump sees demand, + // and a fast FIN cannot discard frames before these callers receive their handles. + const consumer = entry.group.mirror(); + try { + await entry.accepted; + return consumer; + } catch (err) { + consumer.close(); + throw err; + } } // Open the FETCH stream and pump the response into the shared group. Setup errors close the - // group (so coalesced mirrors observe them and the entry evicts) and reject this caller. + // group, evict the entry, and reject every caller waiting for acceptance. async #runFetch( broadcast: Path.Valid, track: string, sequence: number, options: track.FetchGroupOptions, group: netGroup.Producer, - ): Promise { + ): Promise { try { if (!supportsTrackStream(this.version)) { throw new Error("fetch group requires moq-lite-05 or newer"); @@ -751,16 +759,15 @@ export class Subscriber { stream.writer, this.version, ); + // A byte or an empty-group FIN accepts the fetch; a reset rejects it. + // done() buffers that byte so the response pump can decode it normally. + await stream.reader.done(); } catch (err: unknown) { stream.abort(error(err)); throw err; } - // Mint this caller's reader before starting the pump, so the group has demand when the - // pump begins watching it (an abandoned fetch cancels once every reader has left). - const consumer = group.mirror(); void this.#runFetchResponse(stream, group, Time.Timescale(info.timescale)); - return consumer; } catch (err: unknown) { group.close(error(err)); throw err; diff --git a/quest/m1/README.md b/quest/m1/README.md index 2077fb1231..5aef7064c5 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -17,7 +17,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. ## Quests -- [JS fetch answer](/quest/m1/js-fetch-answer.md) - js/net's lite fetch settles on the publisher's answer, and a JS publisher's miss resets with NotFound - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached - [Dropped sources](/quest/m1/dropped-sources.md) - consumers see the producer's real error on every end path, never `Dropped` diff --git a/quest/m1/js-fetch-answer.md b/quest/m1/js-fetch-answer.md deleted file mode 100644 index 0e9bc28b3b..0000000000 --- a/quest/m1/js-fetch-answer.md +++ /dev/null @@ -1,32 +0,0 @@ -# [S] JS lite fetch waits for the publisher's answer - -## Goal - -`js/net`'s lite `fetchGroup` resolves only once the publisher has answered: -the first response byte, or a FIN for an empty group. A missing group rejects -the fetch itself, and every coalesced caller sees the same rejection, instead -of receiving a group whose first `readFrame()` fails. A JS publisher that -cannot serve a group resets the stream with `NotFound`, not a generic error, -so a Rust or JS subscriber can tell a miss from a failure. - -## Plan - -- Rust already behaves this way since #4164, which waits in the lite - subscriber before accepting and rejects on reset. Mirror it: resolve after - the first response byte or an empty-group FIN, and reject on reset. Today - the fetch path returns its mirror before the response arrives; the stream - reader can already block until data or FIN and throw on reset. -- The publisher side throws a plain error for a local miss, which reaches the - wire as a generic reset code. Give it the `NotFound` code the Rust side uses. -- The IETF JS path refuses `fetchGroup` outright and is out of scope. -- Tests in the lite integration suite: a missing group rejects the fetch, a - coalesced second caller rejects too, an existing group is unchanged, and a - JS publisher's miss reaches a subscriber as `NotFound`. - -Public API: none; a behavior change in when `fetchGroup` settles. Wire: no -format change; a miss resets with the existing `NotFound` code instead of a -generic one. - -## Related - -- [#4164](https://github.com/moq-dev/moq/pull/4164) - the same fix in Rust From c81aaae1eb1a4df836593cd681c2dc33b9dda321 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 21:04:32 -0700 Subject: [PATCH 26/45] fix(cli): keep delayed playback at the live edge (#4241) Co-authored-by: GPT-6 --- doc/bin/cli.md | 8 + quest/m1/README.md | 1 - quest/m1/play-tunein-backpressure.md | 44 --- rs/moq-cli/Cargo.toml | 4 + rs/moq-cli/benches/play_buffer.rs | 38 +++ rs/moq-cli/src/play/buffer.rs | 148 ++++++++++ rs/moq-cli/src/play/fake.rs | 18 ++ rs/moq-cli/src/play/media.rs | 179 ++++++++---- rs/moq-cli/src/play/mod.rs | 3 + rs/moq-cli/src/play/timeline.rs | 7 + rs/moq-cli/src/play/video.rs | 405 +++++++++++++++++++++++++++ 11 files changed, 749 insertions(+), 106 deletions(-) delete mode 100644 quest/m1/play-tunein-backpressure.md create mode 100644 rs/moq-cli/benches/play_buffer.rs create mode 100644 rs/moq-cli/src/play/buffer.rs create mode 100644 rs/moq-cli/src/play/video.rs diff --git a/doc/bin/cli.md b/doc/bin/cli.md index 8aca668760..290e9fd853 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -106,6 +106,14 @@ actually is. While video owns the clock, a frame arriving earlier than predicted pulls playback forward, so a late start catches up to live instead of staying behind it. Once the speaker owns the clock, video follows the speaker instead. +Video receives encoded frames independently of decoding, so a tune-in burst can +update that clock even while the window is waiting for its first picture. +Encoded video is retained within the delay budget with byte accounting; a skip +resumes at a keyframe. Decoding starts at most 100 ms before presentation, and +the window holds at most three decoded pictures. A stalled window loses its +oldest picture instead of blocking reception. The configured delay therefore +does not turn into seconds of raw video surfaces. + Each role follows the catalog for as long as it lasts. Each decoder starts at the newest cached group, including when a rendition is reopened, so playback does not replay the retained backlog. A publisher that retires the rendition diff --git a/quest/m1/README.md b/quest/m1/README.md index 5aef7064c5..a0ff60bb93 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -42,7 +42,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Jitter clock](/quest/m1/jitter-flush-clock.md) - renditions advertise `delay` (lag behind the earliest track) and `jitter` (spread), measured at encoder flush, never lowered; js/watch sizes playout over what it subscribes - [Import discontinuity](/quest/m1/import-discontinuity.md) - a seek or pause resets the flush jitter baseline, from moqsink, libmoq, and moq-ffi - [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` -- [Play tune-in backpressure](/quest/m1/play-tunein-backpressure.md) - moq play: a tune-in burst larger than the video queue parks the decoder, so the clock never reaches live at a wide `--delay` - [Moxygen compatibility](/quest/m1/moxygen/README.md) - one subgroup per group, whole-group FETCH, and one datagram per group, never a full moxygen pass - [JavaScript FETCH](/quest/m1/js-fetch.md) - generic on-demand group serving and IETF FETCH for browser publishers - [Archive](/quest/m1/archive/README.md) - record selected tracks to any object_store and replay them over FETCH or derived HLS, on the catalog and store the release ships diff --git a/quest/m1/play-tunein-backpressure.md b/quest/m1/play-tunein-backpressure.md deleted file mode 100644 index b19a7bc2fd..0000000000 --- a/quest/m1/play-tunein-backpressure.md +++ /dev/null @@ -1,44 +0,0 @@ -# [M] play: a tune-in burst must not stall behind the video queue - -## Goal - -`moq play --delay` above roughly a second reaches the live edge as quickly as -the default does. Today the picture can settle a further delay behind live and -stay there for the session. - -`play_video` in `rs/moq-cli/src/play/media.rs` folds each frame into the playout -clock as it is read, then parks on `drained` once the queue holds -`MAX_VIDEO_FRAMES` (30). The clock only moves toward live when a frame arrives -earlier than the anchor predicted, so a parked decoder cannot observe the rest -of a tune-in burst. With `--delay 2s` at 30 fps the queue is a second of media, -the window presents nothing for two seconds, and by the time it drains the -remaining burst frames are late under the old anchor, which `Pacer` deliberately -refuses to re-anchor on. The anchor stays pinned wherever the first frame landed. - -## Plan - -The frames are raw decoded surfaces, so a queue sized by the delay is not the -answer: 30 frames of 1080p NV12 is already ~90 MB. - -Chosen A/V policy: buffer encoded frames for the whole `max_age` window, -decode only a few ahead of presentation, and evict the oldest decoded frame -when that small queue fills. Video keeps observing the live edge without moving -the anchor while audio owns it. `moq_video::decode::Consumer` owns both the -container reader and the native decoder today, so split or compose them without -duplicating the subscription and codec rules. Bound the encoded buffer by media -age and account for its bytes; `--delay` allows 10s, which as raw 1080p frames -would be ~900 MB. - -The regression lives in #3946 (`refs/pull/3946/head`, `play::media::tests`): -61 frames at 30fps, a 2s delay, and no window drain park the decoder at frame 31, leaving the newest frame due 990ms late. Port it onto -the harness, and also cover video-only and speaker-owned anchors, delayed -drains, reordering, discontinuity, and the decoder's tail flush. - -The harness (`play::fake::Recorder`, driving `Media` on a paused tokio clock) -records the speaker and the window's wakes, but nothing presents video yet: a -test drains the shared queue itself, so add a fake presenter that pops frames -as `Presentation::due` allows, mirroring `window.rs`. - -## Related - -- [Playout clock](https://github.com/moq-dev/moq/pull/3528) - added the clock this bounds diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index a1739c6a86..6065077ddd 100644 --- a/rs/moq-cli/Cargo.toml +++ b/rs/moq-cli/Cargo.toml @@ -134,3 +134,7 @@ tempfile = { workspace = true } # `test-util` enables `tokio::time::pause` so the TS round-trip test simulates the # exporter drain timeouts instantly instead of waiting on the wall clock. tokio = { workspace = true, features = ["test-util"] } + +[[bench]] +name = "play_buffer" +harness = false diff --git a/rs/moq-cli/benches/play_buffer.rs b/rs/moq-cli/benches/play_buffer.rs new file mode 100644 index 0000000000..758c6ea827 --- /dev/null +++ b/rs/moq-cli/benches/play_buffer.rs @@ -0,0 +1,38 @@ +//! Encoded retention cost and peak memory, swept over delay and bitrate. +//! Run with `cargo bench -p moq-cli --bench play_buffer`. + +#[allow(dead_code)] +#[path = "../src/play/buffer.rs"] +mod buffer; + +use std::hint::black_box; +use std::time::{Duration, Instant}; + +fn main() { + for delay_ms in [100, 2_000, 10_000] { + for payload_bytes in [1_024, 65_536] { + let frames: Vec<_> = (0..300) + .map(|index| moq_mux::container::Frame { + timestamp: hang::moq_net::Timestamp::from_millis(index * 33).unwrap(), + duration: None, + payload: bytes::Bytes::from(vec![128; payload_bytes]), + keyframe: index % 30 == 0, + }) + .collect(); + let start = Instant::now(); + let mut peak = 0; + for _ in 0..1_000 { + let mut buffer = buffer::Buffer::default(); + for frame in &frames { + buffer.push(black_box(frame.clone()), Duration::from_millis(delay_ms)); + peak = peak.max(buffer.bytes); + } + black_box(buffer); + } + println!( + "delay={delay_ms}ms payload={payload_bytes}B: {:.1} ns/frame, peak={peak}B", + start.elapsed().as_nanos() as f64 / 300_000.0 + ); + } + } +} diff --git a/rs/moq-cli/src/play/buffer.rs b/rs/moq-cli/src/play/buffer.rs new file mode 100644 index 0000000000..3c9e9599c0 --- /dev/null +++ b/rs/moq-cli/src/play/buffer.rs @@ -0,0 +1,148 @@ +//! Encoded playback retention in media time. + +use hang::moq_net; +use moq_mux::container::Frame; +use std::collections::VecDeque; +use std::time::Duration; + +/// Encoded access units stay in decode order, even when their PTS reorders. +#[derive(Default)] +pub(super) struct Buffer { + pub frames: VecDeque, + pub bytes: usize, + edge: Option, + // Monotone minimum: PTS can reorder behind the front of decode order. + oldest: VecDeque, + pub generation: u64, + pub floor: Option, + waiting_keyframe: bool, + pub ended: bool, +} + +impl Buffer { + pub fn clear(&mut self) { + self.frames.clear(); + self.oldest.clear(); + self.bytes = 0; + self.edge = None; + self.generation += 1; + self.waiting_keyframe = true; + } + + pub fn pop(&mut self) -> Option { + let frame = self.frames.pop_front()?; + self.bytes -= frame.payload.len(); + if self.oldest.front() == Some(&frame.timestamp) { + self.oldest.pop_front(); + } + Some(frame) + } + + pub fn push(&mut self, frame: Frame, max_age: Duration) { + let edge = *self.edge.get_or_insert(frame.timestamp); + self.edge = Some(edge.max(frame.timestamp)); + if frame.keyframe { + if self.waiting_keyframe { + self.floor = Some(frame.timestamp); + } + self.waiting_keyframe = false; + } + if self.waiting_keyframe { + return; + } + while self.oldest.back().is_some_and(|at| *at > frame.timestamp) { + self.oldest.pop_back(); + } + self.oldest.push_back(frame.timestamp); + self.bytes += frame.payload.len(); + self.frames.push_back(frame); + let edge = self.edge.unwrap(); + let stale = + |oldest: &moq_net::Timestamp| edge.as_micros().saturating_sub(oldest.as_micros()) > max_age.as_micros(); + if self.oldest.front().is_some_and(stale) { + // Dropping a reference picture invalidates the rest of its GOP. Resume + // only at a retained keyframe, never feed a broken chain to the codec. + self.pop(); + while self.frames.front().is_some_and(|frame| !frame.keyframe) || self.oldest.front().is_some_and(stale) { + self.pop(); + } + self.generation += 1; + self.waiting_keyframe = self.frames.is_empty(); + self.floor = self.frames.front().map(|frame| frame.timestamp); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn frame(ms: u64, keyframe: bool) -> Frame { + Frame { + timestamp: moq_net::Timestamp::from_millis(ms).unwrap(), + duration: None, + payload: bytes::Bytes::from_static(b"encoded"), + keyframe, + } + } + + #[test] + fn eviction_keeps_only_decodable_media_inside_the_age_window() { + let mut buffer = Buffer::default(); + let age = Duration::from_millis(100); + buffer.push(frame(0, true), age); + buffer.push(frame(50, false), age); + buffer.push(frame(100, true), age); + buffer.push(frame(150, false), age); + assert_eq!(buffer.frames.front().unwrap().timestamp.as_millis(), 100); + assert_eq!(buffer.bytes, 14); + buffer.pop(); + assert_eq!(buffer.bytes, 7); + buffer.push(frame(260, false), age); + assert!(buffer.frames.is_empty(), "no retained keyframe"); + assert_eq!(buffer.bytes, 0); + buffer.push(frame(280, false), age); + assert!(buffer.frames.is_empty(), "cannot resume on a dependent picture"); + buffer.push(frame(300, true), age); + assert_eq!(buffer.bytes, 7); + } + + #[test] + fn reordered_timestamps_keep_decode_order_and_the_newest_edge() { + let mut buffer = Buffer::default(); + for (ms, keyframe) in [(0, true), (99, false), (33, false), (66, false)] { + buffer.push(frame(ms, keyframe), Duration::from_millis(100)); + } + assert_eq!( + buffer + .frames + .iter() + .map(|f| f.timestamp.as_millis()) + .collect::>(), + [0, 99, 33, 66] + ); + assert_eq!(buffer.edge.unwrap().as_millis(), 99); + buffer.clear(); + assert_eq!(buffer.bytes, 0); + assert!(buffer.frames.is_empty()); + buffer.push(frame(1, true), Duration::ZERO); + assert_eq!(buffer.edge.unwrap().as_millis(), 1); + } + #[test] + fn age_expiry_finds_a_reordered_picture_behind_the_front() { + let mut buffer = Buffer::default(); + let age = Duration::from_millis(100); + for (ms, keyframe) in [(0, true), (99, false), (33, false)] { + buffer.push(frame(ms, keyframe), age); + } + buffer.pop(); + buffer.push(frame(150, false), age); + assert!( + buffer.frames.is_empty(), + "the 33ms picture expired behind the 99ms reference" + ); + assert_eq!(buffer.bytes, 0); + buffer.push(frame(160, true), age); + assert_eq!(buffer.frames.len(), 1); + } +} diff --git a/rs/moq-cli/src/play/fake.rs b/rs/moq-cli/src/play/fake.rs index ba30cc198d..856a62dab8 100644 --- a/rs/moq-cli/src/play/fake.rs +++ b/rs/moq-cli/src/play/fake.rs @@ -39,6 +39,24 @@ struct State { } impl Recorder { + /// Present the newest due frame, just as the window does on a redraw. + pub fn present(&self, media: &super::media::Media) -> Option { + let mut video = media.video.lock().unwrap(); + let presentation = media.presentation.lock().unwrap(); + let now = Instant::now().into_std(); + let mut shown = None; + while video + .front() + .is_some_and(|frame| presentation.due(frame.timestamp).is_none_or(|at| at <= now)) + { + shown = video.pop_front().map(|frame| frame.timestamp); + } + if shown.is_some() { + media.drained.notify_one(); + } + shown + } + /// Everything the speaker played, in write order. pub fn played(&self) -> Vec { self.state.lock().unwrap().played.clone() diff --git a/rs/moq-cli/src/play/media.rs b/rs/moq-cli/src/play/media.rs index 70428163ee..fec8b15c9f 100644 --- a/rs/moq-cli/src/play/media.rs +++ b/rs/moq-cli/src/play/media.rs @@ -17,13 +17,9 @@ use super::output::{Output, Sink, Speaker}; use super::playback::{Kind, Playback, joined}; use super::source::subscribe; use super::timeline::{AudioTimeline, Presentation, timestamp}; +use super::video::Video; use super::window::Event; -/// Decoded frames held for presentation, and the point at which the decoder is -/// made to wait. About a second at 30fps: enough to absorb a burst, few enough -/// that raw frames can't run away with memory. -const MAX_VIDEO_FRAMES: usize = 30; - /// The floor on the speaker's buffer, whatever delay was asked for. The device /// pulls on a fixed clock, so a ring shallower than this drops out on ordinary /// network jitter, and one with no depth at all can never be read from. @@ -147,6 +143,7 @@ impl Media { // any more, and video takes the anchor back. if !playback.playing(Kind::Audio) && tails.is_empty() && speaker.take().is_some() { self.presentation.lock().unwrap().stopped(); + self.drained.notify_one(); } // Start whatever isn't playing from the newest snapshot, which is not @@ -176,26 +173,37 @@ impl Media { continue; } }; - let mut decode = moq_video::decode::Options::new(); - decode.start = moq_video::decode::Start::Latest; - // Nothing older than the playhead is worth presenting, so the delay - // doubles as the staleness budget on the wire. - decode.max_age = self.args.delay.into_std(); - match moq_video::decode::Consumer::new(&rendition, &config, &name, decode).await { - Ok(consumer) => { - tracing::info!(track = name, decoder = consumer.name(), "playing video rendition"); - let presentation = self.presentation.clone(); - let video = self.video.clone(); - let drained = self.drained.clone(); - let output = self.output.clone(); - tasks.spawn(async move { - ( - Kind::Video, - play_video(consumer, presentation, video, drained, output) - .await - .map(|()| None), - ) - }); + let max_age = self.args.delay.into_std(); + let opened = async { + let decoder = moq_video::decode::Sink::open(&config, &Default::default()).await?; + let track = rendition.track(&name)?; + let mut subscriber = track + .subscribe( + moq_net::track::Subscription::default() + .with_priority(hang::catalog::PRIORITY.video) + .with_max_age(max_age), + ) + .await?; + // Start at the local live edge without asking the shared publisher + // subscription to rewind to a cached sequence. + if let Some(latest) = track.latest() { + subscriber.set_groups(latest..); + } + let format = catalog::hang::Container::try_from(&config)?; + Ok::<_, anyhow::Error>((moq_mux::container::Consumer::new(subscriber, format), decoder)) + } + .await; + match opened { + Ok((track, decoder)) => { + tracing::info!(track = name, decoder = decoder.name(), "playing video rendition"); + let video = Video { + presentation: self.presentation.clone(), + frames: self.video.clone(), + changed: self.drained.clone(), + output: self.output.clone(), + max_age, + }; + tasks.spawn(async move { (Kind::Video, video.run(track, decoder).await.map(|()| None)) }); playback.started(Kind::Video); break; } @@ -234,6 +242,7 @@ impl Media { speaker: speaker.clone().expect("opened above"), presentation: self.presentation.clone(), depth, + changed: self.drained.clone(), output: self.output.clone(), }; tasks.spawn(async move { (Kind::Audio, play_audio(consumer, audio).await.map(Some)) }); @@ -259,43 +268,8 @@ impl Media { } } -async fn play_video( - mut consumer: moq_video::decode::Consumer, - presentation: Arc>, - video: Arc>>, - drained: Arc, - output: O, -) -> anyhow::Result<()> { - while let Some(frame) = consumer.read().await? { - // Fold the arrival into the playout clock before queueing it, so the window - // always has a deadline for whatever it finds in the queue. A move has to - // wake it before the wait below, not after: the window is asleep on the old - // anchor's deadline, and it is the only thing that drains the queue this - // task is about to block on. - if presentation - .lock() - .unwrap() - .video(frame.timestamp, Instant::now().into_std()) - { - output.send(Event::Wake); - } - - // Wait for room rather than dropping the oldest. Audio is paced to real - // time, so during a catch-up burst the frames at the front are still ahead - // of the clock, and dropping them would blank the window until the clock - // reached whatever survived. The playout clock is anchored to the wall - // clock, so the queue always drains and this always clears. - while video.lock().unwrap().len() >= MAX_VIDEO_FRAMES { - drained.notified().await; - } - - video.lock().unwrap().push_back(frame); - output.send(Event::Wake); - } - Ok(()) -} - struct AudioPlayback { + changed: Arc, speaker: O::Speaker, presentation: Arc>, depth: Duration, @@ -309,6 +283,7 @@ async fn play_audio( playback: AudioPlayback, ) -> anyhow::Result<::Sink> { let AudioPlayback { + changed, speaker, presentation, depth, @@ -419,6 +394,7 @@ async fn play_audio( .unwrap() .audio(timing.end, sink.buffered(), Instant::now().into_std()); if moved { + changed.notify_one(); output.send(Event::Wake); } } @@ -569,4 +545,85 @@ mod tests { let gap = new_start.saturating_duration_since(old_end); assert!(gap < AUDIO_CHUNK, "the switch went silent for {gap:?}"); } + /// The 61-frame tune-in burst from #3946 must reach the clock before the + /// window drains its first picture, regardless of the raw queue's capacity. + #[tokio::test] + async fn a_wide_delay_observes_the_whole_tune_in_burst() { + tokio::time::pause(); + let delay = Duration::from_secs(2); + let origin = moq_tokio::origin::spawn(); + let broadcast = origin.create_broadcast("room").unwrap(); + let track = broadcast + .create_track("video", hang::container::track_info(hang::catalog::PRIORITY.video)) + .unwrap(); + let mut producer = moq_mux::container::Producer::new(track, Container::Legacy(moq_mux::container::Kind::Data)); + let mut config = moq_video::encode::Config::new(64, 64, moq_video::Rate::new(30, 1).unwrap()); + config.kind = moq_video::encode::Kind::Software; + config.gop = moq_video::encode::Gop::Keyframe { interval: 120 }; + let mut encoder = moq_video::encode::Encoder::new(&config).unwrap(); + for index in 0..=60 { + let surface = moq_video::Surface::rgba(&vec![128; 64 * 64 * 4], moq_video::Size::new(64, 64)).unwrap(); + let frame = moq_video::Frame::new(surface, moq_net::Timestamp::from_millis(index * 33).unwrap()); + for encoded in encoder.encode(&frame).unwrap() { + producer + .write(moq_mux::container::Frame { + timestamp: encoded.timestamp, + duration: None, + payload: encoded.payload, + keyframe: index == 0, + }) + .unwrap(); + } + } + producer.finish().unwrap(); + let catalog = hang::catalog::VideoConfig::new(hang::catalog::H264 { + inline: true, + profile: 0x42, + constraints: 0, + level: 30, + }); + let mut options = moq_video::decode::Options::new(); + options.decoder.kind = moq_video::decode::Kind::Software; + options.max_age = delay; + let decoder = moq_video::decode::Sink::open(&catalog, &options.decoder).await.unwrap(); + let subscriber = broadcast + .consume() + .track("video") + .unwrap() + .subscribe(moq_net::track::Subscription::default().with_max_age(delay)) + .await + .unwrap(); + let track = moq_mux::container::Consumer::new(subscriber, Container::try_from(&catalog).unwrap()); + let recorder = Recorder::default(); + let media = media(&origin, delay, recorder.clone()); + let now = Instant::now(); + let last = moq_net::Timestamp::from_millis(60 * 33).unwrap(); + let task = tokio::spawn( + Video { + presentation: media.presentation.clone(), + frames: media.video.clone(), + changed: media.drained.clone(), + output: recorder.clone(), + max_age: delay, + } + .run(track, decoder), + ); + loop { + tokio::task::yield_now().await; + if media.video.lock().unwrap().len() == 30 + || media.presentation.lock().unwrap().due(last) == Some((now + delay).into_std()) + { + break; + } + } + let due = media.presentation.lock().unwrap().due(last); + task.abort(); + let _ = task.await; + assert_eq!( + due, + Some((now + delay).into_std()), + "the decoder did not observe the live edge" + ); + assert!(recorder.present(&media).is_none(), "nothing is due before its delay"); + } } diff --git a/rs/moq-cli/src/play/mod.rs b/rs/moq-cli/src/play/mod.rs index 74ce9d499c..c455dd1e8d 100644 --- a/rs/moq-cli/src/play/mod.rs +++ b/rs/moq-cli/src/play/mod.rs @@ -15,6 +15,7 @@ #![cfg_attr(not(feature = "play"), allow(dead_code))] mod args; +mod buffer; mod layout; mod playback; mod source; @@ -27,6 +28,8 @@ mod media; #[cfg(feature = "play")] mod output; #[cfg(feature = "play")] +mod video; +#[cfg(feature = "play")] mod window; #[cfg(feature = "play")] diff --git a/rs/moq-cli/src/play/timeline.rs b/rs/moq-cli/src/play/timeline.rs index 60b97e7aa3..5082120f6c 100644 --- a/rs/moq-cli/src/play/timeline.rs +++ b/rs/moq-cli/src/play/timeline.rs @@ -54,6 +54,13 @@ impl Presentation { before != Some(self.pacer.pace(timestamp, now)) } + /// Start a new video timeline without taking ownership from a speaker. + pub(super) fn video_restarted(&mut self) { + if !self.speaker { + self.pacer = moq_mux::Pacer::default().with_delay(self.delay); + } + } + /// Fold the speaker's position into the anchor, reporting whether that moved /// the schedule. /// diff --git a/rs/moq-cli/src/play/video.rs b/rs/moq-cli/src/play/video.rs new file mode 100644 index 0000000000..51a6efb441 --- /dev/null +++ b/rs/moq-cli/src/play/video.rs @@ -0,0 +1,405 @@ +//! Receive encoded video independently of the paced decoder and window. + +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use hang::moq_net; +use moq_mux::container::Frame; +use tokio::time::Instant; + +use super::buffer::Buffer; +use super::output::Output; +use super::timeline::Presentation; +use super::window::Event; + +/// A few surfaces, independent of the requested playout delay. +pub(super) const MAX_FRAMES: usize = 3; +const DECODE_AHEAD: Duration = Duration::from_millis(100); + +type Track = moq_mux::container::Consumer; + +/// The window's shared state and the subscription's encoded retention budget. +pub(super) struct Video { + pub presentation: Arc>, + pub frames: Arc>>, + pub changed: Arc, + pub output: O, + pub max_age: Duration, +} + +/// The production sink and the deterministic buffered decoder used by tests. +pub(super) trait Decoder { + async fn decode(&mut self, frame: Frame) -> anyhow::Result>; + async fn flush(&mut self) -> anyhow::Result>; +} + +impl Decoder for moq_video::decode::Sink { + async fn decode(&mut self, frame: Frame) -> anyhow::Result> { + Ok(self.decode(frame.payload, frame.timestamp, frame.keyframe).await?) + } + + async fn flush(&mut self) -> anyhow::Result> { + Ok(self.flush().await?) + } +} + +impl Video { + pub async fn run(self, mut track: Track, mut decoder: impl Decoder) -> anyhow::Result<()> { + let buffer = Mutex::new(Buffer::default()); + let receive = async { + let mut discontinuity = track.discontinuity(); + loop { + let frame = track.read().await?; + let mut buffer = buffer.lock().unwrap(); + let before = buffer.generation; + if track.discontinuity() != discontinuity { + discontinuity = track.discontinuity(); + buffer.clear(); + self.presentation.lock().unwrap().video_restarted(); + } + let ended = frame.is_none(); + if let Some(frame) = frame { + if self + .presentation + .lock() + .unwrap() + .video(frame.timestamp, Instant::now().into_std()) + { + self.output.send(Event::Wake); + } + buffer.push(frame, self.max_age); + } else { + buffer.ended = true; + } + if buffer.generation != before { + self.frames.lock().unwrap().clear(); + self.output.send(Event::Wake); + } + tracing::trace!( + bytes = buffer.bytes, + frames = buffer.frames.len(), + "buffered encoded video" + ); + self.changed.notify_one(); + if ended { + break; + } + } + Ok::<_, anyhow::Error>(()) + }; + let decode = async { + let mut generation = None; + loop { + let next = { + let buffer = buffer.lock().unwrap(); + buffer.frames.front().map(|frame| frame.timestamp) + }; + let Some(timestamp) = next else { + if buffer.lock().unwrap().ended { + break; + } + self.changed.notified().await; + continue; + }; + let at = { + let frames = self.frames.lock().unwrap(); + let presentation = self.presentation.lock().unwrap(); + let mut at = presentation.due(timestamp).and_then(|at| at.checked_sub(DECODE_AHEAD)); + // A full window may lag, but a future picture still deserves its + // slot. Once due, evict it rather than blocking the live reader. + if frames.len() >= MAX_FRAMES { + at = at.max(frames.front().and_then(|frame| presentation.due(frame.timestamp))); + } + at + }; + if let Some(at) = at.filter(|at| *at > Instant::now().into_std()) { + tokio::select! { + _ = tokio::time::sleep_until(at.into()) => {}, + _ = self.changed.notified() => {}, + } + continue; + } + let (frame, current) = { + let mut buffer = buffer.lock().unwrap(); + ( + buffer.pop().expect("no await since inspecting the front"), + buffer.generation, + ) + }; + // Never race a codec call: cancelling Sink::decode poisons it. The + // joined receiver continues observing arrivals during this await. + let frames = decoder.decode(frame).await?; + generation = Some(current); + if buffer.lock().unwrap().generation == current { + self.decoded(frames, buffer.lock().unwrap().floor); + } + } + let tail = decoder.flush().await?; + if generation == Some(buffer.lock().unwrap().generation) { + self.decoded(tail, buffer.lock().unwrap().floor); + } + Ok::<_, anyhow::Error>(()) + }; + tokio::try_join!(receive, decode)?; + Ok(()) + } + + fn decoded(&self, frames: Vec, floor: Option) { + let mut queue = self.frames.lock().unwrap(); + for frame in frames { + // A codec may return pictures it held across the keyframe that + // resumed a skipped timeline. Those pictures no longer have a slot. + if floor.is_some_and(|floor| frame.timestamp.as_micros() < floor.as_micros()) { + continue; + } + let index = queue.partition_point(|queued| queued.timestamp <= frame.timestamp); + queue.insert(index, frame); + while queue.len() > MAX_FRAMES { + queue.pop_front(); + } + } + self.output.send(Event::Wake); + } +} + +#[cfg(test)] +mod tests { + use super::super::{args::Args, fake::Recorder, media::Media}; + use super::*; + + #[derive(Default)] + struct Buffered { + pending: Option, + decoded: Arc>>, + flushed: Arc>, + } + + impl Decoder for Buffered { + async fn decode(&mut self, frame: Frame) -> anyhow::Result> { + self.decoded.lock().unwrap().push((frame.timestamp, Instant::now())); + let surface = moq_video::Surface::rgba(&[128; 16 * 16 * 4], moq_video::Size::new(16, 16))?; + Ok(self + .pending + .replace(moq_video::Frame::new(surface, frame.timestamp)) + .into_iter() + .collect()) + } + + async fn flush(&mut self) -> anyhow::Result> { + *self.flushed.lock().unwrap() += 1; + Ok(self.pending.take().into_iter().collect()) + } + } + + fn media(delay: Duration) -> Media { + Media { + origin: moq_tokio::origin::spawn().consume(), + broadcast: "room".into(), + args: Args { + delay: delay.into(), + catalog_format: None, + select: Default::default(), + }, + video: Default::default(), + presentation: Arc::new(Mutex::new(Presentation::new(delay))), + drained: Default::default(), + output: Recorder::default(), + } + } + + fn playback(media: &Media, max_age: Duration) -> Video { + Video { + presentation: media.presentation.clone(), + frames: media.video.clone(), + changed: media.drained.clone(), + output: media.output.clone(), + max_age, + } + } + + fn frame(ms: u64, keyframe: bool) -> Frame { + Frame { + timestamp: moq_net::Timestamp::from_millis(ms).unwrap(), + duration: None, + keyframe, + payload: bytes::Bytes::from_static(b"encoded"), + } + } + + async fn track(frames: impl IntoIterator, delay: Duration) -> Track { + let broadcast = moq_net::broadcast::Info::new().produce(); + let track = broadcast + .create_track("video", hang::container::track_info(hang::catalog::PRIORITY.video)) + .unwrap(); + let subscriber = track + .consume() + .subscribe(moq_net::track::Subscription::default().with_max_age(delay)) + .await + .unwrap(); + let format = moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data); + let mut producer = moq_mux::container::Producer::new( + track, + moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Data), + ); + for frame in frames { + producer.write(frame).unwrap(); + } + producer.finish().unwrap(); + moq_mux::container::Consumer::new(subscriber, format) + } + + async fn burst(speaker: bool, paused_window: bool) { + tokio::time::pause(); + let delay = Duration::from_secs(2); + let media = media(delay); + let start = Instant::now(); + let last = moq_net::Timestamp::from_millis(1_980).unwrap(); + if speaker { + media + .presentation + .lock() + .unwrap() + .audio(Duration::ZERO, delay, start.into_std()); + } + let decoder = Buffered::default(); + let decoded = decoder.decoded.clone(); + let flushed = decoder.flushed.clone(); + let track = track((0..=60).map(|index| frame(index * 33, index == 0)), delay).await; + let task = tokio::spawn(playback(&media, delay).run(track, decoder)); + tokio::task::yield_now().await; + let last_due = start + + delay + if speaker { + Duration::from_millis(1_980) + } else { + Duration::ZERO + }; + assert_eq!(media.presentation.lock().unwrap().due(last), Some(last_due.into_std())); + assert!(media.video.lock().unwrap().len() <= MAX_FRAMES); + if speaker { + assert!( + decoded.lock().unwrap().is_empty(), + "the delay was buffered as raw pictures" + ); + } + let mut shown = Vec::new(); + while Instant::now() <= last_due + Duration::from_millis(10) { + if (!paused_window || Instant::now() >= start + Duration::from_secs(1)) + && let Some(timestamp) = media.output.present(&media) + { + shown.push((timestamp, Instant::now())); + } + assert!(media.video.lock().unwrap().len() <= MAX_FRAMES, "unbounded raw queue"); + tokio::time::advance(Duration::from_millis(1)).await; + tokio::task::yield_now().await; + } + task.await.unwrap().unwrap(); + assert_eq!(*flushed.lock().unwrap(), 1, "decoder tail was not flushed exactly once"); + let &(timestamp, at) = shown.last().expect("presented video"); + assert_eq!(timestamp.as_micros(), last.as_micros(), "decoder tail was lost"); + assert!( + at.max(last_due).duration_since(at.min(last_due)) <= Duration::from_millis(1), + "live edge remained late: {at:?}, expected {last_due:?}" + ); + assert!(shown.windows(2).all(|pair| pair[0].0 < pair[1].0)); + assert_eq!(decoded.lock().unwrap().len(), 61); + } + + #[tokio::test] + async fn video_only_burst_reaches_live_and_flushes_its_tail() { + burst(false, false).await; + } + + #[tokio::test] + async fn a_stalled_presenter_does_not_stall_arrivals_or_decode() { + burst(false, true).await; + } + + #[tokio::test] + async fn a_video_burst_follows_the_speakers_clock() { + burst(true, false).await; + } + + #[tokio::test] + async fn reordered_decoder_output_is_presented_in_timestamp_order() { + tokio::time::pause(); + let delay = Duration::from_millis(100); + let media = media(delay); + let track = track( + [frame(0, true), frame(99, false), frame(33, false), frame(66, false)], + delay, + ) + .await; + let task = tokio::spawn(playback(&media, delay).run(track, Buffered::default())); + let mut shown = Vec::new(); + for _ in 0..110 { + tokio::task::yield_now().await; + if let Some(frame) = media.output.present(&media) { + shown.push(frame.as_millis()); + } + tokio::time::advance(Duration::from_millis(1)).await; + } + task.await.unwrap().unwrap(); + assert_eq!( + shown, + [33, 66, 99], + "the bounded queue evicts the oldest, then presents reordered output" + ); + } + #[tokio::test] + async fn a_discontinuity_discards_old_pictures_and_restarts_the_delay() { + tokio::time::pause(); + let delay = Duration::from_secs(2); + let media = media(delay); + let broadcast = moq_net::broadcast::Info::new().produce(); + let track = broadcast + .create_track("video", hang::container::track_info(hang::catalog::PRIORITY.video)) + .unwrap(); + let subscriber = track + .consume() + .subscribe(moq_net::track::Subscription::default().with_max_age(delay)) + .await + .unwrap(); + let mut producer = moq_mux::container::Producer::new( + track, + moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Video), + ); + let track = moq_mux::container::Consumer::new( + subscriber, + moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Video), + ); + producer.write(frame(0, true)).unwrap(); + let decoder = Buffered::default(); + let decoded = decoder.decoded.clone(); + let task = tokio::spawn(playback(&media, delay).run(track, decoder)); + tokio::task::yield_now().await; + tokio::time::advance(delay).await; + tokio::task::yield_now().await; + assert_eq!(decoded.lock().unwrap().len(), 1, "old picture held inside decoder"); + producer.discontinuity().unwrap(); + producer.write(frame(500, true)).unwrap(); + producer.finish().unwrap(); + let restarted = Instant::now(); + tokio::task::yield_now().await; + assert_eq!( + media + .presentation + .lock() + .unwrap() + .due(moq_net::Timestamp::from_millis(500).unwrap()), + Some((restarted + delay).into_std()) + ); + assert!(media.output.present(&media).is_none()); + tokio::time::advance(delay + Duration::from_millis(1)).await; + task.await.unwrap().unwrap(); + let queued = media + .video + .lock() + .unwrap() + .iter() + .map(|frame| frame.timestamp.as_millis()) + .collect::>(); + assert_eq!(queued, [500], "old decoder output crossed the discontinuity"); + assert_eq!(media.output.present(&media).unwrap().as_millis(), 500); + } +} From ce123c825b5333c03250cdcfa3af82377bf331e4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 21:24:43 -0700 Subject: [PATCH 27/45] feat(moq-mux): catalog delay measures cross-rendition encoder lateness (#4170) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- doc/bin/gstreamer.md | 3 +- doc/bin/obs.md | 3 +- doc/concept/audio-jitter.md | 33 ++++ doc/concept/hang.md | 1 + doc/lib/rs/moq-mux.md | 8 +- drafts/draft-lcurley-moq-hang.md | 15 ++ js/hang/src/catalog/audio.ts | 11 ++ js/hang/src/catalog/root.test.ts | 24 +++ js/hang/src/catalog/text.ts | 11 ++ js/hang/src/catalog/video.ts | 11 ++ js/msf/src/catalog.test.ts | 24 +++ js/msf/src/catalog.ts | 4 + js/publish/src/catalog.test.ts | 114 +++++++------- js/publish/src/catalog.ts | 27 ++-- js/watch/src/audio/config.test.ts | 5 + js/watch/src/audio/config.ts | 7 +- js/watch/src/msf.test.ts | 60 ++++++++ js/watch/src/msf.ts | 9 ++ js/watch/src/sync.test.ts | 20 +++ js/watch/src/video/decoder.test.ts | 149 ++++++++++++++++++ js/watch/src/video/decoder.ts | 32 +++- js/watch/src/video/playhead.test.ts | 8 +- js/watch/src/video/playhead.ts | 18 ++- quest/m0/audio-jitter-target/README.md | 1 - quest/m1/README.md | 2 +- quest/m1/data-jitter.md | 7 +- quest/m1/jitter-flush-clock.md | 113 -------------- quest/m1/publish-delay.md | 27 ++++ quest/m2/watch-data-sync.md | 1 - rs/hang/src/catalog/audio/mod.rs | 12 ++ rs/hang/src/catalog/root.rs | 9 +- rs/hang/src/catalog/text/mod.rs | 12 ++ rs/hang/src/catalog/video/mod.rs | 12 ++ rs/moq-hls/src/export/rendition.rs | 2 + rs/moq-msf/src/lib.rs | 29 ++++ rs/moq-mux/src/catalog/estimate.rs | 205 +++++++++++++++++++------ rs/moq-mux/src/catalog/msf/consumer.rs | 26 ++++ rs/moq-mux/src/catalog/producer.rs | 16 ++ rs/moq-mux/src/catalog/tracks.rs | 94 ++++++++++-- rs/moq-mux/src/container/producer.rs | 10 +- rs/moq-mux/src/error.rs | 4 + rs/moq-video/src/encode/producer.rs | 6 +- 42 files changed, 907 insertions(+), 278 deletions(-) create mode 100644 js/watch/src/video/decoder.test.ts delete mode 100644 quest/m1/jitter-flush-clock.md create mode 100644 quest/m1/publish-delay.md diff --git a/doc/bin/gstreamer.md b/doc/bin/gstreamer.md index 99bc063da5..c3695b214d 100644 --- a/doc/bin/gstreamer.md +++ b/doc/bin/gstreamer.md @@ -77,7 +77,8 @@ is at least 1, and a rate is the delta over any window you sample. Unlike Set `encoder=true` on audio and video pads a local encoder feeds (`x264enc`, `opusenc`, ...). The pad then measures how late each frame reaches the sink behind its running time and raises the catalog `jitter` by the spread, -so players buffer for an encoder that delivers irregularly. Leave it off, the +and `delay` by how far it trails the earliest such pad, so players buffer for an +encoder that delivers irregularly or behind the others. Leave it off, the default, for file, demuxed, and network media: their arrival reflects the disk or the network, not the original encoder, and a GStreamer segment cannot tell the two apart. Text and opaque pads refuse it. diff --git a/doc/bin/obs.md b/doc/bin/obs.md index e22801d357..a9733f27d1 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -35,7 +35,8 @@ OBS Studio install. OBS reports each locally encoded packet's handoff to libmoq against the shared broadcast media clock. Each track's catalog `jitter` is the largest measured -delay above that track's own recent minimum, rounded up to milliseconds. +delay above that track's own recent minimum, and its `delay` is how far that +minimum trails the earliest track, both rounded up to milliseconds. ## Source quality and moq-transcode diff --git a/doc/concept/audio-jitter.md b/doc/concept/audio-jitter.md index eb4c490763..f300399fba 100644 --- a/doc/concept/audio-jitter.md +++ b/doc/concept/audio-jitter.md @@ -271,6 +271,39 @@ is the one thing a measurement cannot be. Note that `js/watch` today adds the two rather than taking the maximum, which over-buffers a bursty publisher by its own flush span. +## Across renditions + +Every track plays from one clock, anchored on the earliest lateness any track +has shown. The estimator measures each track against its own fastest frame, so a +constant offset between tracks cancels out of `measured`: a video encoder that +flushes 200 ms behind the audio encoder produces the same video target as one +that does not, and its frames would all arrive 200 ms late for the shared clock. + +The catalog `delay` field carries that offset: how far a rendition's minimum +flush lateness trails the broadcast's earliest rendition, measured by the +publisher and never lowered. It is an addend, not a floor, because nothing on +this page contains it. The shared playout is the largest requirement among the +renditions actually subscribed: + +``` +playout = max over subscribed renditions of (delay + target) +``` + +A receiver **must not** subtract one rendition's `delay` from another's. Each is +a lifetime maximum taken against a sliding baseline, so two values need not +share an origin: if the earliest rendition later drifts behind another, their +difference understates the real spread. The maximum never under-buffers, +because the subscribed renditions' spread is measured from an earliest baseline +no earlier than the broadcast's, so it never exceeds the largest `delay` among +them. The cost is over-buffering by the smallest subscribed `delay` when the +broadcast's earliest rendition is not subscribed. + +The playout is recomputed when a rendition is subscribed, unsubscribed, or its +catalog entry rises, so dropping a slow rendition lowers latency. The shared +reference itself only ever moves earlier, so once the earliest subscribed track +leaves, the clock stays anchored to it until playback re-anchors; that errs on +the safe side. + ## Rise, fall, startup, and the ceiling There is **no separate rise or fall limiter**. The histogram is the only diff --git a/doc/concept/hang.md b/doc/concept/hang.md index c95fa3f2e4..20f3bfc0ed 100644 --- a/doc/concept/hang.md +++ b/doc/concept/hang.md @@ -55,6 +55,7 @@ A few things the catalog can express beyond decoder config: - **Labels.** Any rendition may carry a human-readable `label` for a track picker. The map key stays the track name used to subscribe, so labels need not be unique and renaming one doesn't rename the track. - **Renditions in another broadcast.** A rendition may point at a relative broadcast path, so a transcoder can publish a ladder that adds low rungs and references the source's original rendition without re-publishing its bytes. The path resolves against where the consumer found the catalog, so a reference that escapes above the root names nothing and the catalog is rejected. - **Jitter.** A rendition can say how far its frames fell behind the media clock before the publisher flushed them, in whole milliseconds rounded up. Encoders report the spread of lateness above each rendition's own recent minimum, so a constant encoder delay is not jitter; container imports estimate batch spans without counting ingest delay. It describes the publisher, never the network, only grows over the life of a stream, and a player sizes its buffer to at least this much. A `0` is read as absent. +- **Delay.** A rendition can also say how far its frames reach the transport behind the broadcast's earliest rendition, measured the same way from each rendition's minimum lateness, so a video encoder running 200 ms behind audio advertises `delay: 200` on video. It follows the same rules as jitter. A player holds the largest `delay + jitter` among the renditions it subscribes to, and never subtracts one rendition's `delay` from another's. - **Stalled renditions.** A publisher can flag a rendition as temporarily bad so players prefer another one without the track disappearing. First-party video publishers set this flag after more than three frame intervals of source silence or encoding lag while subscribed, and clear it after three on-time completed frames or when idle. Browser and native capture poll while waiting; FLV and MPEG-TS importers observe video silence as container data arrives. The shared detector is `hang::catalog::stalled::Detector` in Rust and `Catalog.Stalled.Detector` in JavaScript. It is a playback diagnostic, not an authorization or routing signal. - **Archive.** A broadcast may advertise an `archive` entry naming its timeline track (a small index of each complete aligned segment) and, if recorded, the replay MoQ path, object-store URL, and format version. The timeline is what lets the [HLS gateway](/bin/hls) build playlists without subscribing to media. - **Clock.** The optional root `clock` maps PTS zero to wall time so every media track and the archive index share one fixed epoch after timescale conversion. It is independent of `archive`, so a live-only publisher can expose wall-clock timing without creating a segment index. diff --git a/doc/lib/rs/moq-mux.md b/doc/lib/rs/moq-mux.md index 5fe596a4fd..7be69723be 100644 --- a/doc/lib/rs/moq-mux.md +++ b/doc/lib/rs/moq-mux.md @@ -41,9 +41,11 @@ retires the entry. Calling `modify` before the first `set` returns `Error::NotPublished`. Container writes measure bitrate; importers can also measure batch span or reorder delay for jitter. Locally encoded frames call `container::Producer::flush(timestamp, Instant::now())`; jitter is the spread -above that track's own recent minimum lateness, published as soon as it rises. -Generic imports remain clock-free. Invalid or decreasing jitter is rejected -before the edit is retained, including while the initial catalog is reserved. +above that track's own recent minimum lateness, and delay is how far that +minimum trails the earliest track on the same catalog. Both are published as +soon as they rise. Generic imports remain clock-free. Invalid or decreasing +jitter or delay is rejected before the edit is retained, including while the +initial catalog is reserved. Codec importers propagate catalog and media errors through their configuration and frame-writing methods. diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index 30d711e3c9..04fffe9264 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -481,6 +481,7 @@ type CommonExtensions = { "label": string | undefined, "container": Container, "jitter": number | undefined, + "delay": number | undefined, } ~~~ @@ -532,6 +533,19 @@ For example: - A fragment or packet batch contributes the media span between its earliest timestamp and flush point. - Reordered frames contribute the delay they were held before flushing, without treating a decode-order presentation timestamp gap as delay by itself. +### delay {#field-delay} +The maximum amount, in milliseconds, by which a rendition's minimum flush lateness ({{field-jitter}}) has trailed the smallest minimum among the broadcast's renditions that measure it. +If absent, a consumer SHOULD assume the rendition does not trail the others. + +A publisher measures each rendition's minimum over the same recent window it uses for `jitter`, from one clock shared by every rendition, and advertises the largest difference observed. +A container importer does not measure `delay`. +The rounding, `0`, and never-lower rules of `jitter` apply unchanged. + +A consumer SHOULD hold at least the largest `delay` plus `jitter` among the renditions it plays together. +A consumer MUST NOT subtract one rendition's `delay` from another's: each is a maximum over the life of the stream, so two values need not share an origin. + +For example, a video encoder that flushes 200 milliseconds after the audio encoder for the same media time advertises a video `delay` of 200 and no audio `delay`. + # Container {#container} Audio, video, and text tracks use a container to encapsulate the media payload. A rendition declares its container via the `container` field of its catalog entry ({{common}}): @@ -1085,6 +1099,7 @@ A publisher MAY estimate an unknown final duration from the frame cadence, but M - An audio endpoint bounds only the terminal packets that follow it in its own group. - Replaced the archive timeline `wall` field with a root `clock` section (`wall` plus `timescale`): one fixed broadcast mapping every track and the archive index convert into, independent of any archive. Zero timescales and walls past the JSON-safe integer range are refused. - Added optional `bitrate` and `jitter` fields to `json` and `binary` track entries. +- Added the optional `delay` rendition field: how far a rendition's minimum flush lateness trails the broadcast's earliest rendition, never lowered once advertised and never subtracted across renditions. - Recommended namespaced keys for application root sections. # Acknowledgments diff --git a/js/hang/src/catalog/audio.ts b/js/hang/src/catalog/audio.ts index ec7039eedb..f897c9125a 100644 --- a/js/hang/src/catalog/audio.ts +++ b/js/hang/src/catalog/audio.ts @@ -59,6 +59,17 @@ export const AudioConfigSchema = z.object({ z.transform((value) => (value === 0 ? undefined : value)), ), ), + + // How far this rendition's frames reach the transport behind the broadcast's earliest + // rendition, in whole milliseconds rounded up. A player holds `delay + jitter` for it and never + // subtracts one rendition's `delay` from another's. Absent on the earliest rendition. It only + // ever grows over the life of a stream. + delay: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** Schema for the catalog audio section: a map of track name to rendition config. */ diff --git a/js/hang/src/catalog/root.test.ts b/js/hang/src/catalog/root.test.ts index a62dbe1181..c37154805c 100644 --- a/js/hang/src/catalog/root.test.ts +++ b/js/hang/src/catalog/root.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import * as z from "@zod/mini"; import { ARCHIVE_VERSION } from "./archive.ts"; +import { u53 } from "./integers.ts"; import type { RelativeBroadcast } from "./path.ts"; import { RootSchema } from "./root.ts"; @@ -95,6 +96,29 @@ test("legacy zero jitter is absent for audio and video", () => { expect(JSON.stringify(parsed)).not.toContain('"jitter"'); }); +test("delay parses beside jitter and zero is absent", () => { + const parsed = RootSchema.parse({ + audio: { + renditions: { + audio: { + codec: "opus", + container: { kind: "legacy" }, + sampleRate: 48000, + numberOfChannels: 2, + delay: 0, + }, + }, + }, + video: { + renditions: { video: { codec: "avc1.64001f", container: { kind: "legacy" }, jitter: 34, delay: 200 } }, + }, + text: { renditions: { captions: { format: "vtt", container: { kind: "legacy" }, delay: 120 } } }, + }); + expect(parsed.audio?.renditions.audio?.delay).toBeUndefined(); + expect(parsed.video?.renditions.video?.delay).toBe(u53(200)); + expect(parsed.text?.renditions.captions?.delay).toBe(u53(120)); +}); + test("clock round-trips at the root", () => { const parsed = RootSchema.parse({ clock: { wall: 1_751_846_400_000_000, timescale: 1_000_000 }, diff --git a/js/hang/src/catalog/text.ts b/js/hang/src/catalog/text.ts index 3bd4e3dc21..5be0bee11b 100644 --- a/js/hang/src/catalog/text.ts +++ b/js/hang/src/catalog/text.ts @@ -58,6 +58,17 @@ export const TextConfigSchema = z.object({ z.transform((value) => (value === 0 ? undefined : value)), ), ), + + // How far this rendition's frames reach the transport behind the broadcast's earliest + // rendition, in whole milliseconds rounded up. A player holds `delay + jitter` for it and never + // subtracts one rendition's `delay` from another's. Absent on the earliest rendition. It only + // ever grows over the life of a stream. + delay: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** Schema for the catalog text section: a map of track name to rendition config. */ diff --git a/js/hang/src/catalog/video.ts b/js/hang/src/catalog/video.ts index 2d173e1758..53c9946a7f 100644 --- a/js/hang/src/catalog/video.ts +++ b/js/hang/src/catalog/video.ts @@ -73,6 +73,17 @@ export const VideoConfigSchema = z.object({ z.transform((value) => (value === 0 ? undefined : value)), ), ), + + // How far this rendition's frames reach the transport behind the broadcast's earliest + // rendition, in whole milliseconds rounded up. A player holds `delay + jitter` for it and never + // subtracts one rendition's `delay` from another's. Absent on the earliest rendition. It only + // ever grows over the life of a stream. + delay: z.optional( + z.pipe( + u53Schema, + z.transform((value) => (value === 0 ? undefined : value)), + ), + ), }); /** diff --git a/js/msf/src/catalog.test.ts b/js/msf/src/catalog.test.ts index 749b18819d..a6e16a222b 100644 --- a/js/msf/src/catalog.test.ts +++ b/js/msf/src/catalog.test.ts @@ -185,6 +185,30 @@ test("preserves SAP fields through decode and encode", () => { expect(wireTracks[0].jitter).toBe(15); }); +test("preserves delay through decode and encode", () => { + const catalog = decode( + encodeJson({ + version: "draft-01", + tracks: [ + { + name: "video0", + packaging: "loc", + isLive: true, + role: "video", + codec: "avc1.640028", + delay: 200, + }, + ], + }), + ); + + expect(catalog.tracks[0].delay).toBe(200); + + const wire = decodeJson(encode(catalog)); + const wireTracks = wire.tracks as { delay?: number }[]; + expect(wireTracks[0].delay).toBe(200); +}); + test.each([ ["omitted", undefined], ["false", false], diff --git a/js/msf/src/catalog.ts b/js/msf/src/catalog.ts index a11cbca452..9596adbee0 100644 --- a/js/msf/src/catalog.ts +++ b/js/msf/src/catalog.ts @@ -48,6 +48,10 @@ const trackShape = { // The player's buffer must be at least this large to avoid underruns. // Mirrors the `jitter` field in the hang catalog. jitter: z.optional(z.number()), + + // Non-standard: how far this rendition trails the broadcast's earliest, in milliseconds. + // Mirrors hang `delay`. A player holds `delay + jitter` and does not subtract across renditions. + delay: z.optional(z.number()), }; /** Zod schema describing a single track entry in an MSF catalog. */ diff --git a/js/publish/src/catalog.test.ts b/js/publish/src/catalog.test.ts index cd76a88781..215da4f89c 100644 --- a/js/publish/src/catalog.test.ts +++ b/js/publish/src/catalog.test.ts @@ -117,10 +117,37 @@ test("a reconnecting subscriber is seeded with the full current catalog", async effect.close(); }); -test("catalog producer refuses zero jitter before retaining an edit", () => { - const catalog = new CatalogProducer(); +for (const field of ["jitter", "delay"] as const) { + test(`catalog producer refuses zero ${field} before retaining an edit`, () => { + const catalog = new CatalogProducer(); + for (const section of ["audio", "video", "text"] as const) { + expect(() => + catalog.mutate((value) => { + Object.assign(value, { + [section]: { + renditions: { + media: { + codec: "opus", + container: { kind: "legacy" }, + sampleRate: 48000, + numberOfChannels: 2, + [field]: 0, + }, + }, + }, + }); + }), + ).toThrow(`omit ${field}`); + } + catalog.mutate((value) => { + expect(value.audio).toBeUndefined(); + expect(value.video).toBeUndefined(); + }); + }); + for (const section of ["audio", "video", "text"] as const) { - expect(() => + test(`catalog refuses ${section} ${field} decreases without retaining them`, () => { + const catalog = new CatalogProducer(); catalog.mutate((value) => { Object.assign(value, { [section]: { @@ -130,70 +157,45 @@ test("catalog producer refuses zero jitter before retaining an edit", () => { container: { kind: "legacy" }, sampleRate: 48000, numberOfChannels: 2, - jitter: 0, + [field]: 100, }, }, }, }); - }), - ).toThrow("omit jitter"); - } - catalog.mutate((value) => { - expect(value.audio).toBeUndefined(); - expect(value.video).toBeUndefined(); - }); -}); - -for (const section of ["audio", "video", "text"] as const) { - test(`catalog refuses ${section} jitter decreases without retaining them`, () => { - const catalog = new CatalogProducer(); - catalog.mutate((value) => { - Object.assign(value, { - [section]: { - renditions: { - media: { - codec: "opus", - container: { kind: "legacy" }, - sampleRate: 48000, - numberOfChannels: 2, - jitter: 100, - }, - }, - }, }); - }); - // The section is optional on the loose root type, so re-read it through a guard. - const retained = (value: Catalog.Root) => { - const sectionValue = value[section]; - if (!sectionValue) throw new Error(`expected a retained ${section} section`); - return sectionValue; - }; - for (const jitter of [Catalog.u53(50), undefined]) { - expect(() => + // The section is optional on the loose root type, so re-read it through a guard. + const retained = (value: Catalog.Root) => { + const sectionValue = value[section]; + if (!sectionValue) throw new Error(`expected a retained ${section} section`); + return sectionValue; + }; + for (const estimate of [Catalog.u53(50), undefined]) { + expect(() => + catalog.mutate((value) => { + retained(value).renditions.media[field] = estimate; + }), + ).toThrow(`${field} cannot decrease`); catalog.mutate((value) => { - retained(value).renditions.media.jitter = jitter; - }), - ).toThrow("jitter cannot decrease"); + expect(retained(value).renditions.media[field]).toBe(Catalog.u53(100)); + }); + } catalog.mutate((value) => { - expect(retained(value).renditions.media.jitter).toBe(Catalog.u53(100)); + delete retained(value).renditions.media; }); - } - catalog.mutate((value) => { - delete retained(value).renditions.media; - }); - catalog.mutate((value) => { - Object.assign(retained(value).renditions, { - media: { - codec: "opus", - container: { kind: "legacy" }, - sampleRate: 48000, - numberOfChannels: 2, - jitter: 50, - }, + catalog.mutate((value) => { + Object.assign(retained(value).renditions, { + media: { + codec: "opus", + container: { kind: "legacy" }, + sampleRate: 48000, + numberOfChannels: 2, + [field]: 50, + }, + }); }); }); - }); + } } for (const section of ["json", "binary"] as const) { diff --git a/js/publish/src/catalog.ts b/js/publish/src/catalog.ts index 3a40e3d1ae..225a4e4715 100644 --- a/js/publish/src/catalog.ts +++ b/js/publish/src/catalog.ts @@ -24,13 +24,15 @@ export class CatalogProducer { mutate(fn: (catalog: Catalog.Root) => void): void { const value = structuredClone(this.#value); fn(value); - for (const [section, next] of Object.entries(jitters(value))) { - const previous = jitters(this.#value)[section]; - for (const [name, jitter] of Object.entries(next)) { - if (jitter === 0) throw new Error("omit jitter for a track flushed immediately"); - const before = previous?.[name]; - if (before !== undefined && (jitter === undefined || jitter < before)) { - throw new Error("jitter cannot decrease for an existing track"); + for (const field of ["jitter", "delay"] as const) { + const previous = advertised(this.#value, field); + for (const [section, next] of Object.entries(advertised(value, field))) { + for (const [name, estimate] of Object.entries(next)) { + if (estimate === 0) throw new Error(`omit ${field} rather than advertising 0`); + const before = previous[section]?.[name]; + if (before !== undefined && (estimate === undefined || estimate < before)) { + throw new Error(`${field} cannot decrease for an existing track`); + } } } } @@ -60,10 +62,13 @@ export class CatalogProducer { } } -/** Every track's advertised jitter, by section and then track name. */ -function jitters(catalog: Catalog.Root): Record> { - const pick = (tracks: Record | undefined) => - Object.fromEntries(Object.entries(tracks ?? {}).map(([name, config]) => [name, config.jitter])); +/** Every track's advertised `field`, by section and then track name. */ +function advertised( + catalog: Catalog.Root, + field: "jitter" | "delay", +): Record> { + const pick = (tracks: Record | undefined) => + Object.fromEntries(Object.entries(tracks ?? {}).map(([name, config]) => [name, config[field]])); return { audio: pick(catalog.audio?.renditions), video: pick(catalog.video?.renditions), diff --git a/js/watch/src/audio/config.test.ts b/js/watch/src/audio/config.test.ts index 36ee450983..ca5dee3a23 100644 --- a/js/watch/src/audio/config.test.ts +++ b/js/watch/src/audio/config.test.ts @@ -68,6 +68,11 @@ test("an advertised jitter of zero falls back to the codec frame duration", () = expect(playbackJitter(config({ jitter: 60 }))).toBe(Time.Milli(63)); }); +test("a rendition's delay adds to its jitter", () => { + expect(playbackJitter(config({ delay: 200 }))).toBe(Time.Milli(223)); + expect(playbackJitter(config({ delay: 200, jitter: 60 }))).toBe(Time.Milli(263)); +}); + test("AAC and MP3 jitter follows their codec frame sizes", () => { expect(playbackJitter(config({ codec: "mp4a.40.2", sampleRate: 48000 }))).toBe(Time.Milli(25)); expect(playbackJitter(config({ codec: "mp4a.40.2", sampleRate: 24000 }))).toBe(Time.Milli(49)); diff --git a/js/watch/src/audio/config.ts b/js/watch/src/audio/config.ts index e5ae6d5fdc..2665ce83d8 100644 --- a/js/watch/src/audio/config.ts +++ b/js/watch/src/audio/config.ts @@ -40,7 +40,10 @@ export function playbackIdentity(config: Catalog.AudioConfig): PlaybackIdentity }; } -/** The jitter to add to the sync buffer for a rendition, in milliseconds. */ +/** + * The sync buffer a rendition needs, in milliseconds: its catalog `delay` behind the broadcast's + * earliest rendition plus its own jitter. + */ export function playbackJitter(config: Catalog.AudioConfig): Time.Milli { // A publisher advertising 0 is claiming frames are never delayed, which no encoder can do, so // fall back to the codec's frame duration the same way an absent field does. @@ -48,7 +51,7 @@ export function playbackJitter(config: Catalog.AudioConfig): Time.Milli { // Add the worklet render quantum so the ring buffer has margin between frame arrivals. const overhead = Math.ceil((WORKLET_QUANTUM / config.sampleRate) * 1000); - return Time.Milli(codecJitter + overhead); + return Time.Milli((config.delay ?? 0) + codecJitter + overhead); } // Estimate the minimum jitter (frame duration) based on the audio codec. diff --git a/js/watch/src/msf.test.ts b/js/watch/src/msf.test.ts index dd2e930259..2a58e4d815 100644 --- a/js/watch/src/msf.test.ts +++ b/js/watch/src/msf.test.ts @@ -1,7 +1,67 @@ import { expect, test } from "bun:test"; +import { u53 } from "@moq/hang/catalog"; import type * as Msf from "@moq/msf"; import { toHang } from "./msf"; +test("copies delay onto the hang rendition", () => { + const catalog: Msf.Catalog = { + tracks: [ + { + name: "video", + packaging: "loc", + role: "video", + codec: "vp09.00.10.08", + delay: 200, + jitter: 40, + }, + { + name: "audio", + packaging: "loc", + role: "audio", + codec: "opus", + delay: 80, + }, + ], + }; + + expect(toHang(catalog).video?.renditions.video?.delay).toBe(u53(200)); + expect(toHang(catalog).video?.renditions.video?.jitter).toBe(u53(40)); + expect(toHang(catalog).audio?.renditions.audio?.delay).toBe(u53(80)); +}); + +test("rounds a fractional MSF delay up, and drops zero", () => { + const catalog: Msf.Catalog = { + tracks: [ + { + name: "video", + packaging: "loc", + role: "video", + codec: "vp09.00.10.08", + delay: 200.2, + }, + { + name: "audio", + packaging: "loc", + role: "audio", + codec: "opus", + delay: 0.2, + }, + { + name: "early", + packaging: "loc", + role: "audio", + codec: "opus", + delay: 0, + }, + ], + }; + + const hang = toHang(catalog); + expect(hang.video?.renditions.video?.delay).toBe(u53(201)); + expect(hang.audio?.renditions.audio?.delay).toBe(u53(1)); + expect(hang.audio?.renditions.early?.delay).toBeUndefined(); +}); + test("preserves stalled video renditions", () => { const catalog: Msf.Catalog = { tracks: [ diff --git a/js/watch/src/msf.ts b/js/watch/src/msf.ts index cc7271ef66..a78a396ec5 100644 --- a/js/watch/src/msf.ts +++ b/js/watch/src/msf.ts @@ -46,6 +46,13 @@ function toContainer(track: Msf.Track): ContainerInfo | undefined { } } +// Hang stores delay as whole milliseconds rounded up, and zero means the rendition is not behind. +// MSF writes a fractional millisecond, which u53 rejects. +function delayMillis(value: number): ReturnType | undefined { + if (value <= 0) return undefined; + return u53(Math.ceil(value)); +} + function toVideoConfig(track: Msf.Track): Catalog.VideoConfig | undefined { if (!track.codec) return undefined; @@ -62,6 +69,7 @@ function toVideoConfig(track: Msf.Track): Catalog.VideoConfig | undefined { bitrate: track.bitrate != null ? u53(track.bitrate) : undefined, stalled: track.stalled, jitter: track.jitter != null ? u53(track.jitter) : undefined, + delay: track.delay != null ? delayMillis(track.delay) : undefined, }; } @@ -85,6 +93,7 @@ function toAudioConfig(track: Msf.Track): Catalog.AudioConfig | undefined { numberOfChannels: u53(channels), bitrate: track.bitrate != null ? u53(track.bitrate) : undefined, jitter: track.jitter != null ? u53(track.jitter) : undefined, + delay: track.delay != null ? delayMillis(track.delay) : undefined, }; } diff --git a/js/watch/src/sync.test.ts b/js/watch/src/sync.test.ts index d8d18764b8..f0c068ebdd 100644 --- a/js/watch/src/sync.test.ts +++ b/js/watch/src/sync.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "bun:test"; +import type * as Catalog from "@moq/hang/catalog"; import { Time } from "@moq/net"; import { Signal } from "@moq/signals"; +import { playbackJitter } from "./audio/config"; import { type Delay, Sync } from "./sync"; +import { renditionJitter } from "./video/playhead"; // Effects in @moq/signals flush on a microtask, so let pending updates drain before asserting. const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -80,6 +83,23 @@ describe("delay and buffer", () => { sync.close(); }); + it("holds the largest delay plus jitter among subscribed renditions", async () => { + const audio = { codec: "opus", container: { kind: "legacy" }, sampleRate: 48000, numberOfChannels: 2 }; + const video = { codec: "avc1.640028", container: { kind: "legacy" }, jitter: 34, delay: 200 }; + const sync = new Sync({ delay: 100 as Time.Milli }); + sync.register(new Signal(playbackJitter(audio as Catalog.AudioConfig))); + const unsubscribe = sync.register(new Signal(renditionJitter(video as Catalog.VideoConfig))); + await flush(); + // 100 ms of network jitter over the video's 200 ms delay and 34 ms spread. + expect(sync.out.delay.peek()).toBe(334 as Time.Milli); + + // Dropping the slow rendition lowers latency to the audio's own 20 ms frame and 3 ms quantum. + unsubscribe(); + await flush(); + expect(sync.out.delay.peek()).toBe(123 as Time.Milli); + sync.close(); + }); + it("unregisters duplicate jitter inputs independently", async () => { const media = new Signal(20 as Time.Milli); const sync = new Sync({ delay: 100 as Time.Milli }); diff --git a/js/watch/src/video/decoder.test.ts b/js/watch/src/video/decoder.test.ts new file mode 100644 index 0000000000..fac3a7bbde --- /dev/null +++ b/js/watch/src/video/decoder.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "bun:test"; +import * as Catalog from "@moq/hang/catalog"; +import type * as Moq from "@moq/net"; +import { Time } from "@moq/net"; +import { Signal } from "@moq/signals"; +import type { Broadcast } from "../broadcast"; +import { Sync } from "../sync"; +import { Decoder } from "./decoder"; +import { Source } from "./source"; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +async function settle(): Promise { + for (let i = 0; i < 8; i++) await flush(); +} + +function config(fields: Record): Catalog.VideoConfig { + return Catalog.VideoConfigSchema.parse({ + codec: "avc1.640028", + container: { kind: "legacy" }, + ...fields, + }); +} + +// The subscription is already closed, so the track never reaches VideoDecoder. +function closedConsumer(): Moq.Broadcast.Consumer { + return { closed: new Signal("ended") } as unknown as Moq.Broadcast.Consumer; +} + +function watchBroadcast(catalog: Signal): Broadcast { + return { + out: { catalog }, + relativeBroadcast: () => closedConsumer(), + } as unknown as Broadcast; +} + +async function play(catalog: Signal): Promise<{ + broadcast: Signal; + source: Source; + sync: Sync; + decoder: Decoder; +}> { + const broadcast = new Signal(watchBroadcast(catalog)); + const source = new Source({ + broadcast, + supported: async () => true, + }); + const sync = new Sync({ delay: Time.Milli(0) }); + await settle(); + const decoder = new Decoder({ source, sync }); + await settle(); + return { broadcast, source, sync, decoder }; +} + +describe("Decoder jitter across a source switch", () => { + it("keeps the outgoing floor when the next source reuses the track name", async () => { + const outgoing = new Signal({ + video: { renditions: { video: config({ delay: 200, jitter: 60 }) } }, + }); + const { broadcast, source, sync, decoder } = await play(outgoing); + try { + expect(decoder.out.jitter.peek()).toBe(Time.Milli(260)); + + // A rise on the catalog this rendition subscribed to still resizes the floor. + outgoing.set({ + video: { renditions: { video: config({ delay: 300, jitter: 60 }) } }, + }); + await settle(); + expect(decoder.out.jitter.peek()).toBe(Time.Milli(360)); + + broadcast.set( + watchBroadcast( + new Signal({ + video: { renditions: { video: config({ jitter: 20 }) } }, + }), + ), + ); + await settle(); + // The old frames are still on screen. The new catalog's same name is not their floor. + expect(decoder.out.jitter.peek()).toBe(Time.Milli(360)); + } finally { + decoder.close(); + source.close(); + sync.close(); + } + }); + + it("follows a newer pending catalog when the track name stays the same", async () => { + const outgoing = new Signal({ + video: { renditions: { video: config({ jitter: 20 }) } }, + }); + const { broadcast, source, sync, decoder } = await play(outgoing); + try { + expect(decoder.out.jitter.peek()).toBe(Time.Milli(20)); + + broadcast.set( + watchBroadcast( + new Signal({ + video: { renditions: { video: config({ delay: 200 }) } }, + }), + ), + ); + await settle(); + expect(decoder.out.jitter.peek()).toBe(Time.Milli(200)); + + const newest = new Signal({ + video: { renditions: { video: config({ delay: 500 }) } }, + }); + broadcast.set(watchBroadcast(newest)); + await settle(); + // The first switch is still pending, so the name did not change. The new catalog still has to win. + expect(decoder.out.jitter.peek()).toBe(Time.Milli(500)); + + newest.set({ + video: { renditions: { video: config({ delay: 700 }) } }, + }); + await settle(); + expect(decoder.out.jitter.peek()).toBe(Time.Milli(700)); + } finally { + decoder.close(); + source.close(); + sync.close(); + } + }); + + it("keeps the outgoing floor when the next source uses a different name", async () => { + const outgoing = new Signal({ + video: { renditions: { hd: config({ delay: 200, jitter: 60 }) } }, + }); + const { broadcast, source, sync, decoder } = await play(outgoing); + try { + expect(decoder.out.jitter.peek()).toBe(Time.Milli(260)); + + broadcast.set( + watchBroadcast( + new Signal({ + video: { renditions: { sd: config({ jitter: 20 }) } }, + }), + ), + ); + await settle(); + expect(decoder.out.jitter.peek()).toBe(Time.Milli(260)); + } finally { + decoder.close(); + source.close(); + sync.close(); + } + }); +}); diff --git a/js/watch/src/video/decoder.ts b/js/watch/src/video/decoder.ts index c6319ff5e9..592eb898d3 100644 --- a/js/watch/src/video/decoder.ts +++ b/js/watch/src/video/decoder.ts @@ -92,7 +92,10 @@ export class Decoder { // The current track running, held so we can cancel it when the new track is ready. #active = new Signal(undefined); - #pendingJitter = new Signal(undefined); + // The rendition preparing to replace the active one, with the catalog of the broadcast it + // subscribed to. One value so a later source that reuses the track name still notifies: + // the name alone would compare equal, and the jitter effect would keep the previous catalog. + #pending = new Signal<{ track: string; catalog: Getter } | undefined>(undefined); readonly #identity: Computed; #signals = new Effect(); @@ -125,9 +128,20 @@ export class Decoder { this.#signals.run(this.#runBuffering.bind(this)); } + // Read from the full catalog entry, which the decoder config omits, so a rising entry resizes Sync. + // Each in-flight rendition stays tied to the catalog it subscribed to: during a source switch the + // shared catalog already describes the next broadcast, and the same track name there is a + // different rendition. #runJitter(effect: Effect): void { - const active = effect.get(this.#active)?.jitter; - const pending = effect.get(this.#pendingJitter); + const floor = (track: string | undefined, catalog: Getter | undefined) => { + if (track === undefined || catalog === undefined) return undefined; + const config = effect.get(catalog)?.video?.renditions?.[track]; + return config && renditionJitter(config); + }; + const activeTrack = effect.get(this.#active); + const active = floor(activeTrack?.track, activeTrack?.catalog); + const pendingTrack = effect.get(this.#pending); + const pending = floor(pendingTrack?.track, pendingTrack?.catalog); effect.set(this.#out.jitter, switchJitter({ active, pending })); } @@ -164,8 +178,9 @@ export class Decoder { track, config: identity.decoder, stats: this.#out.stats, + catalog: broadcast.out.catalog, }); - effect.set(this.#pendingJitter, pending.jitter); + effect.set(this.#pending, { track, catalog: broadcast.out.catalog }); effect.cleanup(() => pending?.close()); @@ -185,7 +200,7 @@ export class Decoder { // Upgrade the pending track to active. // #runActive will be in charge of it now. this.#active.set(pending); - this.#pendingJitter.set(undefined); + this.#pending.set(undefined); pending = undefined; // This effect is done; close it to avoid a useless re-run. @@ -267,6 +282,9 @@ interface DecoderTrackProps { broadcast: Moq.Broadcast.Consumer; track: string; config: DecoderConfig; + // The broadcast catalog this subscription started from. Updates on that broadcast still apply; + // a later source does not. + catalog: Getter; stats: Signal; } @@ -276,8 +294,8 @@ class DecoderTrack { broadcast: Moq.Broadcast.Consumer; track: string; config: DecoderConfig; + catalog: Getter; stats: Signal; - jitter: Time.Milli | undefined; timestamp = new Signal(undefined); frame = new Signal(undefined); @@ -299,8 +317,8 @@ class DecoderTrack { this.broadcast = props.broadcast; this.track = props.track; this.config = props.config; + this.catalog = props.catalog; this.stats = props.stats; - this.jitter = renditionJitter(props.config); this.#signals.run(this.#run.bind(this)); } diff --git a/js/watch/src/video/playhead.test.ts b/js/watch/src/video/playhead.test.ts index 7c701a46a0..08c5fef605 100644 --- a/js/watch/src/video/playhead.test.ts +++ b/js/watch/src/video/playhead.test.ts @@ -4,7 +4,7 @@ import type { Time } from "@moq/net"; import { caughtUp, renditionJitter, switchJitter } from "./playhead"; // `jitter` is a branded u53 in the catalog schema, so build the config through a cast. -function config(props: { jitter?: number; framerate?: number }): Catalog.VideoConfig { +function config(props: { jitter?: number; delay?: number; framerate?: number }): Catalog.VideoConfig { return { codec: "avc1.640028", container: { kind: "legacy" }, ...props } as Catalog.VideoConfig; } @@ -26,6 +26,12 @@ describe("renditionJitter", () => { it("is undefined when the catalog declares neither", () => { expect(renditionJitter(config({}))).toBeUndefined(); }); + + it("adds the delay behind the earliest rendition", () => { + expect(renditionJitter(config({ delay: 200, jitter: 60 }))).toBe(ms(260)); + expect(renditionJitter(config({ delay: 200, framerate: 30 }))).toBe(ms(234)); + expect(renditionJitter(config({ delay: 200 }))).toBe(ms(200)); + }); }); describe("caughtUp", () => { diff --git a/js/watch/src/video/playhead.ts b/js/watch/src/video/playhead.ts index 7f341d3c36..851f645190 100644 --- a/js/watch/src/video/playhead.ts +++ b/js/watch/src/video/playhead.ts @@ -9,15 +9,19 @@ const SLACK = Time.Milli(100); /** * How far behind live a rendition's playhead can sit while still being at its own live edge. * - * Frames arrive one group at a time, so this is the rendition's group cadence: the sync buffer - * has to cover it or playback starves between groups. The catalog value wins. Otherwise assume - * the publisher flushes each frame as it's encoded, so a frame interval is the longest we wait. - * Undefined when the catalog declares neither. + * Its catalog `delay` behind the broadcast's earliest rendition, plus its own spread: frames arrive + * one group at a time, so the sync buffer has to cover the group cadence or playback starves + * between groups. The catalog `jitter` wins. Otherwise assume the publisher flushes each frame as + * it's encoded, so a frame interval is the longest we wait. Undefined when the catalog declares + * none of them. */ export function renditionJitter(config: Catalog.VideoConfig): Time.Milli | undefined { - if (config.jitter !== undefined) return Time.Milli(config.jitter); - if (config.framerate) return Time.Milli(Math.ceil(1000 / config.framerate)); - return undefined; + let spread: Time.Milli | undefined; + if (config.jitter !== undefined) spread = Time.Milli(config.jitter); + else if (config.framerate) spread = Time.Milli(Math.ceil(1000 / config.framerate)); + + if (config.delay === undefined) return spread; + return Time.Milli.add(Time.Milli(config.delay), spread ?? Time.Milli.zero); } /** The playheads involved in promoting a new rendition. */ diff --git a/quest/m0/audio-jitter-target/README.md b/quest/m0/audio-jitter-target/README.md index cf59623b4b..9f9600f3e1 100644 --- a/quest/m0/audio-jitter-target/README.md +++ b/quest/m0/audio-jitter-target/README.md @@ -71,7 +71,6 @@ buffer against uneven arrivals. ## Related -- [Jitter clock](/quest/m1/jitter-flush-clock.md) - the advertised jitter (#3513 landed the flush span), which `doc/concept/audio-jitter.md` settles as a floor on the measured target - [Audio quality harness](/quest/m1/audio-quality-harness/README.md) - the automated proof, built on its own schedule - [Time stretch](/quest/m1/watch-audio-time-stretch.md) - inaudible convergence, on top of this - [Plan: A/V clock](/quest/m0/plan-av-clock.md) - the clock this target eventually feeds diff --git a/quest/m1/README.md b/quest/m1/README.md index a0ff60bb93..787191075c 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -39,7 +39,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Optional max age](/quest/m1/ietf-max-age.md) - max age is optional, set only by the publisher, and crosses moq-transport as MAX_CACHE_DURATION - [IETF announce count](/quest/m1/ietf-announce-count.md) - an opt-in moq-transport extension carries the replay count, so IETF announce consumers go live without a timer -- [Jitter clock](/quest/m1/jitter-flush-clock.md) - renditions advertise `delay` (lag behind the earliest track) and `jitter` (spread), measured at encoder flush, never lowered; js/watch sizes playout over what it subscribes +- [Publish delay](/quest/m1/publish-delay.md) - js/publish encoders advertise `delay` behind the earliest rendition, like moq-mux - [Import discontinuity](/quest/m1/import-discontinuity.md) - a seek or pause resets the flush jitter baseline, from moqsink, libmoq, and moq-ffi - [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` - [Moxygen compatibility](/quest/m1/moxygen/README.md) - one subgroup per group, whole-group FETCH, and one datagram per group, never a full moxygen pass diff --git a/quest/m1/data-jitter.md b/quest/m1/data-jitter.md index e21e11ca3b..3ccf0386a4 100644 --- a/quest/m1/data-jitter.md +++ b/quest/m1/data-jitter.md @@ -5,8 +5,8 @@ A JSON or binary track whose payloads carry a capture time on the publisher's own clock (a UDP datagram's arrival, a sensor read) advertises a detected `delay` and `jitter`: how late the publisher hands payloads to the transport -relative to that time, the same measurement -[jitter clock](/quest/m1/jitter-flush-clock.md) defines for encoders. A +relative to that time, the same measurement `moq_mux::catalog::Estimator` +makes for encoders. A telemetry source slower than the video it accompanies shows up as `delay`. Tracks written without a capture time advertise neither rather than a meaningless zero. @@ -45,6 +45,3 @@ Public API: additive on `hang`, `moq-binary`, `moq-json`, `moq-mux`, `@moq/hang`, `@moq/binary`, and `@moq/json`. Wire: one optional field on data entries. -## Required - -- [Jitter clock](/quest/m1/jitter-flush-clock.md) - defines the flush-lateness measurement and `delay` diff --git a/quest/m1/jitter-flush-clock.md b/quest/m1/jitter-flush-clock.md deleted file mode 100644 index e83e361955..0000000000 --- a/quest/m1/jitter-flush-clock.md +++ /dev/null @@ -1,113 +0,0 @@ -# [L] moq-mux: catalog delay and jitter measure how far behind the media clock an encoder flushes - -## Goal - -An original publisher advertises two numbers per rendition, both measured -from the gap between a frame's media timestamp and the wall-clock moment it -handed that frame to the transport (its lateness), the same lateness -`js/watch` `sync.ts` computes on receive, measured one hop earlier: - -- `delay`: the rendition's minimum lateness behind the broadcast's earliest - rendition. A video encoder running 200 ms behind the audio encoder - advertises `delay: 200` on video and none on audio. -- `jitter`: the spread of the rendition's lateness above its own minimum. A - fragment flushed at its end, a B-frame held for reordering, and a - latency-buffered batch all raise it without being special cases. - -`delay + jitter` is the worst-case lateness, and neither value is ever -lowered once advertised. `js/watch` sizes playout over the renditions it -subscribes to as `max(delay + jitter) - min(delay)` plus network jitter, and -resizes when that set changes, so dropping a slow track lowers latency. - -Only encoders feed the clock; file and pipe imports and the RTMP, SRT, and TS -gateways never do, so an unstable ingest link cannot inflate the catalog. TS -and fragmented MP4 imports retain their clock-free jitter estimates from the -media span of each emitted batch and advertise no `delay`. - -## Plan - -- **Landed (#3940):** `jitter` measured at encoder flush. `Estimator::flush`, - `container::Producer::flush`, and codec importer forwarding; each rendition - keeps its own 10 s sliding minimum and advertises the lifetime maximum spread - above it. The `moq-video` and `moq-audio` encoders (so `moq import capture`), - libmoq (so OBS), moq-ffi and its wrappers, and the `js/publish` encoders call - it. The provisional PTS-gap floor is gone, and `moq_mux::Error::JitterDecreased` - plus zero-as-absent text jitter enforce never-lower in Rust and JS. - `moq-gst` pads opt in with `encoder=true`; imports stay clock-free. - What remains below is `delay` and the player. A seek or pause resetting the - baseline from moqsink and the bindings is - [Import discontinuity](/quest/m1/import-discontinuity.md), including a - `moq-gst` encoder pad across a `PLAYING -> PAUSED -> PLAYING` cycle (running - time stops, the wall clock does not) and a flushing seek. -- **Measurement.** Lateness is `now - timestamp`, observed by the existing - `flush` calls, so no call site changes. Each rendition keeps its own - baseline, the minimum lateness over a sliding window (about 10 s), so a media - clock that drifts slower than wall time does not ratchet forever. The - broadcast baseline, on `catalog::Producer` and shared by every rendition - that flushes, is the minimum of those; the per-rendition `Baseline` epochs - must become one shared epoch for the subtraction to mean anything. `delay` is - the rendition baseline minus the broadcast baseline, reported as its lifetime - maximum. Renditions that never flush advertise no `delay`. -- **Open:** lifetime maxima taken against a sliding baseline stop sharing an - origin when the earliest rendition changes. If A starts at 0 and B at - 200 ms, B keeps `delay: 200`; if A then drifts to 500 ms, A advertises 300 - and `Sync` computes `300 - 200 = 100` while the tracks are 300 ms apart. - Options: - - *No subtraction (recommended).* Keep the sliding baselines and never-lower, - and change the player rule to `max(delay + jitter)` over the subscribed - renditions, dropping `- min(delay)`. The subscribed renditions' true spread - is measured from an earliest subscribed baseline no earlier than the - broadcast baseline, so it never exceeds the largest advertised `delay`, - and the catalog alone can never under-buffer. The cost is over-buffering by - `min(delay)` when the broadcast's earliest rendition is not subscribed - (a video-only viewer of a broadcast whose audio leads by 200 ms pays - 200 ms). Dropping a slow track still lowers latency. The draft says a - consumer MUST NOT subtract `delay` values across renditions. - - *Fixed common origin.* Measure every `delay` from the broadcast's first - lateness. Exact subtraction, but common drift raises every rendition - together, so values grow without bound and the catalog republishes for the - life of the broadcast; the problem the sliding window exists to avoid. - - *Coordinated rebasing.* Advertise each `delay` as its current value against - the current broadcast baseline and let it fall. Exact, but `delay` gives up - never-lower, the catalog churns as baselines move, and the player must - shrink safely. - - *No `delay` field.* The player measures each subscribed track's own - arrival baseline and sizes by their spread. No wire change, and it also - covers gateway and ingest offsets, but a track's offset is unknown until its - first frames arrive, so subscribing to a slower rendition glitches once. - Every option also needs the player's reference to follow the earliest - subscribed track's current arrival rather than its lifetime minimum, since - `Sync.received` only ever lowers it; that is receiver-side and belongs with - the arrival minimum the [audio jitter target](/quest/m0/audio-jitter-target/README.md) - already expires. -- A faster-than-real-time source flushes early; each frame becomes the new - minimum and both stay at zero, which is correct for something that is not - live. -- **Wire.** Add optional `delay` beside `jitter` on video, audio, and text - renditions in `rs/hang`, `js/hang`, and `drafts/draft-lcurley-moq-hang.md`, - serialized with `MillisCeil` and zero-as-absent like `jitter`. Additive: - today's `jitter` never included a cross-track offset. Extend the draft's - never-lower rule to `delay` (unless rebasing wins), along with - `moq_mux::Error::JitterDecreased` and the `js/publish/src/catalog.ts` check. -- **Player.** `Sync` registers each subscribed rendition's `delay` and - `jitter` and recomputes when a rendition registers, unregisters, or its - catalog entry rises. Rename `Sync`'s own `delay` output (the resolved - playout total) so it does not collide with the field. #3954 on the - [audio jitter target](/quest/m0/audio-jitter-target/README.md) line changes - what `register()` takes, so build on whichever lands first. -- **Docs.** Update `doc/concept/audio-jitter.md`, the normative playout page, - with the cross-track rule and the catalog floor it reads. -- **Tests** inject the clock. Cover: a constant offset reports as `delay` on - the slower track and nothing on the faster, a slow common drift stays - bounded, the earliest rendition changing does not under-buffer, a decrease is - refused on every site, and `Sync` resizes on a subscription change. - -Public API: additive `delay` field in `hang` and `@moq/hang`, the -decreased-estimate error extended to `delay`, `Sync` output rename in -`@moq/watch`. Wire: one optional catalog field. - -## Related - -- [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - reads `jitter` as its floor, which is now the spread alone -- [Audio time-stretch](/quest/m1/watch-audio-time-stretch.md) - converges audio to a resized target without skipping -- [Data jitter](/quest/m1/data-jitter.md) - feeds the same measurement from JSON and binary tracks diff --git a/quest/m1/publish-delay.md b/quest/m1/publish-delay.md new file mode 100644 index 0000000000..aeb4209d69 --- /dev/null +++ b/quest/m1/publish-delay.md @@ -0,0 +1,27 @@ +# [S] js/publish: encoders advertise catalog delay + +## Goal + +The browser publisher advertises `delay` the way `moq-mux` does: each rendition +reports how far its minimum flush lateness trails the broadcast's earliest +rendition, as a lifetime maximum that is never lowered. A browser video encoder +running behind its audio encoder advertises that offset on video, so +`js/watch` holds it instead of playing video late. + +## Plan + +- `js/publish/src/jitter.ts` already measures each rendition's lateness on + `performance.now()`, and every js/publish timestamp shares that clock, so a + broadcast-wide sliding minimum beside the per-rendition one is enough. Mirror + `moq_mux::catalog::Estimator`: one window per rendition, one shared by the + broadcast, and `delay` is the gap between their minima. +- The shared minimum belongs to the `Broadcast` the encoders register on, not to + the encoders, so a swapped broadcast starts fresh. Keep it off the public API. +- The draft's rule stands: a consumer never subtracts `delay` across + renditions and holds the largest `delay + jitter` it subscribes to, so the + publisher reports its sliding-baseline maximum as is. +- `CatalogProducer` already refuses a lowered or zero `delay`. + +## Related + +- [Data jitter](/quest/m1/data-jitter.md) - the same measurement for JSON and binary tracks in Rust diff --git a/quest/m2/watch-data-sync.md b/quest/m2/watch-data-sync.md index e0d3d6ae8b..19b0b1f440 100644 --- a/quest/m2/watch-data-sync.md +++ b/quest/m2/watch-data-sync.md @@ -21,7 +21,6 @@ releases it. ## Required -- [Jitter clock](/quest/m1/jitter-flush-clock.md) - the `delay` field and `Sync` sizing this registers into - [Data jitter](/quest/m1/data-jitter.md) - data tracks advertise the `delay` and `jitter` this reads ## Related diff --git a/rs/hang/src/catalog/audio/mod.rs b/rs/hang/src/catalog/audio/mod.rs index 6afa7cef18..7c013c94a9 100644 --- a/rs/hang/src/catalog/audio/mod.rs +++ b/rs/hang/src/catalog/audio/mod.rs @@ -124,6 +124,17 @@ pub struct AudioConfig { #[serde_as(as = "MillisCeil")] #[serde(default)] pub jitter: Option, + + /// How far this rendition's frames reach the transport behind the broadcast's earliest + /// rendition, measured at the publisher from each rendition's minimum flush lateness. + /// Absent on the earliest rendition and on any rendition the publisher did not measure. + /// + /// A consumer holds `delay + jitter` for this rendition and MUST NOT subtract one rendition's + /// `delay` from another's: each is a lifetime maximum, so two need not share an origin. It only + /// ever grows over the life of a stream, and is serialized like [`jitter`](Self::jitter). + #[serde_as(as = "MillisCeil")] + #[serde(default)] + pub delay: Option, } impl AudioConfig { @@ -144,6 +155,7 @@ impl AudioConfig { description: None, container: Container::default(), jitter: None, + delay: None, } } } diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 21c1e11e85..8941e3835f 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -301,18 +301,19 @@ mod test { assert_eq!(encoded, output, "wrong encoded output"); } - /// Lock in the on-wire shape of the jitter field: a bare integer number + /// Lock in the on-wire shape of the jitter and delay fields: a bare integer number /// of milliseconds. If `Option` ever loses the `duration_millis` /// serde adapter, this regresses to serde's default `{secs, nanos}` shape. #[test] - fn jitter_serialized_as_millis() { + fn jitter_and_delay_serialized_as_millis() { let mut encoded = r#"{ "video": { "renditions": { "video": { "codec": "avc1.64001f", "container": {"kind": "legacy"}, - "jitter": 100 + "jitter": 100, + "delay": 200 } } }, @@ -355,6 +356,7 @@ mod test { optimize_for_latency: None, container: Container::Legacy, jitter: Some(std::time::Duration::from_millis(100)), + delay: Some(std::time::Duration::from_millis(200)), }, ); @@ -371,6 +373,7 @@ mod test { description: None, container: Container::Legacy, jitter: Some(std::time::Duration::from_millis(40)), + delay: None, }, ); diff --git a/rs/hang/src/catalog/text/mod.rs b/rs/hang/src/catalog/text/mod.rs index 29e089e590..496b016af2 100644 --- a/rs/hang/src/catalog/text/mod.rs +++ b/rs/hang/src/catalog/text/mod.rs @@ -107,6 +107,17 @@ pub struct TextConfig { #[serde_as(as = "MillisCeil")] #[serde(default)] pub jitter: Option, + + /// How far this rendition's frames reach the transport behind the broadcast's earliest + /// rendition, measured at the publisher from each rendition's minimum flush lateness. + /// Absent on the earliest rendition and on any rendition the publisher did not measure. + /// + /// A consumer holds `delay + jitter` for this rendition and MUST NOT subtract one rendition's + /// `delay` from another's: each is a lifetime maximum, so two need not share an origin. It only + /// ever grows over the life of a stream, and is serialized like [`jitter`](Self::jitter). + #[serde_as(as = "MillisCeil")] + #[serde(default)] + pub delay: Option, } impl TextConfig { @@ -125,6 +136,7 @@ impl TextConfig { label: None, container: Container::default(), jitter: None, + delay: None, } } } diff --git a/rs/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index 1c75126d0e..db43d6c3eb 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -235,6 +235,17 @@ pub struct VideoConfig { #[serde_as(as = "MillisCeil")] #[serde(default)] pub jitter: Option, + + /// How far this rendition's frames reach the transport behind the broadcast's earliest + /// rendition, measured at the publisher from each rendition's minimum flush lateness. + /// Absent on the earliest rendition and on any rendition the publisher did not measure. + /// + /// A consumer holds `delay + jitter` for this rendition and MUST NOT subtract one rendition's + /// `delay` from another's: each is a lifetime maximum, so two need not share an origin. It only + /// ever grows over the life of a stream, and is serialized like [`jitter`](Self::jitter). + #[serde_as(as = "MillisCeil")] + #[serde(default)] + pub delay: Option, } impl VideoConfig { @@ -260,6 +271,7 @@ impl VideoConfig { optimize_for_latency: None, container: Container::default(), jitter: None, + delay: None, } } } diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index f1f1a03691..5832ae9375 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -190,6 +190,7 @@ fn normalize_video(config: &VideoConfig) -> VideoConfig { let mut config = config.clone(); config.bitrate = None; config.jitter = None; + config.delay = None; config.label = None; config.stalled = None; // The muxer ignores a non-finite framerate, and NaN never equals itself, so a catalog @@ -203,6 +204,7 @@ fn normalize_audio(config: &AudioConfig) -> AudioConfig { let mut config = config.clone(); config.bitrate = None; config.jitter = None; + config.delay = None; config.label = None; config } diff --git a/rs/moq-msf/src/lib.rs b/rs/moq-msf/src/lib.rs index 200aa204f2..83087ca201 100644 --- a/rs/moq-msf/src/lib.rs +++ b/rs/moq-msf/src/lib.rs @@ -184,6 +184,15 @@ pub struct Track { /// Serialized as a JSON number of milliseconds, matching the hang catalog. #[serde_as(as = "Option")] pub jitter: Option, + + /// How far this rendition trails the broadcast's earliest rendition (non-standard + /// extension; not in the MSF/CMSF drafts). + /// + /// Serialized as a JSON number of milliseconds, matching [`jitter`](Self::jitter). Absent on + /// the earliest rendition. A consumer holds `delay + jitter` and does not subtract one + /// rendition's `delay` from another's. + #[serde_as(as = "Option")] + pub delay: Option, } impl Catalog<()> { @@ -480,6 +489,7 @@ impl Track { max_grp_sap_starting_type: None, max_obj_sap_starting_type: None, jitter: None, + delay: None, } } } @@ -631,6 +641,7 @@ mod test { max_grp_sap_starting_type: None, max_obj_sap_starting_type: None, jitter: None, + delay: None, } } @@ -655,6 +666,7 @@ mod test { max_grp_sap_starting_type: None, max_obj_sap_starting_type: None, jitter: None, + delay: None, } } @@ -679,6 +691,7 @@ mod test { max_grp_sap_starting_type: Some(1), max_obj_sap_starting_type: Some(2), jitter: Some(Duration::from_millis(15)), + delay: None, } } @@ -700,6 +713,7 @@ mod test { assert!(track.get("maxGrpSapStartingType").is_none()); assert!(track.get("maxObjSapStartingType").is_none()); assert!(track.get("jitter").is_none()); + assert!(track.get("delay").is_none()); assert_eq!(track["stalled"], true); } @@ -823,6 +837,7 @@ mod test { assert_eq!(track.max_grp_sap_starting_type, None); assert_eq!(track.max_obj_sap_starting_type, None); assert_eq!(track.jitter, None); + assert_eq!(track.delay, None); } #[test] @@ -858,6 +873,20 @@ mod test { assert_eq!(value["tracks"][0]["jitter"].as_f64(), Some(15.0)); } + #[test] + fn delay_roundtrips() { + let mut track = track_with_sap_and_jitter(); + track.delay = Some(Duration::from_millis(200)); + let original = Catalog::new(vec![track]); + + let json = original.to_json().unwrap(); + let parsed = Catalog::<()>::from_str(&json).unwrap(); + assert_eq!(parsed.tracks[0].delay, Some(Duration::from_millis(200))); + + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["tracks"][0]["delay"].as_f64(), Some(200.0)); + } + #[test] fn serialize_emits_draft01_version() { // Callers never set a version; we always emit the newest draft string. diff --git a/rs/moq-mux/src/catalog/estimate.rs b/rs/moq-mux/src/catalog/estimate.rs index 8ac0378131..9455626547 100644 --- a/rs/moq-mux/src/catalog/estimate.rs +++ b/rs/moq-mux/src/catalog/estimate.rs @@ -1,4 +1,5 @@ use std::collections::VecDeque; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::{Duration, Instant}; use moq_net::Timestamp; @@ -19,6 +20,8 @@ pub struct Estimate { pub jitter: Option, /// The maximum bitrate in bits per second. pub bitrate: Option, + /// The most this track's minimum flush lateness has trailed the broadcast's earliest track. + pub delay: Option, } impl Estimate { @@ -33,6 +36,12 @@ impl Estimate { self.bitrate = bitrate.into(); self } + + /// Set the delay behind the broadcast's earliest track (or clear it with `None`). + pub fn with_delay(mut self, delay: impl Into>) -> Self { + self.delay = delay.into(); + self + } } /// Measures the catalog jitter and bitrate of one track from the frames written to it. @@ -76,7 +85,13 @@ impl Estimate { pub struct Estimator { jitter: Jitter, bitrate: Bitrate, - baseline: Baseline, + /// This track's recent minimum flush lateness. + baseline: Window, + /// The recent minimum across every track sharing it. A standalone estimator's is its own, so + /// it never measures a delay. + broadcast: Baseline, + /// The largest amount [`baseline`](Self::baseline) has trailed [`broadcast`](Self::broadcast). + delay: Duration, } impl Estimator { @@ -85,6 +100,14 @@ impl Estimator { Self::default() } + /// Measure `delay` against the tracks sharing `broadcast`. + pub(crate) fn with_broadcast(broadcast: Baseline) -> Self { + Self { + broadcast, + ..Self::default() + } + } + /// Observe a frame of `bytes` encoded bytes at presentation time `timestamp`, as written by /// [`container::Producer::write`](crate::container::Producer::write). pub fn write(&mut self, timestamp: Timestamp, bytes: usize) { @@ -94,10 +117,13 @@ impl Estimator { /// Measure when an encoder handed a frame to the transport. Only locally encoded frames should /// call this; imports keep clock-free batch and reorder estimates. Jitter is the spread above - /// this track's own recent minimum lateness, so a constant encoder delay is not jitter. + /// this track's own recent minimum lateness, so a constant encoder delay is not jitter. Delay + /// is how far that minimum trails the earliest track on the same catalog. pub fn flush(&mut self, timestamp: Timestamp, now: Instant) { - let spread = self.baseline.observe(timestamp, now); - self.jitter.max = self.jitter.max.max(spread); + let (lateness, earliest) = self.broadcast.observe(timestamp, now); + let minimum = self.baseline.observe(now, lateness); + self.jitter.max = self.jitter.max.max(nanos_between(minimum, lateness)); + self.delay = self.delay.max(nanos_between(earliest, minimum)); } /// Close the current span at `end`, as [`container::Producer::cut`](crate::container::Producer::cut) @@ -110,11 +136,15 @@ impl Estimator { self.bitrate.cut(end.map(nanos)); } - /// Discard the open bitrate span and the flush baseline, so nothing is measured across a break + /// Discard the open bitrate span and the flush baselines, so nothing is measured across a break /// in the timeline. See [`container::Producer::discontinuity`](crate::container::Producer::discontinuity). + /// + /// The broadcast baseline is cleared too: a pause usually stops every track, and the others' + /// pre-pause minimum would otherwise read as a delay until it left the window. pub fn discontinuity(&mut self) { self.bitrate.discontinuity(); - self.baseline = Baseline::default(); + self.baseline = Window::default(); + self.broadcast.discontinuity(); } /// Observe a frame's reorder delay (`PTS - DTS`), which raises the jitter to the decode buffer a @@ -137,6 +167,7 @@ impl Estimator { Estimate { jitter: self.jitter.current(), bitrate: self.bitrate.current(), + delay: (!self.delay.is_zero()).then_some(self.delay), } } } @@ -258,14 +289,43 @@ impl Jitter { } } -/// One track's minimum encode lateness over the recent window. +/// The flush clock shared by every track on one catalog: a common epoch, so lateness compares +/// across tracks, and the minimum lateness any of them flushed with over the recent window. /// /// An `Instant` has no public mapping to the broadcast's media epoch. The first observation -/// chooses a local origin; its unknown offset cancels when subtracting the recent minimum. A -/// monotonic deque makes insertion and expiry amortized O(1). +/// chooses a local origin; its unknown offset cancels when subtracting one minimum from another. +#[derive(Clone, Default)] +pub(crate) struct Baseline(Arc>); + #[derive(Default)] -struct Baseline { +struct Shared { epoch: Option, + window: Window, +} + +impl Baseline { + /// Return the frame's lateness on the shared epoch and the broadcast's minimum including it. + fn observe(&self, timestamp: Timestamp, now: Instant) -> (i128, i128) { + let mut shared = self.0.lock().unwrap_or_else(PoisonError::into_inner); + let epoch = *shared.epoch.get_or_insert(now); + let elapsed = match now.checked_duration_since(epoch) { + Some(duration) => duration.as_nanos() as i128, + None => -(epoch.duration_since(now).as_nanos() as i128), + }; + let lateness = elapsed - timestamp.as_nanos() as i128; + (lateness, shared.window.observe(now, lateness)) + } + + fn discontinuity(&self) { + self.0.lock().unwrap_or_else(PoisonError::into_inner).window = Window::default(); + } +} + +/// The minimum lateness over the last [`JITTER_WINDOW`], so a media clock drifting against the +/// wall clock does not ratchet forever. A monotonic deque makes insertion and expiry amortized +/// O(1). +#[derive(Default)] +struct Window { samples: VecDeque, } @@ -274,32 +334,29 @@ struct Sample { lateness: i128, } -impl Baseline { - fn observe(&mut self, timestamp: Timestamp, now: Instant) -> Duration { - let epoch = *self.epoch.get_or_insert(now); +impl Window { + /// Insert a lateness observed at `now` and return the window's minimum. + fn observe(&mut self, now: Instant, lateness: i128) -> i128 { while self .samples .front() - .is_some_and(|sample| now.duration_since(sample.at) > JITTER_WINDOW) + .is_some_and(|sample| now.saturating_duration_since(sample.at) > JITTER_WINDOW) { self.samples.pop_front(); } - - let elapsed = match now.checked_duration_since(epoch) { - Some(duration) => duration.as_nanos() as i128, - None => -(epoch.duration_since(now).as_nanos() as i128), - }; - let lateness = elapsed - timestamp.as_nanos() as i128; while self.samples.back().is_some_and(|sample| sample.lateness >= lateness) { self.samples.pop_back(); } self.samples.push_back(Sample { at: now, lateness }); - let minimum = self.samples.front().expect("the current sample was inserted").lateness; - let spread = u64::try_from(lateness - minimum).unwrap_or(u64::MAX); - Duration::from_nanos(spread) + self.samples.front().expect("the current sample was inserted").lateness } } +/// `to - from` as a duration, clamped at zero. +fn nanos_between(from: i128, to: i128) -> Duration { + Duration::from_nanos(u64::try_from(to - from).unwrap_or(if to > from { u64::MAX } else { 0 })) +} + #[cfg(test)] mod tests { use super::*; @@ -355,34 +412,94 @@ mod tests { #[test] fn baseline_expires_drift() { + let mut estimator = Estimator::new(); let anchor = Instant::now(); - - let mut drift = Baseline::default(); - let maximum = (0..100u128) - .map(|second| { - drift.observe( - micros((second * 1_000_000) as u64), - anchor + Duration::from_millis((second * 1_001) as u64), - ) - }) - .max() - .unwrap(); - assert!(maximum <= Duration::from_millis(10), "{maximum:?}"); + for second in 0..100u64 { + estimator.flush( + micros(second * 1_000_000), + anchor + Duration::from_millis(second * 1_001), + ); + } + let jitter = estimator.estimate().jitter.unwrap(); + assert!(jitter <= Duration::from_millis(10), "{jitter:?}"); } #[test] fn early_flush_keeps_lowering_the_baseline() { - let mut baseline = Baseline::default(); + let mut estimator = Estimator::new(); let anchor = Instant::now(); - for second in 0..100u128 { - assert_eq!( - baseline.observe( - micros((second * 2_000_000) as u64), - anchor + Duration::from_secs(second as u64) - ), - Duration::ZERO - ); + for second in 0..100u64 { + estimator.flush(micros(second * 2_000_000), anchor + Duration::from_secs(second)); } + assert_eq!(estimator.estimate(), Estimate::default()); + } + + /// Two tracks on one broadcast baseline, flushed every 100 ms for `seconds` with the lateness + /// each closure returns (in ms) at a given frame's media time. + fn pair(seconds: u64, fast: impl Fn(u64) -> u64, slow: impl Fn(u64) -> u64) -> (Estimate, Estimate) { + let broadcast = Baseline::default(); + let mut estimators = [ + Estimator::with_broadcast(broadcast.clone()), + Estimator::with_broadcast(broadcast), + ]; + let anchor = Instant::now(); + for frame in 0..seconds * 10 { + let pts = frame * 100; + for (estimator, lateness) in estimators.iter_mut().zip([fast(pts), slow(pts)]) { + estimator.flush(micros(pts * 1_000), anchor + Duration::from_millis(pts + lateness)); + } + } + let [fast, slow] = estimators.map(|estimator| estimator.estimate()); + (fast, slow) + } + + #[test] + fn a_constant_offset_is_delay_on_the_slower_track() { + let (fast, slow) = pair(5, |_| 0, |_| 200); + assert_eq!(fast, Estimate::default()); + assert_eq!(slow, Estimate::default().with_delay(Duration::from_millis(200))); + } + + #[test] + fn a_common_drift_stays_bounded() { + // Both media clocks run 0.1% slow for 100 s, so each lateness climbs 100 ms together. + let (fast, slow) = pair(100, |pts| pts / 1_000, |pts| 200 + pts / 1_000); + assert_eq!(fast.delay, None); + assert_eq!(slow.delay, Some(Duration::from_millis(200))); + for jitter in [fast.jitter, slow.jitter] { + assert!(jitter.unwrap() <= Duration::from_millis(10), "{jitter:?}"); + } + } + + /// The earliest track starts at 0 and the other at 200 ms, then the first drifts to 500 ms. The + /// delays no longer share an origin, but the drifting track's own `delay` covers the new offset, + /// so the largest `delay + jitter` never falls below the real spread. + #[test] + fn the_earliest_track_changing_does_not_under_buffer() { + let drift = |pts: u64| pts.saturating_sub(10_000).min(50_000) / 100; + let (drifted, steady) = pair(80, drift, |_| 200); + assert_eq!(steady.delay, Some(Duration::from_millis(200))); + assert_eq!(drifted.delay, Some(Duration::from_millis(300))); + } + + #[test] + fn a_discontinuity_clears_the_broadcast_baseline() { + let broadcast = Baseline::default(); + let mut audio = Estimator::with_broadcast(broadcast.clone()); + let mut video = Estimator::with_broadcast(broadcast); + let anchor = Instant::now(); + audio.flush(micros(0), anchor); + video.flush(micros(0), anchor + Duration::from_millis(50)); + assert_eq!(video.estimate().delay, Some(Duration::from_millis(50))); + + // Both resume at the previous live edge after a 2 s pause: the pause is not a delay. + audio.discontinuity(); + video.discontinuity(); + video.flush(micros(40_000), anchor + Duration::from_millis(2_090)); + audio.flush(micros(40_000), anchor + Duration::from_millis(2_040)); + video.flush(micros(80_000), anchor + Duration::from_millis(2_130)); + assert_eq!(video.estimate().delay, Some(Duration::from_millis(50))); + assert_eq!(audio.estimate().delay, None); } #[test] diff --git a/rs/moq-mux/src/catalog/msf/consumer.rs b/rs/moq-mux/src/catalog/msf/consumer.rs index 2d49b61510..c8e2a256a0 100644 --- a/rs/moq-mux/src/catalog/msf/consumer.rs +++ b/rs/moq-mux/src/catalog/msf/consumer.rs @@ -237,6 +237,7 @@ fn video_config_from_msf(track: &moq_msf::Track) -> Result> config.framerate = track.framerate; config.container = container; config.jitter = track.jitter; + config.delay = track.delay; Ok(Some(config)) } @@ -276,6 +277,7 @@ fn audio_config_from_msf(track: &moq_msf::Track) -> Result> config.description = legacy_description(track)?; config.container = container; config.jitter = track.jitter; + config.delay = track.delay; Ok(Some(config)) } @@ -446,6 +448,30 @@ mod test { assert_eq!(video.stalled, Some(true)); } + #[test] + fn delay_round_trips_onto_hang() { + let mut video = video_track("video0", moq_msf::Packaging::Legacy, None); + video.jitter = Some(std::time::Duration::from_millis(40)); + video.delay = Some(std::time::Duration::from_millis(200)); + + let mut audio = audio_track("audio0", moq_msf::Packaging::Loc); + audio.delay = Some(std::time::Duration::from_millis(80)); + + let catalog = from_msf::<()>(&moq_msf::Catalog::new(vec![video, audio])).expect("delay should convert"); + assert_eq!( + catalog.video.renditions["video0"].delay, + Some(std::time::Duration::from_millis(200)) + ); + assert_eq!( + catalog.video.renditions["video0"].jitter, + Some(std::time::Duration::from_millis(40)) + ); + assert_eq!( + catalog.audio.renditions["audio0"].delay, + Some(std::time::Duration::from_millis(80)) + ); + } + #[test] fn loc_audio_yields_loc_container() { let msf = moq_msf::Catalog::new(vec![audio_track("audio0", moq_msf::Packaging::Loc)]); diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 0d0958a123..0811704b1b 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -182,6 +182,9 @@ pub struct Producer { /// Connection allocator passthrough tracks claim their peak-hold bitrate on. /// See [`Config::with_bandwidth`]. bandwidth: moq_net::bandwidth::Allocator, + /// The minimum flush lateness across this catalog's renditions, which each rendition's + /// advertised `delay` is measured against. + baseline: super::estimate::Baseline, } // Manual Clone so a producer is cheaply clonable regardless of whether `E` is. @@ -194,6 +197,7 @@ impl Clone for Producer { timeline: self.timeline.clone(), max_age: self.max_age, bandwidth: self.bandwidth.clone(), + baseline: self.baseline.clone(), } } } @@ -342,6 +346,7 @@ impl Producer { timeline, max_age: config.max_age, bandwidth: config.bandwidth, + baseline: Default::default(), }) } @@ -615,6 +620,11 @@ impl Producer { .with_bandwidth(self.bandwidth.clone())) } + /// A fresh estimator whose `delay` is measured against this catalog's other renditions. + pub(crate) fn estimator(&self) -> super::Estimator { + super::Estimator::with_broadcast(self.baseline.clone()) + } + /// The allocator passthrough tracks claim on. fMP4 writes groups by hand, so it reads this itself. pub(crate) fn bandwidth(&self) -> moq_net::bandwidth::Allocator { self.bandwidth.clone() @@ -941,6 +951,7 @@ fn to_msf_media(catalog: &hang::Catalog) -> moq_msf::Catalog { track.max_grp_sap_starting_type = sap_type; track.max_obj_sap_starting_type = sap_type; track.jitter = config.jitter; + track.delay = config.delay; tracks.push(track); } @@ -971,6 +982,7 @@ fn to_msf_media(catalog: &hang::Catalog) -> moq_msf::Catalog { track.max_grp_sap_starting_type = Some(1); track.max_obj_sap_starting_type = Some(1); track.jitter = config.jitter; + track.delay = config.delay; tracks.push(track); } @@ -1608,6 +1620,7 @@ mod test { video_config.framerate = Some(30.0); video_config.container = Container::Legacy; video_config.jitter = Some(std::time::Duration::from_millis(100)); + video_config.delay = Some(std::time::Duration::from_millis(200)); let mut video_renditions = BTreeMap::new(); video_renditions.insert("video0".to_string(), video_config); @@ -1615,6 +1628,7 @@ mod test { let mut audio_config = AudioConfig::new(AudioCodec::Opus, 48_000, 2); audio_config.container = Container::Legacy; audio_config.jitter = Some(std::time::Duration::from_millis(40)); + audio_config.delay = Some(std::time::Duration::from_millis(80)); let mut audio_renditions = BTreeMap::new(); audio_renditions.insert("audio0".to_string(), audio_config); @@ -1631,12 +1645,14 @@ mod test { assert_eq!(video.max_grp_sap_starting_type, Some(2)); assert_eq!(video.max_obj_sap_starting_type, Some(2)); assert_eq!(video.jitter, Some(std::time::Duration::from_millis(100))); + assert_eq!(video.delay, Some(std::time::Duration::from_millis(200))); let audio = &msf.tracks[1]; assert_eq!(audio.role, Some(moq_msf::Role::Audio)); assert_eq!(audio.max_grp_sap_starting_type, Some(1)); assert_eq!(audio.max_obj_sap_starting_type, Some(1)); assert_eq!(audio.jitter, Some(std::time::Duration::from_millis(40))); + assert_eq!(audio.delay, Some(std::time::Duration::from_millis(80))); } #[test] diff --git a/rs/moq-mux/src/catalog/tracks.rs b/rs/moq-mux/src/catalog/tracks.rs index 6c2c349fdb..91c0670fcc 100644 --- a/rs/moq-mux/src/catalog/tracks.rs +++ b/rs/moq-mux/src/catalog/tracks.rs @@ -190,6 +190,8 @@ pub struct VideoHint { pub optimize_for_latency: Option, /// The maximum jitter before the next frame is emitted. pub jitter: Option, + /// How far this rendition trails the broadcast's earliest rendition. + pub delay: Option, /// The container wrapping each frame on the wire. /// /// Unlike the other fields this is a choice, not a hint: the bitstream never reveals a @@ -224,6 +226,7 @@ impl From for VideoHint { framerate: config.framerate, optimize_for_latency: config.optimize_for_latency, jitter: config.jitter, + delay: config.delay, container: config.container, } } @@ -247,6 +250,7 @@ impl VideoHint { fill(&mut config.framerate, self.framerate); fill(&mut config.optimize_for_latency, self.optimize_for_latency); fill(&mut config.jitter, self.jitter); + fill(&mut config.delay, self.delay); config.container = self.container.clone(); } @@ -276,11 +280,15 @@ impl RenditionConfig for hang::catalog::VideoConfig { } fn estimate(&self) -> Estimate { - Estimate::default().with_jitter(self.jitter).with_bitrate(self.bitrate) + Estimate::default() + .with_jitter(self.jitter) + .with_bitrate(self.bitrate) + .with_delay(self.delay) } fn set_estimate(&mut self, estimate: Estimate) { self.jitter = estimate.jitter; self.bitrate = estimate.bitrate; + self.delay = estimate.delay; } } @@ -300,11 +308,15 @@ impl RenditionConfig for hang::catalog::AudioConfig { } fn estimate(&self) -> Estimate { - Estimate::default().with_jitter(self.jitter).with_bitrate(self.bitrate) + Estimate::default() + .with_jitter(self.jitter) + .with_bitrate(self.bitrate) + .with_delay(self.delay) } fn set_estimate(&mut self, estimate: Estimate) { self.jitter = estimate.jitter; self.bitrate = estimate.bitrate; + self.delay = estimate.delay; } } @@ -319,10 +331,11 @@ impl RenditionConfig for hang::catalog::TextConfig { catalog.text.renditions.remove(name); } fn estimate(&self) -> Estimate { - Estimate::default().with_jitter(self.jitter) + Estimate::default().with_jitter(self.jitter).with_delay(self.delay) } fn set_estimate(&mut self, estimate: Estimate) { self.jitter = estimate.jitter; + self.delay = estimate.delay; } } @@ -518,6 +531,11 @@ impl> Rendition { &self.name } + /// A fresh estimator measuring `delay` against the catalog's other renditions. + pub(crate) fn estimator(&self) -> super::Estimator { + self.catalog.estimator() + } + /// Resolve a timestamp on the broadcast's shared clock (see [`Producer::timestamp`]). pub fn timestamp(&self, hint: Option) -> crate::Result { self.catalog.timestamp(hint) @@ -533,7 +551,7 @@ impl> Rendition { pub(crate) fn set(&mut self, mut config: C) -> crate::Result<()> { let supplied = config.estimate(); let resolved = Self::resolved(&supplied, &self.detected); - self.check_jitter(&resolved)?; + self.check_decrease(&resolved)?; config.set_estimate(resolved.clone()); { let mut guard = self.catalog.modify()?; @@ -560,6 +578,9 @@ impl> Rendition { if estimate.bitrate.is_none() { estimate.bitrate = detected.bitrate; } + if estimate.delay.is_none() { + estimate.delay = detected.delay; + } estimate } @@ -581,10 +602,11 @@ impl> Rendition { return Ok(()); } let mut resolved = Self::resolved(&self.supplied, &estimate); - // A measurement never lowers the published jitter, including one raised through `modify`. - if let Some(published) = self.config()?.estimate().jitter { - resolved.jitter = Some(resolved.jitter.map_or(published, |jitter| jitter.max(published))); - } + // A measurement never lowers the published jitter or delay, including one raised through + // `modify`. + let published = self.config()?.estimate(); + resolved.jitter = resolved.jitter.max(published.jitter); + resolved.delay = resolved.delay.max(published.delay); self.detected = estimate; if self.published.as_ref() != Some(&resolved) { let mut config = self.config()?; @@ -595,13 +617,18 @@ impl> Rendition { Ok(()) } - fn check_jitter(&self, next: &Estimate) -> crate::Result<()> { - if self.present - && let Some(previous) = self.config()?.estimate().jitter - && next.jitter.is_none_or(|jitter| jitter < previous) - { + /// Refuse a config that lowers the jitter or delay already advertised to subscribers. + fn check_decrease(&self, next: &Estimate) -> crate::Result<()> { + if !self.present { + return Ok(()); + } + let previous = self.config()?.estimate(); + if next.jitter < previous.jitter { return Err(crate::Error::JitterDecreased); } + if next.delay < previous.delay { + return Err(crate::Error::DelayDecreased); + } Ok(()) } @@ -620,7 +647,7 @@ impl> Rendition { if !self.present { return Err(crate::Error::NotPublished); } - self.check_jitter(&config.estimate())?; + self.check_decrease(&config.estimate())?; let mut guard = self.catalog.modify()?; let mut next = (*guard).clone(); config.insert(&mut next, &self.name); @@ -723,6 +750,45 @@ mod tests { ); } + #[test] + fn published_delay_never_decreases() { + let (_broadcast, catalog, mut rendition) = video_track(); + let delayed = |delay| { + let mut config = config(None, None); + config.delay = delay; + config + }; + rendition.set(delayed(Some(Duration::from_millis(200)))).unwrap(); + for smaller in [Some(Duration::from_millis(100)), None] { + assert!(matches!( + rendition.set(delayed(smaller)), + Err(crate::Error::DelayDecreased) + )); + assert!(matches!( + rendition.replace(delayed(smaller)), + Err(crate::Error::DelayDecreased) + )); + } + assert_eq!( + catalog.snapshot().video.renditions["v"].delay, + Some(Duration::from_millis(200)) + ); + + let (_broadcast, catalog, mut detected) = video_track(); + detected.set(config(None, None)).unwrap(); + detected + .estimate(Estimate::default().with_delay(Duration::from_millis(200))) + .unwrap(); + // A lower measurement holds the published value rather than failing the write path. + detected + .estimate(Estimate::default().with_delay(Duration::from_millis(100))) + .unwrap(); + assert_eq!( + catalog.snapshot().video.renditions["v"].delay, + Some(Duration::from_millis(200)) + ); + } + #[test] fn measurement_keeps_a_jitter_raised_by_modify() { let (_broadcast, catalog, mut rendition) = video_track(); diff --git a/rs/moq-mux/src/container/producer.rs b/rs/moq-mux/src/container/producer.rs index fa18792c30..abdd85fd72 100644 --- a/rs/moq-mux/src/container/producer.rs +++ b/rs/moq-mux/src/container/producer.rs @@ -187,7 +187,7 @@ where previous_timestamp: None, cadence: None, reordered: false, - estimator: crate::catalog::Estimator::new(), + estimator: rendition.estimator(), bandwidth: None, rendition: Some(Box::new(rendition)), } @@ -202,7 +202,7 @@ where /// /// Estimate fields the config left to detection at [`set`](Self::set) stay owned by detection: /// an edit to them here is published but replaced by the next measurement, except that jitter - /// never drops below the published value. Call `set` with the field filled in to pin it. + /// and delay never drop below the published value. Call `set` with the field filled in to pin it. pub fn modify(&mut self) -> crate::Result> { let rendition = self.rendition.as_mut().ok_or(crate::Error::NotPublished)?; let config = rendition.config()?; @@ -794,7 +794,7 @@ mod tests { } #[test] - fn catalog_flush_measures_each_rendition_against_its_own_minimum() { + fn catalog_flush_measures_delay_across_renditions_and_jitter_within_each() { let mut broadcast = moq_net::broadcast::Info::new().produce(); let catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap(); let mut tracks = Vec::new(); @@ -814,9 +814,11 @@ mod tests { let ms = std::time::Duration::from_millis; let pts = |millis: u64| Timestamp::from_micros(millis * 1_000).unwrap(); tracks[0].flush(pts(0), anchor).unwrap(); - // A constant 200ms offset behind the other rendition is not jitter. + // A constant 200ms offset behind the other rendition is delay, not jitter. tracks[1].flush(pts(0), anchor + ms(200)).unwrap(); assert_eq!(catalog.snapshot().video.renditions["slow"].jitter, None); + assert_eq!(catalog.snapshot().video.renditions["slow"].delay, Some(ms(200))); + assert_eq!(catalog.snapshot().video.renditions["fast"].delay, None); // A frame flushed 60ms later than the slow rendition's own minimum is. tracks[1].flush(pts(40), anchor + ms(300)).unwrap(); diff --git a/rs/moq-mux/src/error.rs b/rs/moq-mux/src/error.rs index a0a50989da..9603dfa5e5 100644 --- a/rs/moq-mux/src/error.rs +++ b/rs/moq-mux/src/error.rs @@ -241,6 +241,10 @@ pub enum Error { /// A rendition tried to lower jitter already advertised to subscribers. #[error("catalog jitter cannot decrease for a published rendition")] JitterDecreased, + + /// A rendition tried to lower delay already advertised to subscribers. + #[error("catalog delay cannot decrease for a published rendition")] + DelayDecreased, } impl Error { diff --git a/rs/moq-video/src/encode/producer.rs b/rs/moq-video/src/encode/producer.rs index dd76386bcd..998e7dbf1c 100644 --- a/rs/moq-video/src/encode/producer.rs +++ b/rs/moq-video/src/encode/producer.rs @@ -663,10 +663,10 @@ mod tests { producer.publish(&encoder.finish().unwrap()).unwrap(); let (name, resolved) = rendition(&catalog).expect("the importer should have registered a video rendition"); - // Jitter aside, which is measured from the frames rather than declared by either. + // Jitter and delay aside, which are measured from the frames rather than declared by either. let (mut before, mut after) = (advertised, resolved.clone()); - before.jitter = None; - after.jitter = None; + (before.jitter, before.delay) = (None, None); + (after.jitter, after.delay) = (None, None); assert_eq!( before, after, "the first keyframe should confirm the advertised rendition, not correct it" From c280683e9985644bdf9d053d5b3b713ee8c99d21 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 21:30:44 -0700 Subject: [PATCH 28/45] chore(claude): share skills through kixelated/skills, tighten quest skills (#4227) Co-authored-by: Claude Opus 5.5 Co-authored-by: Grok 4.7 --- .claude/hooks/submodule.sh | 20 +++++++++++++ .claude/settings.json | 4 +++ .claude/shared | 1 + .claude/skills/bump | 1 + .claude/skills/clean | 1 + .claude/skills/close | 1 + .claude/skills/close/SKILL.md | 15 ---------- .claude/skills/close/agents/openai.yaml | 4 --- .claude/skills/grill | 1 + .claude/skills/merge | 1 + .claude/skills/merge/SKILL.md | 18 ------------ .claude/skills/merge/agents/openai.yaml | 4 --- .claude/skills/peer-review | 1 + .claude/skills/plan-quests/SKILL.md | 3 +- .claude/skills/recommended | 1 + .claude/skills/spawn-merge | 1 + .claude/skills/spawn-merge/SKILL.md | 28 ------------------- .claude/skills/spawn-merge/agents/openai.yaml | 4 --- .claude/skills/spawn-quests/SKILL.md | 9 ++++-- .claude/skills/takeover | 1 + .claude/skills/takeover/SKILL.md | 16 ----------- .claude/skills/takeover/agents/openai.yaml | 4 --- .gitmodules | 3 ++ .remarkignore | 7 +++++ 24 files changed, 52 insertions(+), 97 deletions(-) create mode 100755 .claude/hooks/submodule.sh create mode 160000 .claude/shared create mode 120000 .claude/skills/bump create mode 120000 .claude/skills/clean create mode 120000 .claude/skills/close delete mode 100644 .claude/skills/close/SKILL.md delete mode 100644 .claude/skills/close/agents/openai.yaml create mode 120000 .claude/skills/grill create mode 120000 .claude/skills/merge delete mode 100644 .claude/skills/merge/SKILL.md delete mode 100644 .claude/skills/merge/agents/openai.yaml create mode 120000 .claude/skills/peer-review create mode 120000 .claude/skills/recommended create mode 120000 .claude/skills/spawn-merge delete mode 100644 .claude/skills/spawn-merge/SKILL.md delete mode 100644 .claude/skills/spawn-merge/agents/openai.yaml create mode 120000 .claude/skills/takeover delete mode 100644 .claude/skills/takeover/SKILL.md delete mode 100644 .claude/skills/takeover/agents/openai.yaml create mode 100644 .gitmodules diff --git a/.claude/hooks/submodule.sh b/.claude/hooks/submodule.sh new file mode 100755 index 0000000000..cda3fd4ace --- /dev/null +++ b/.claude/hooks/submodule.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# The shared skills in .claude/skills are symlinks into the .claude/shared +# submodule. git leaves that unpopulated in a new worktree, and a dangling +# symlink is silently skipped. A checkout on the wrong commit stays stale too. + +set -eu + +# Require an explicit project dir so we never touch a stray checkout. +[ -n "${CLAUDE_PROJECT_DIR:-}" ] || exit 0 +cd "$CLAUDE_PROJECT_DIR" || exit 0 + +# '-' is uninitialized. '+' is populated at a commit other than the gitlink. +# A matching checkout has no prefix. +case "$(git submodule status .claude/shared 2>/dev/null)" in + -* | +*) ;; + *) exit 0 ;; +esac + +echo "submodule hook: updating .claude/shared to the pinned skills." >&2 +git submodule update --init .claude/shared >&2 diff --git a/.claude/settings.json b/.claude/settings.json index 954f580759..29502e7553 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,6 +4,10 @@ "SessionStart": [ { "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/submodule.sh" + }, { "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/direnv.sh" diff --git a/.claude/shared b/.claude/shared new file mode 160000 index 0000000000..763fb4c986 --- /dev/null +++ b/.claude/shared @@ -0,0 +1 @@ +Subproject commit 763fb4c9865a0625716de8cde80d4af1b5edcef7 diff --git a/.claude/skills/bump b/.claude/skills/bump new file mode 120000 index 0000000000..c99bb07978 --- /dev/null +++ b/.claude/skills/bump @@ -0,0 +1 @@ +../shared/bump \ No newline at end of file diff --git a/.claude/skills/clean b/.claude/skills/clean new file mode 120000 index 0000000000..8bf0f1985a --- /dev/null +++ b/.claude/skills/clean @@ -0,0 +1 @@ +../shared/clean \ No newline at end of file diff --git a/.claude/skills/close b/.claude/skills/close new file mode 120000 index 0000000000..31284b7508 --- /dev/null +++ b/.claude/skills/close @@ -0,0 +1 @@ +../shared/close \ No newline at end of file diff --git a/.claude/skills/close/SKILL.md b/.claude/skills/close/SKILL.md deleted file mode 100644 index 24cb8a7619..0000000000 --- a/.claude/skills/close/SKILL.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: close -description: Close a GitHub PR. ---- - -Abandon the specified PR. - -If this PR was completing a quest, takeover the PR to delete the quest instead (without the other changes). -If this PR was not completing a quest, close the PR as-is. - -After GitHub confirms the PR is merged or closed, run `just clean` in its local -checkout if that recipe exists, once this task's checks and builds have stopped. -Enabling auto-merge is not completion. Preserve another task's active processes; -report a refused or failed cleanup without changing the PR outcome. Leave the -checkout itself for the host's worktree cleanup timer to retire after you exit. diff --git a/.claude/skills/close/agents/openai.yaml b/.claude/skills/close/agents/openai.yaml deleted file mode 100644 index 8b363520a1..0000000000 --- a/.claude/skills/close/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Close PR" - short_description: "Close a GitHub PR" - default_prompt: "Use $close to close this PR." diff --git a/.claude/skills/grill b/.claude/skills/grill new file mode 120000 index 0000000000..a27dcd72e5 --- /dev/null +++ b/.claude/skills/grill @@ -0,0 +1 @@ +../shared/grill \ No newline at end of file diff --git a/.claude/skills/merge b/.claude/skills/merge new file mode 120000 index 0000000000..4735484baf --- /dev/null +++ b/.claude/skills/merge @@ -0,0 +1 @@ +../shared/merge \ No newline at end of file diff --git a/.claude/skills/merge/SKILL.md b/.claude/skills/merge/SKILL.md deleted file mode 100644 index aab5a0dda6..0000000000 --- a/.claude/skills/merge/SKILL.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: merge -description: Merge a GitHub PR once reviews and CI pass. ---- - -Land a pull request. -If unsure about any course of action, pause and interactively prompt the user for guidance. - -- Parse the arguments to determine the PR number, otherwise resolve it from the current context/branch. -- Fix any minor issues with the PR, such as merge conflicts and failing CI checks. -- If a draft, flip it to "Ready for Review" then wait for at least one automated review to complete. -- Fix any review feedback you agree with, and leave a comment if you disagree. - -If everything looks good, enable auto-merge and post a summary of the changes made. -You don't need to address everything. - -You may repeat any of the above steps on new CI results or review feedback. -Abort on any major issues or blockers, or if little progress is being made. diff --git a/.claude/skills/merge/agents/openai.yaml b/.claude/skills/merge/agents/openai.yaml deleted file mode 100644 index 44df0dd02d..0000000000 --- a/.claude/skills/merge/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Merge" - short_description: "Land a GitHub PR once reviews and CI pass" - default_prompt: "Use $merge to review and land this pull request." diff --git a/.claude/skills/peer-review b/.claude/skills/peer-review new file mode 120000 index 0000000000..7abac91d3d --- /dev/null +++ b/.claude/skills/peer-review @@ -0,0 +1 @@ +../shared/peer-review \ No newline at end of file diff --git a/.claude/skills/plan-quests/SKILL.md b/.claude/skills/plan-quests/SKILL.md index 887a0691c7..0f55d0a72f 100644 --- a/.claude/skills/plan-quests/SKILL.md +++ b/.claude/skills/plan-quests/SKILL.md @@ -26,7 +26,7 @@ When the work changes what a user sees (a wire, an API, a flag, a dashboard), as When the frontier disagrees with a settled quest/plan, challenge the user and resolve the conflict. Begin the interview by scoping the goal: the observable outcome, why it matters, and its important boundaries and non-goals. -Do not move on to implementation decisions until the goal is settled. +Restate the goal in one sentence and get it confirmed before moving on to implementation decisions. If the goal contains independently completable outcomes, split them before planning. Map the implementation plan as a design tree: every material decision branches into the decisions that hang off it. @@ -34,6 +34,7 @@ The session is done when the frontier is empty. The result may be one quest or multiple quests and questlines, split based on what can be completed independently. Prefix each quest title with `[XS]`, `[S]`, `[M]`, `[L]`, or `[XL]`, including implementation, verification, and landing work. Once complete, create, update, or delete the relevant quests and questlines. +Record each settled decision and its reason in the quest's Plan, so later sessions don't ask it again. New work joins the milestone matching its priority, at its rank; a questline groups only quests that ship together, and its README holds the work no child owns (the end-to-end test, the docs page). When done, commit and create a draft PR following `CONTRIBUTING.md`. diff --git a/.claude/skills/recommended b/.claude/skills/recommended new file mode 120000 index 0000000000..440251611f --- /dev/null +++ b/.claude/skills/recommended @@ -0,0 +1 @@ +../shared/recommended \ No newline at end of file diff --git a/.claude/skills/spawn-merge b/.claude/skills/spawn-merge new file mode 120000 index 0000000000..669e016de4 --- /dev/null +++ b/.claude/skills/spawn-merge @@ -0,0 +1 @@ +../shared/spawn-merge \ No newline at end of file diff --git a/.claude/skills/spawn-merge/SKILL.md b/.claude/skills/spawn-merge/SKILL.md deleted file mode 100644 index 94e4ede39b..0000000000 --- a/.claude/skills/spawn-merge/SKILL.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: spawn-merge -description: Decide and merge GitHub PRs in parallel. ---- - -Read the /merge, /takeover, and /close skills before starting. - -The goal is to evaluate the open PRs in the repository and decide which ones to merge. -Each merge is performed in parallel by a sub-agent. - -Start by listing all open PRs that are ready for review (skip drafts). -An argument can be used to filter the PRs in scope. - -One at a time, for each PR, interactively prompt the user if we should /merge, skip, or /close. -Include a short summary and your recommendation. - -If the user chooses to merge the PR, run the /merge command using a sub-agent. -There can be at most N concurrent merge operations, where N is half the number of physical CPU cores. -If ordering matters, then queue the PRs in order. - -If the user chooses to close the PR, run the /close command using a sub-agent. - -Keep going until all PRs have been decided then wait for all spawned sub-agents to finish. -Before finishing, refresh the open PR list and process any newly opened PRs that match the original scope. - -Summarize the results when done. -Include all of the issues encountered and suggested follow-ups. -Repeat the process for any newly opened PRs that match the original scope. diff --git a/.claude/skills/spawn-merge/agents/openai.yaml b/.claude/skills/spawn-merge/agents/openai.yaml deleted file mode 100644 index 7ad171c371..0000000000 --- a/.claude/skills/spawn-merge/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Spawn Merge" - short_description: "Land multiple GitHub PRs once reviews and CI pass" - default_prompt: "Use $spawn_merge to review and land these pull requests." diff --git a/.claude/skills/spawn-quests/SKILL.md b/.claude/skills/spawn-quests/SKILL.md index 6ade2b5d88..d8782dd12b 100644 --- a/.claude/skills/spawn-quests/SKILL.md +++ b/.claude/skills/spawn-quests/SKILL.md @@ -15,15 +15,18 @@ Judge readiness from each line branch's own quest directory, and treat a quest d Inspect any blocked quests, and determine if they can be unblocked. A line whose `Quests` list has emptied is a ready quest too: finishing it marks the line's PR ready. -One at a time, for each each quest, interactively prompt the user if we should /start-quest, /plan-quests, skip, or delete. -Include a short summary and your recommendation. +Recommend an action for each quest: /start-quest, /plan-quests, skip, or delete. +Start the quests you'd start with no open question right away. +Interactively prompt the user about the rest, a few per prompt, each with a short summary and your recommendation. Spawn a background sub-agent for each /start-quest. Create a fresh worktree on the base `quest branch` prints, creating that line branch first if it is missing. Agents share no writable files: each keeps its scratch files in its own worktree's `.scratch/`, and anything you hand every agent goes in its prompt, not a shared file. +Each agent blocks on its own checks and reports back only when done or blocked. Limit the concurrency to at most N agents in parallel, where N is half the number of physical CPU cores. +Other sessions share this machine: hold new agents while the load average exceeds the core count. -Monitor the sub-agents and report their final status, but do not monitor their PRs. +Report each sub-agent's final status, staying silent on interim notifications, but do not monitor their PRs. Prompt the user if they want to /plan-quests for any suggested follow-ups. Run /plan-quests for any selected quests in the foreground. diff --git a/.claude/skills/takeover b/.claude/skills/takeover new file mode 120000 index 0000000000..f004a334ef --- /dev/null +++ b/.claude/skills/takeover @@ -0,0 +1 @@ +../shared/takeover \ No newline at end of file diff --git a/.claude/skills/takeover/SKILL.md b/.claude/skills/takeover/SKILL.md deleted file mode 100644 index 1ad2afced6..0000000000 --- a/.claude/skills/takeover/SKILL.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -name: takeover -description: Adopt someone else's open PR and drive it to landable. ---- - -Someone else opened this PR; you are now responsible for it. -If unsure about any course of action, prompt the user for guidance. - -- Parse the arguments to determine the PR number. -- Read the PR, any linked issues, reviews/comments posted on the PR, and the surrounding code. -- Judge the approach before touching anything. Would you take a different approach? -- Fix any issues with the PR, such as merge conflicts and failing CI checks. -- Address any automated review findings (Codex/CodeRabbit) you agree with. Turn down any you disagree with with a comment. One round only; never request a review. -- Push any changes you made to the PR, updating the summary if needed. -- Summarize the changes made and any potential follow-up actions. -- Do not merge the PR; that is the `merge` skill's job, and only once the user approves. diff --git a/.claude/skills/takeover/agents/openai.yaml b/.claude/skills/takeover/agents/openai.yaml deleted file mode 100644 index 8f684a245b..0000000000 --- a/.claude/skills/takeover/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Takeover" - short_description: "Adopt someone else's PR and drive it to landable" - default_prompt: "Use $takeover to adopt this pull request and make it landable." diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..25cd0e0737 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule ".claude/shared"] + path = .claude/shared + url = https://github.com/kixelated/skills diff --git a/.remarkignore b/.remarkignore index 62cd171e1f..5716b01a44 100644 --- a/.remarkignore +++ b/.remarkignore @@ -15,3 +15,10 @@ doc/draft/ # Quests use a constrained Markdown dialect checked by the quest validator. quest/ + +# Skills are symlinks into the kixelated/skills submodule, which formats its +# own files; remark would follow them (and .agents, Codex's link to the same +# directory) and rewrite the pinned checkout. +.claude/shared/ +.claude/skills/ +.agents/ From ea7db013bc91272d699148d1c0a957e96081a7c4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 25 Sep 2026 22:14:11 -0700 Subject: [PATCH 29/45] perf(kio): keep a parked waiter that quiet lists still hold (#4240) Co-authored-by: Claude Opus 5.5 --- quest/m1/perf/group-cost.md | 10 +- rs/kio/Cargo.toml | 2 +- rs/kio/benches/waiter.rs | 4 +- rs/kio/src/loom.rs | 5 +- rs/kio/src/waiter.rs | 260 +++++++++++++++++++++++++++++--- rs/kio/tests/waiter_allocs.rs | 45 +++++- rs/moq-net/benches/origin.rs | 7 +- rs/moq-net/benches/session.rs | 159 ++++++++++++++++++- rs/moq-net/src/coding/reader.rs | 2 +- 9 files changed, 445 insertions(+), 49 deletions(-) diff --git a/quest/m1/perf/group-cost.md b/quest/m1/perf/group-cost.md index cec74b3338..a716f8889e 100644 --- a/quest/m1/perf/group-cost.md +++ b/quest/m1/perf/group-cost.md @@ -17,9 +17,13 @@ quote). No single function dominates. An earlier Linux profile spread it over `k waker (`Once::call`, about 3%, so waiters are created per poll), and malloc/free (10-12%). -Count allocations per viewer-group first (a counting allocator in the bench, -as `moq-json`'s allocation bench does), then remove the largest sources. A -measured no-win abandons the quest, per this line's rules. +`SESSION_ALLOCS=1` makes the session bench print allocations per +viewer-group. The largest source, a fresh `Arc` whenever a partial +wake made `kio::Park` retire a still-registered waiter, is gone: kio now +keeps that waiter and skips re-registering on lists that still hold it +(2026-09: 228 to 122 allocations per viewer-group, 352 to 118 paced). Find +the next largest source from there. A measured no-win abandons the quest, +per this line's rules. ## Related diff --git a/rs/kio/Cargo.toml b/rs/kio/Cargo.toml index ae815a85d6..aed9d6849d 100644 --- a/rs/kio/Cargo.toml +++ b/rs/kio/Cargo.toml @@ -13,7 +13,7 @@ keywords = ["async", "producer", "consumer", "state", "sync"] categories = ["asynchronous", "concurrency"] [dependencies] -smallvec = "1.15" +smallvec = { version = "1.15", features = ["union"] } # Loom swaps in its own Arc/Mutex/atomics so it can permute every interleaving. # It's a normal (not dev) dependency because `sync.rs` re-exports it from the diff --git a/rs/kio/benches/waiter.rs b/rs/kio/benches/waiter.rs index 821dbc2d0b..9fb08a8cd4 100644 --- a/rs/kio/benches/waiter.rs +++ b/rs/kio/benches/waiter.rs @@ -70,7 +70,7 @@ fn bench_register(c: &mut Criterion) { g.finish(); } -/// Re-poll with a still-registered waiter, including retirement and slot reuse. +/// Re-poll with a still-registered waiter, which the list recognizes and skips. fn bench_reregister(c: &mut Criterion) { let mut g = c.benchmark_group("waiter_reregister"); for &n in &SIZES { @@ -118,7 +118,7 @@ fn bench_cancel(c: &mut Criterion) { /// The cycle a poll function actually drives: hold the park, register with L lists, /// then one list wakes. The re-poll arrives with live registrations still parked on -/// the other lists, requiring retirement before re-registration. +/// the other lists, which it keeps, re-registering only on the list that woke. fn bench_park_cycle(c: &mut Criterion) { let mut g = c.benchmark_group("waiter_park_cycle"); // Include broad polls that remain parked on many quiet lists. diff --git a/rs/kio/src/loom.rs b/rs/kio/src/loom.rs index 9695abbc5d..67dcdc2bce 100644 --- a/rs/kio/src/loom.rs +++ b/rs/kio/src/loom.rs @@ -289,8 +289,9 @@ fn queue_close_wakes_a_parked_pop() { }); } -/// A partial wake retires the waiter still parked on the quiet list. The new -/// registration must hear a terminal wake on the drained list or the task hangs. +/// A partial wake re-polls a waiter still parked on the quiet list. Its registration +/// on the drained list must be real, not skipped as a duplicate, or the terminal +/// wake is lost and the task hangs. #[test] fn a_replaced_waiter_hears_the_drained_list_again() { loom::model(|| { diff --git a/rs/kio/src/waiter.rs b/rs/kio/src/waiter.rs index 81a66ba48e..1d3755b138 100644 --- a/rs/kio/src/waiter.rs +++ b/rs/kio/src/waiter.rs @@ -5,7 +5,10 @@ use std::{ pin::Pin, // std, not `crate::sync`: loom's Arc has no `downgrade`, and `Waker::from` takes // std's. See `sync.rs`. - sync::{Arc, OnceLock, Weak}, + sync::{ + Arc, OnceLock, Weak, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, task::{Context, Poll, Wake, Waker}, }; @@ -37,14 +40,28 @@ pub struct Waiter { // first `register` (a poll that never parks never allocates it), then reused so multiple // lists in one poll share a single allocation whose `Weak`s die together when the waiter drops. shared: OnceLock>, + + // The list tag each registration was made under, so registering on a list that + // still holds this waiter is a no-op. Filled from the front, zero past the end, + // and cleared all at once. + parked: [AtomicU64; PARKED], + + // A registration found no free slot in `parked`, so a list may hold this waiter + // unrecorded and a later registration there would stack a duplicate. + lost: AtomicBool, } +/// Lists a waiter remembers being parked on. A relay's busiest tasks park on up to 8. +const PARKED: usize = 8; + impl Waiter { /// Create a new waiter from an async [`Waker`]. pub fn new(waker: Waker) -> Self { Self { waker, shared: OnceLock::new(), + parked: Default::default(), + lost: AtomicBool::new(false), } } @@ -55,7 +72,7 @@ impl Waiter { /// Register this waiter with a [`WaiterList`] for future notification. /// - /// Delegates to [`WaiterList::register`], which is not idempotent. + /// Delegates to [`WaiterList::register`], a no-op while the list still holds it. pub fn register(&self, list: &mut WaiterList) { list.register(self); } @@ -72,6 +89,63 @@ impl Waiter { self.shared.get_or_init(|| Arc::new(self.waker.clone())) } + /// Record a registration under `tag`, or report one already recorded there. + /// + /// Returns whether the list needs an entry: false only when a record proves it + /// still holds this waiter. A record of an older round of the same list is + /// replaced, since the drain that ended that round took the entry with it. + fn record(&self, tag: u64) -> bool { + // Retiring anyway, so skip the bookkeeping: duplicates die with the waiter. + if self.lost.load(Ordering::Relaxed) { + return true; + } + + // The common case, kept a tight loop of its own. + if self.parked.iter().any(|slot| slot.load(Ordering::Relaxed) == tag) { + return false; + } + + let serial = tag >> ROUND_BITS; + for slot in &self.parked { + let old = slot.load(Ordering::Relaxed); + // The first empty slot ends the records. A record of the same list in an + // older round is stale: the drain that ended that round took the entry. + if old >> ROUND_BITS == serial { + // Only a registration on this list, under its lock, writes this slot. + slot.store(tag, Ordering::Relaxed); + return true; + } + // Claimed, not stored: another thread registering this waiter on another + // list may be taking the same empty slot. + if old == 0 + && slot + .compare_exchange(0, tag, Ordering::Relaxed, Ordering::Relaxed) + .is_ok() + { + return true; + } + } + self.lost.store(true, Ordering::Relaxed); + true + } + + /// Whether every list holding this waiter is recorded, so registering it again + /// cannot stack a duplicate. + fn reusable(&self) -> bool { + if !self.lost.load(Ordering::Relaxed) { + return true; + } + if self.shared.get().is_some_and(|shared| Arc::weak_count(shared) > 0) { + return false; + } + // Every list let go, so every record is stale: start over. + for slot in &self.parked { + slot.store(0, Ordering::Relaxed); + } + self.lost.store(false, Ordering::Relaxed); + true + } + /// Poll a foreign [`Future`] against this waiter, so it re-wakes the enclosing /// `poll_*` step when it is ready. pub fn poll_future(&self, future: Pin<&mut F>) -> Poll { @@ -88,6 +162,8 @@ impl Clone for Waiter { Self { waker: self.waker.clone(), shared: OnceLock::from(shared), + parked: std::array::from_fn(|i| AtomicU64::new(self.parked[i].load(Ordering::Relaxed))), + lost: AtomicBool::new(self.lost.load(Ordering::Relaxed)), } } } @@ -102,6 +178,37 @@ pub struct WaiterList { entries: SmallVec<[Weak; INLINE_WAITERS]>, /// Rotating cursor for opportunistic GC on `register`. cursor: usize, + /// A serial unique to this list in the high bits and a drain count in the low + /// [`ROUND_BITS`], so an unchanged tag proves an entry made under it is still + /// here. Zero until the first registration. + tag: u64, +} + +/// Low bits of a list tag that count drains. A wrap takes a fresh serial instead, +/// so no tag is ever reused. +const ROUND_BITS: u32 = 16; + +/// A serial for a list tag, unique for the life of the process. Handed out in +/// per-thread blocks so lists created on many threads don't share a cache line. +fn serial() -> u64 { + use std::cell::Cell; + + const BLOCK: u64 = 1024; + static NEXT: AtomicU64 = AtomicU64::new(1); + thread_local! { + static RANGE: Cell<(u64, u64)> = const { Cell::new((0, 0)) }; + } + + RANGE.with(|range| { + let (mut next, mut end) = range.get(); + if next == end { + next = NEXT.fetch_add(BLOCK, Ordering::Relaxed); + end = next + BLOCK; + assert!(end < 1 << (64 - ROUND_BITS), "waiter list serials exhausted"); + } + range.set((next + 1, end)); + next << ROUND_BITS + }) } impl WaiterList { @@ -110,22 +217,28 @@ impl WaiterList { Self { entries: SmallVec::new(), cursor: 0, + tag: 0, } } - /// Register a waiter. + /// Register a waiter, a no-op while this list still holds it. /// - /// Not idempotent: a waiter already in the list is appended again. - /// [`Park::hold`] retires any waiter that still has live registrations - /// before the next poll, so the in-tree poll path never stacks - /// duplicates. Callers that retain a waiter across polls must drop it - /// before registering again. + /// The list's tag changes on every drain and the waiter records the tag it + /// joined under, so a matching record proves the entry is still here and a + /// waiter kept across polls re-registers for free. /// /// Each call probes at most two slots at the rotating cursor and reuses /// a dead one in place. The cursor advances on each live probe so the /// window covers the list over time. A list about to grow sweeps every /// dead slot first. pub fn register(&mut self, waiter: &Waiter) { + if self.tag == 0 { + self.tag = serial(); + } + if !waiter.record(self.tag) { + return; + } + let new_weak = Arc::downgrade(waiter.shared()); for _ in 0..self.entries.len().min(2) { @@ -152,18 +265,34 @@ impl WaiterList { self.entries.push(new_weak); } + /// Start a new round, so no record of an entry made in the last one matches. + fn drained(&mut self) { + self.cursor = 0; + if self.tag == 0 || self.entries.is_empty() { + // No serial yet (a `take` snapshot, say) or no entry: no record can match + // the current tag, and a zero tag must stay zero to draw a fresh serial. + return; + } + self.tag += 1; + if self.tag & ((1 << ROUND_BITS) - 1) == 0 { + // The round wrapped: take a fresh serial rather than reuse a tag. + self.tag = 0; + } + } + /// Drain all entries into a new [`WaiterList`], leaving this one empty. pub fn take(&mut self) -> Self { - self.cursor = 0; + self.drained(); Self { entries: std::mem::take(&mut self.entries), cursor: 0, + tag: 0, } } /// Wake all live waiters, draining the list. pub fn wake(&mut self) { - self.cursor = 0; + self.drained(); for waker in self.entries.drain(..).filter_map(|w| w.upgrade()) { waker.wake_by_ref(); } @@ -232,16 +361,21 @@ impl Park { /// (the usual `ready!` on a nested poll) still leaves its registrations live. /// There is no second call to forget. /// - /// The held waiter is reused when it would wake the same task *and* has no live - /// list registrations (the usual case after a wakeup, which drains every entry), - /// so a steady-state park allocates nothing. Otherwise it is retired for a fresh - /// one: a still-registered waiter must not be registered again, because - /// [`WaiterList`] reclaims a slot only once its `Arc` dies, so reusing one with - /// live entries would stack duplicates the list could never collect. + /// The held waiter is reused when it would wake the same task, so a steady-state + /// park allocates nothing even when one list woke it and others still hold it: + /// registering again on those is a no-op (see [`WaiterList::register`]). It is + /// retired for a fresh one when the task changed, or when it parked on more lists + /// than it can record while some still hold it, since re-registering there could + /// stack duplicates a list never collects: it reclaims a slot only once its `Arc` + /// dies. + /// + /// A reused waiter keeps the registrations its next poll does not renew, so a + /// list it has moved on from may wake it once more, spuriously. pub fn hold(&mut self, cx: &Context<'_>) -> &Waiter { - let reuse = self.0.as_ref().is_some_and(|waiter| { - cx.waker().will_wake(&waiter.waker) && waiter.shared.get().is_none_or(|shared| Arc::weak_count(shared) == 0) - }); + let reuse = self + .0 + .as_ref() + .is_some_and(|waiter| cx.waker().will_wake(&waiter.waker) && waiter.reusable()); if !reuse { // The outgoing waiter drops here, killing its registrations so the lists // can reclaim those slots. @@ -721,10 +855,8 @@ mod tests { /// anything ever parks. Growing either is invisible at the call site, so bound /// them: a diff that has to raise these numbers should say why. /// - /// A bound and not an equality, because `SmallVec` stores its inline array in a - /// union or a tagged enum depending on whether anything else in the build graph - /// enabled `smallvec/union` (glib and wgpu-hal both do). That moves the list - /// between 48 B and 56 B for reasons that have nothing to do with kio. + /// kio enables `smallvec/union`, which drops the inline array's enum tag, so the + /// list costs the same whatever else is in the build graph. #[test] #[cfg(target_pointer_width = "64")] fn the_list_stays_small() { @@ -831,12 +963,90 @@ mod tests { } #[test] - fn register_appends_a_live_waiter() { + fn register_is_idempotent_until_the_list_drains() { let mut list = WaiterList::new(); let waiter = Waiter::new(Waker::noop().clone()); waiter.register(&mut list); waiter.register(&mut list); - assert_eq!(list.entries.len(), 2, "a live waiter must not dedup"); + assert_eq!(list.entries.len(), 1, "a waiter the list holds must not stack"); + + list.wake(); + waiter.register(&mut list); + assert_eq!(list.entries.len(), 1, "a drained waiter must register again"); + } + + /// The relay's shape: a task parks on a few lists, one of them wakes it, and the + /// rest stay quiet. Retiring the waiter there cost an `Arc` per poll. + #[test] + fn partial_wakes_reuse_the_waiter() { + let waker = Waker::from(Arc::new(Flag::default())); + let cx = Context::from_waker(&waker); + let mut park = Park::default(); + let mut lists: Vec<_> = (0..PARKED).map(|_| WaiterList::new()).collect(); + let first = park.hold(&cx).shared().clone(); + for round in 0..100 { + let waiter = park.hold(&cx); + assert!(Arc::ptr_eq(&first, waiter.shared()), "retired a recorded waiter"); + for list in &mut lists { + waiter.register(list); + } + lists[round % PARKED].wake(); + } + for list in &lists { + assert!(list.entries.len() <= 1, "re-registration stacked a duplicate"); + } + } + + /// The tag moves with the list, so a list that moved still recognizes its waiter. + #[test] + fn a_moved_list_still_holds_its_waiter() { + let waiter = Waiter::new(Waker::noop().clone()); + let mut list = WaiterList::new(); + waiter.register(&mut list); + + let mut moved = std::mem::take(&mut list); + waiter.register(&mut moved); + assert_eq!(moved.entries.len(), 1); + + // The emptied original is a different list now. + waiter.register(&mut list); + assert_eq!(list.entries.len(), 1); + } + + /// A `take` snapshot has no serial, and waking it must not invent one: every + /// snapshot would share it, and a reused one would skip a real registration. + #[test] + fn a_woken_snapshot_draws_a_fresh_serial() { + let waiter = Waiter::new(Waker::noop().clone()); + let mut snapshots = [WaiterList::new(), WaiterList::new()].map(|mut list| { + Waiter::new(Waker::noop().clone()).register(&mut list); + let mut snapshot = list.take(); + snapshot.wake(); + snapshot + }); + for snapshot in &mut snapshots { + waiter.register(snapshot); + assert_eq!(snapshot.entries.len(), 1, "a snapshot skipped a real registration"); + } + } + + /// A tag must never repeat, or a stale record would skip a registration the list + /// no longer holds and the wakeup would be lost. + #[test] + fn a_wrapped_round_takes_a_fresh_serial() { + let stale = Waiter::new(Waker::noop().clone()); + let mut list = WaiterList::new(); + stale.register(&mut list); + list.wake(); + + // Skip ahead to the last round, then drain once more. + list.tag |= (1 << ROUND_BITS) - 1; + let other = Waiter::new(Waker::noop().clone()); + other.register(&mut list); + list.wake(); + + stale.register(&mut list); + assert_eq!(list.entries.len(), 1, "a wrapped round matched a stale record"); } #[test] diff --git a/rs/kio/tests/waiter_allocs.rs b/rs/kio/tests/waiter_allocs.rs index 4f37ba0cb3..32d4f01e9c 100644 --- a/rs/kio/tests/waiter_allocs.rs +++ b/rs/kio/tests/waiter_allocs.rs @@ -7,9 +7,10 @@ use std::alloc::{GlobalAlloc, Layout, System}; use std::cell::Cell; -use std::task::Waker; +use std::sync::Arc; +use std::task::{Context, Wake, Waker}; -use kio::{Waiter, WaiterList}; +use kio::{Park, Waiter, WaiterList}; /// Inline slots in a `WaiterList`, which is private, so this mirrors it. A lower /// real value fails the test rather than passing it quietly. @@ -89,3 +90,43 @@ fn a_small_list_cycles_without_allocating() { fn a_spilled_list_allocates_once_per_wake() { assert_eq!(cycle_allocs(&waiters(INLINE_WAITERS + 1), 100), 100); } + +/// A task parked on several lists that only one of them wakes, as a relay's group +/// stream parks on its frames, its priority, and its subscription. Its park re-polls +/// with the same waiter, so a poll that re-registers everywhere allocates nothing. +#[test] +fn a_partial_wake_reuses_the_parked_waiter() { + // A waker of its own, since `Park` reuses a waiter only for a waker that + // `will_wake` matches, and `Waker::noop()` need not match itself. + struct Task; + #[expect(clippy::manual_noop_waker, reason = "the waker must match itself under will_wake")] + impl Wake for Task { + fn wake(self: Arc) {} + } + + let waker = Waker::from(Arc::new(Task)); + let cx = Context::from_waker(&waker); + let mut park = Park::default(); + let mut woken = WaiterList::new(); + let mut quiet = [WaiterList::new(), WaiterList::new()]; + + let mut poll = || { + let waiter = park.hold(&cx); + woken.register(waiter); + for list in &mut quiet { + list.register(waiter); + } + woken.wake(); + }; + + poll(); + let before = ALLOCS.with(Cell::get); + for _ in 0..100 { + poll(); + } + assert_eq!( + ALLOCS.with(Cell::get) - before, + 0, + "a partial wake re-allocated the waiter" + ); +} diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index ae998dd904..b2cef774cb 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -242,11 +242,8 @@ fn bench_serve_idle(c: &mut Criterion) { }) .collect(); b.iter(|| { - // A fresh waiter per sweep. A live registration keeps the waiter's - // `Weak` in each route's list until it drops, so a waiter reused - // across sweeps would stack one per route per iteration. Production - // retires the parked waiter the same way: `Park::hold` drops a - // still-registered waiter before the next poll registers again. + // A fresh waiter per sweep, so every poll pays a real registration: a + // reused one would find itself still parked on each route and skip it. let waiter = kio::Waiter::noop(); // Nothing is queued, so every poll parks again: the idle sweep. for dynamic in &dynamics { diff --git a/rs/moq-net/benches/session.rs b/rs/moq-net/benches/session.rs index b093dc6db1..947fa10727 100644 --- a/rs/moq-net/benches/session.rs +++ b/rs/moq-net/benches/session.rs @@ -10,6 +10,8 @@ //! as a slope. Unwatched broadcasts stay announced and silent: their cost is the //! route table they occupy, not a publisher writing into its own cache. //! +//! `SESSION_ALLOCS=1` prints allocations per viewer-group instead of timing. +//! //! `session_join_*` times a new viewer connecting, resolving a broadcast, and //! receiving its latest group, swept over what the relays already announce. //! @@ -21,11 +23,13 @@ #[path = "../tests/support/mod.rs"] mod support; +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::time::{Duration, Instant}; use bytes::Bytes; use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; -use moq_net::{Hop, Timestamp, Version, broadcast, cache, origin, track}; +use moq_net::{Hop, Timestamp, Version, broadcast, cache, group, origin, track}; use support::harness::{MockConnectOptions, MockPair, connect_mock}; /// The lite draft production negotiates, the next one (opt-in), and the newest @@ -59,6 +63,8 @@ struct Shape { watch: usize, /// Payload bytes per frame. frame: usize, + /// Write one frame per round, as a live source does, instead of a whole group. + paced: bool, } impl Shape { @@ -71,25 +77,73 @@ impl Shape { viewers: 16, watch: 1, frame: 64, + paced: false, }; fn total(&self) -> usize { self.publishers * self.broadcasts } + /// Frames each group gets per round. + fn frames(&self) -> usize { + if self.paced { 1 } else { FRAMES } + } + /// Payload bytes every viewer reads in one round. fn expected(&self) -> usize { - self.viewers * self.watch * FRAMES * self.frame + self.viewers * self.watch * self.frames() * self.frame } fn id(&self, version: &str) -> String { format!( - "{version}/relays={}/publishers={}/broadcasts={}/viewers={}/watch={}/frame={}", - self.relays, self.publishers, self.broadcasts, self.viewers, self.watch, self.frame + "{version}/relays={}/publishers={}/broadcasts={}/viewers={}/watch={}/frame={}{}", + self.relays, + self.publishers, + self.broadcasts, + self.viewers, + self.watch, + self.frame, + if self.paced { "/paced" } else { "" }, ) } } +struct Counter; + +static COUNTING: AtomicBool = AtomicBool::new(false); +static ALLOCATIONS: AtomicUsize = AtomicUsize::new(0); + +#[global_allocator] +static ALLOCATOR: Counter = Counter; + +// Counting only. Every call forwards to the system allocator unchanged. +unsafe impl GlobalAlloc for Counter { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + if COUNTING.load(Ordering::Relaxed) { + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + } + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + if COUNTING.load(Ordering::Relaxed) { + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + } + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + if COUNTING.load(Ordering::Relaxed) { + ALLOCATIONS.fetch_add(1, Ordering::Relaxed); + } + unsafe { System.realloc(ptr, layout, new_size) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } +} + fn runtime() -> tokio::runtime::Runtime { tokio::runtime::Builder::new_current_thread() .enable_time() @@ -229,7 +283,13 @@ struct Room { /// Broadcasts at least one viewer watches; only these write each round. watched: Vec, payload: Bytes, - expected: usize, + shape: Shape, + /// Frames written so far, so paced rounds know where each group starts and ends. + written: usize, + /// The group each watched broadcast is writing, when paced. + writing: Vec>, + /// The group each viewer's subscriber is reading, when paced, flattened in viewer order. + reading: Vec>, } impl Room { @@ -267,14 +327,24 @@ impl Room { .map(|(i, _)| i) .collect(), payload: Bytes::from(vec![0; shape.frame]), - expected: shape.expected(), + shape, + written: 0, + writing: Vec::new(), + reading: Vec::new(), }; + room.writing.resize_with(room.watched.len(), || None); + room.reading.resize_with(shape.viewers * shape.watch, || None); // Warm every path so the timed rounds skip first-group setup. - room.round().await; + for _ in 0..FRAMES / shape.frames() { + room.round().await; + } room } async fn round(&mut self) { + if self.shape.paced { + return self.paced_round().await; + } for &broadcast in &self.watched { write_group(&self.cluster.tracks[broadcast], &self.payload); } @@ -284,7 +354,38 @@ impl Room { bytes += read_group(subscriber).await; } } - assert_eq!(bytes, self.expected); + assert_eq!(bytes, self.shape.expected()); + } + + /// One frame into every watched group and one frame out to every viewer, so each + /// hop serves a group frame by frame across [`FRAMES`] rounds. + async fn paced_round(&mut self) { + let last = self.written % FRAMES == FRAMES - 1; + self.written += 1; + + for (writing, &broadcast) in self.writing.iter_mut().zip(&self.watched) { + let group = writing.get_or_insert_with(|| self.cluster.tracks[broadcast].append_group().unwrap()); + group.write_frame(Timestamp::ZERO, self.payload.clone()).unwrap(); + if last { + group.finish().unwrap(); + *writing = None; + } + } + + let mut bytes = 0; + let subscribers = self.viewers.iter_mut().flat_map(|viewer| &mut viewer.subscribers); + for (subscriber, reading) in subscribers.zip(&mut self.reading) { + if reading.is_none() { + *reading = Some(subscriber.recv_group().await.unwrap().expect("track ended")); + } + let frame = reading.as_mut().unwrap().read_frame().await.unwrap(); + bytes += frame.expect("group ended early").payload.len(); + if last { + let end = reading.take().unwrap().read_frame().await.unwrap(); + assert!(end.is_none(), "group did not end after its last frame"); + } + } + assert_eq!(bytes, self.shape.expected()); } async fn measure(&mut self, iters: u64) -> Duration { @@ -362,7 +463,39 @@ fn join(c: &mut Criterion, name: &str, shapes: impl IntoIterator) group.finish(); } +/// Print allocations per viewer-group instead of timing, to compare across revisions. +fn allocations() { + const ROUNDS: usize = 256; + + let rt = runtime(); + let fanout = Shape { + publishers: 1, + viewers: 256, + ..Shape::BASE + }; + for version in VERSIONS { + for shape in [Shape::BASE, fanout] { + for paced in [false, true] { + let shape = Shape { paced, ..shape }; + let mut room = rt.block_on(Room::new(version, shape)); + ALLOCATIONS.store(0, Ordering::Relaxed); + COUNTING.store(true, Ordering::Relaxed); + rt.block_on(room.measure(ROUNDS as u64)); + COUNTING.store(false, Ordering::Relaxed); + + let groups = ROUNDS * shape.viewers * shape.watch * shape.frames() / FRAMES; + let allocations = ALLOCATIONS.load(Ordering::Relaxed) as f64 / groups as f64; + println!("{}: {allocations:.1} allocations per viewer-group", shape.id(version)); + } + } + } +} + fn session(c: &mut Criterion) { + if std::env::var_os("SESSION_ALLOCS").is_some() { + allocations(); + return; + } let base = Shape::BASE; delivery( @@ -371,6 +504,16 @@ fn session(c: &mut Criterion) { [1, 16, 256].map(|publishers| Shape { publishers, ..base }), ); delivery(c, "viewers", [1, 16, 256].map(|viewers| Shape { viewers, ..base })); + // Live media: every hop serves each group frame by frame. + delivery( + c, + "paced", + [1, 16, 256].map(|viewers| Shape { + viewers, + paced: true, + ..base + }), + ); delivery( c, "scale", diff --git a/rs/moq-net/src/coding/reader.rs b/rs/moq-net/src/coding/reader.rs index 12bf76bf5f..434e962551 100644 --- a/rs/moq-net/src/coding/reader.rs +++ b/rs/moq-net/src/coding/reader.rs @@ -460,7 +460,7 @@ mod tests { type Error = crate::lite::test_transport::SinkError; fn poll_read(&mut self, cx: &mut Context<'_>, dst: &mut [u8]) -> Poll, Self::Error>> { - // Like an executor poll, retire the previous turn's registrations first. + // Start each turn with none of the last turn's registrations left. self.waiter = kio::Waiter::new(self.waiter.waker().clone()); while self.payload.poll_read_chunk(&self.waiter).is_ready() {} self.chunks.poll_read(cx, dst) From d2d5d7876a18cc7b94676e73e0a60b9bf20ebcb6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 07:35:13 -0700 Subject: [PATCH 30/45] fix(auth): keep accepted grants on fixed expiry deadlines (#4237) Co-authored-by: GPT-6 --- doc/bin/relay/auth.md | 5 +- doc/lib/rs/moq-auth.md | 2 +- quest/m1/README.md | 1 - quest/m1/auth-expiry-clock.md | 24 ---------- rs/moq-auth/Cargo.toml | 4 +- rs/moq-auth/src/client.rs | 87 ++++++++++++++++++++++++----------- rs/moq-auth/src/grant.rs | 8 +++- rs/moq-relay/src/auth.rs | 35 ++++++++++---- 8 files changed, 99 insertions(+), 67 deletions(-) delete mode 100644 quest/m1/auth-expiry-clock.md diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 2cb99cc912..f9289e3f61 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -49,7 +49,10 @@ ingest here). A 2xx with a grant admits. A 401 or 403 refuses. Anything else at connect, a timeout, a 5xx, or an unparseable body, refuses and logs an error; nothing is admitted because the server was down. A grant that names nothing refuses, and one with `revalidate` but no `expires` is refused -as invalid. A few seconds of clock skew are tolerated on `expires`. +as invalid. A grant already less than five seconds past `expires` is accepted +for the remainder of that clock-skew window; future expiries are unchanged. +The client and relay snapshot each accepted grant's deadline on a monotonic +clock, so later polls, outages, and wall-clock adjustments do not restart it. **Revalidate and outage.** On the cadence the relay POSTs `revalidate` with the same request. A grant applies: a changed `root` or one that no longer covers diff --git a/doc/lib/rs/moq-auth.md b/doc/lib/rs/moq-auth.md index 8f3ea78286..ddab0dc5a9 100644 --- a/doc/lib/rs/moq-auth.md +++ b/doc/lib/rs/moq-auth.md @@ -14,7 +14,7 @@ answers the relay, in a service that mints tokens for clients, or in your own accept loop that decides in process. - **Request and grant**: `Request` is the JSON a relay POSTs per session event (`connect`, `revalidate`, `end`) with everything it knows: id, node, transport, addresses, SNI and ALPN, the raw path and query, the declared role, and the verified certificate facts. `Grant` is the answer: `publish` and `subscribe` pattern unions, an optional `root` alias, `expires`, `revalidate`, `tier`, and `peer`, which marks the session as a cluster peer so the routes it announces report `Source::Peer`. `Grant::validate` refuses a grant that names nothing, asks to be revalidated without a bound or at no interval, or has already expired, with a few seconds of clock skew on `expires`. -- **Lease**: `lease::Producer` and `lease::Consumer` are the handle a session holds for its grant. The consumer reads the current grant, waits for a change, and learns why the lease ended; the producer updates and revokes. Either side's terminal call returns the reason the lease actually ended with, so whichever got there first is what both report. `Consumer::fixed` is a grant nobody drives. `Consumer::revalidate` nudges a re-check now and the producer observes it via `poll_revalidate` or `revalidate_requested`, which is how the relay's session push lands. Whoever runs the accept loop builds the producer, so an embedder decides in process with no trait and no HTTP. Enforcing `expires` is the holder's job; the `Client` driver also revokes at expiry so its `end` event goes out. +- **Lease**: `lease::Producer` and `lease::Consumer` are the handle a session holds for its grant. The consumer reads the current grant, waits for a change, and learns why the lease ended; the producer updates and revokes. Either side's terminal call returns the reason the lease actually ended with, so whichever got there first is what both report. `Consumer::fixed` is a grant nobody drives. `Consumer::revalidate` nudges a re-check now and the producer observes it via `poll_revalidate` or `revalidate_requested`, which is how the relay's session push lands. Whoever runs the accept loop builds the producer, so an embedder decides in process with no trait and no HTTP. Enforcing `expires` is the holder's job; the `Client` driver also revokes at expiry so its `end` event goes out. `Grant::deadline()` (feature `tokio`, also enabled by `client` and `serve`) snapshots expiry on Tokio's clock. Call it once per accepted grant and retain the deadline: future expiries are unchanged, and one already less than five seconds late gets the remainder of that skew window. - **Client**: `Client::new(url, tls)` and `Client::connect(request)` drive a lease against an auth server over `https://`, `unix://`, or loopback `http://`: revalidate on cadence with jittered backoff through an outage until `expires`, revoke on a 401/403 or an invalid grant, and POST `end` with the reason, duration, and byte totals the session reported through `lease::Consumer::close` when it ended. Dropping the consumer reports zero bytes. `end.reason` is `dropped`, `expired`, `refused`, `invalid`, or the session's own classification. - **Server**: `serve::Policy` and `serve::Server` (feature `serve`) are the reference auth server behind `moq auth serve`: a `jwt` in the query verified against a key file or a `{kid}.jwk` directory, an explicit grant for verified certificates, the anonymous permissions, a tier, the revalidation cadence, a default `expires`, and live session caps per token and per remote address. A token is authorized at the dialed path with `Claims::authorize`; residuals become the grant. `Server::router` is an axum `POST /` you can mount in your own service. - **Keys**: generate HS256/384/512, RS256/384/512, PS256/384/512, ES256/384, or EdDSA keys as JWKs, with a `kid` for rotation and an optional immutable scope that caps every token the key signs. diff --git a/quest/m1/README.md b/quest/m1/README.md index 787191075c..022226b808 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -25,7 +25,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Interop flakes](/quest/m1/interop-flakes.md) - the interop harness passes with other runs sharing the machine - [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race - [Origin narrowing](/quest/m1/origin-narrowing.md) - a live origin grant narrows in place and ends the subscriptions it no longer covers, the deafen boundary #2714 asked for -- [Auth expiry clock](/quest/m1/auth-expiry-clock.md) - moq-auth and the relay hold one fixed expiry deadline and honour the same skew allowance - [Binding surface](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper expose the decode delay, route source, and connection timing - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, audio, and video namespaces built from the handle below - [Track demand](/quest/m1/track-demand.md) - Rust and JS watch a track's subscribers through `demand()` alone diff --git a/quest/m1/auth-expiry-clock.md b/quest/m1/auth-expiry-clock.md deleted file mode 100644 index c2a069c104..0000000000 --- a/quest/m1/auth-expiry-clock.md +++ /dev/null @@ -1,24 +0,0 @@ -# [S] Grant expiry is one fixed deadline everywhere - -## Goal - -A grant's expiry is a deadline fixed when the grant arrives, in both -`moq-auth`'s `Client::drive` and the relay, and both honour `moq-auth`'s 5 s -clock-skew allowance. Today `Client::drive` recomputes the time left from the -wall clock on every re-check, so the countdown restarts, and the relay ignores -the skew allowance, so a grant just past expiry stays live in the client but -ends immediately in the relay. - -## Plan - -- Build on the lease clock `lease::Producer` owns (#3943): it sets - its deadline once per grant and measures it on the tokio clock, so the - client's outage tests run on a paused clock like the relay's (#3969 fixed - the relay side). -- Apply the skew allowance in one place both sides share. -- Test: re-polling keeps the deadline; a grant within the skew window is live - on both sides; the client outage tests run on a paused clock. - -## Required - -- The relay's fixed expiry deadline (#3969) has merged diff --git a/rs/moq-auth/Cargo.toml b/rs/moq-auth/Cargo.toml index 9c65a292ea..4ca32b17dc 100644 --- a/rs/moq-auth/Cargo.toml +++ b/rs/moq-auth/Cargo.toml @@ -12,9 +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", "dep:tokio", "dep:tracing", "dep:rand"] +client = ["dep:reqwest", "dep:rustls", "tokio", "dep:tracing", "dep:rand"] # The reference auth server behind `moq auth serve`: the policy a relay held, on axum. -serve = ["dep:axum", "dep:tokio", "dep:tracing", "tokio/net"] +serve = ["dep:axum", "tokio", "dep:tracing", "tokio/net"] tokio = ["dep:tokio"] [dependencies] diff --git a/rs/moq-auth/src/client.rs b/rs/moq-auth/src/client.rs index 020358d65c..8b8fc0359f 100644 --- a/rs/moq-auth/src/client.rs +++ b/rs/moq-auth/src/client.rs @@ -80,6 +80,7 @@ impl Client { client: self.clone(), request, producer: Some(producer), + expires: grant.deadline(), started: Instant::now(), }; tokio::spawn(driver.run(grant)); @@ -115,6 +116,7 @@ struct Driver { request: Request, producer: Option, started: Instant, + expires: Option, } impl Driver { @@ -140,7 +142,7 @@ impl Driver { .take() .expect("the driver owns the producer until it ends"); let mut failures = 0u32; - let mut next = grant.revalidate.map(|cadence| Instant::now() + cadence); + let mut next = grant.revalidate.map(|cadence| tokio::time::Instant::now() + cadence); // The re-check in flight, kept out of the select so expiry and the session's // close are still polled while a stalled server holds the reply. let mut inflight: Option> + Send>>> = None; @@ -149,16 +151,15 @@ impl Driver { let mut pending = false; loop { - let expires = grant.expires.map(crate::grant::until); let revalidate = async { match next { - Some(at) => tokio::time::sleep_until(at.into()).await, + Some(at) => tokio::time::sleep_until(at).await, None => std::future::pending().await, } }; let expire = async { - match expires { - Some(after) => tokio::time::sleep(after).await, + match self.expires { + Some(at) => tokio::time::sleep_until(at).await, None => std::future::pending().await, } }; @@ -177,7 +178,8 @@ impl Driver { match result { Ok(fresh) => { failures = 0; - next = fresh.revalidate.map(|cadence| Instant::now() + cadence); + self.expires = fresh.deadline(); + next = fresh.revalidate.map(|cadence| tokio::time::Instant::now() + cadence); producer.update(fresh.clone()); grant = fresh; } @@ -190,7 +192,7 @@ impl Driver { failures += 1; let delay = backoff(failures, grant.revalidate.unwrap_or(BACKOFF_MAX)); tracing::warn!(id = %self.request.id, %err, ?delay, "auth revalidation failed; retrying"); - next = Some(Instant::now() + delay); + next = Some(tokio::time::Instant::now() + delay); } } if pending { @@ -314,6 +316,37 @@ mod tests { server } + /// Keep HTTP handling on the paused test clock instead of wiremock's separate runtime. + async fn clock_server(log: Log, grant: Grant, stall: bool) -> Client { + use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::post}; + let router = Router::new() + .route( + "/", + post( + |State((log, grant, stall)): State<(Log, Grant, bool)>, Json(request): Json| async move { + let event = request.event.clone(); + log.0.lock().push(request); + match event { + Event::Connect => Json(grant).into_response(), + Event::Revalidate if stall => std::future::pending().await, + Event::Revalidate => StatusCode::SERVICE_UNAVAILABLE.into_response(), + Event::End { .. } => StatusCode::OK.into_response(), + } + }, + ), + ) + .with_state((log, grant, stall)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + // Only the lease clock is under test; an HTTP timeout can auto-advance + // Tokio's paused clock before loopback I/O gets its first reactor turn. + Client { + http: reqwest::Client::builder().no_proxy().build().unwrap(), + url: url.parse().unwrap(), + } + } + fn client(server: &MockServer) -> Client { Client::new(server.uri().parse().unwrap(), None).unwrap() } @@ -461,14 +494,12 @@ mod tests { #[tokio::test] async fn a_grant_within_clock_skew_stays_live() { - let server = server(Log::default(), |_| { - let mut grant = Grant::new(patterns(&["**"]), Patterns::new()); - grant.expires = Some(SystemTime::now() - Duration::from_secs(1)); - ResponseTemplate::new(200).set_body_json(grant) - }) - .await; + tokio::time::pause(); + let mut grant = Grant::new(patterns(&["**"]), Patterns::new()); + grant.expires = Some(SystemTime::now() - Duration::from_secs(1)); + let client = clock_server(Log::default(), grant, false).await; + let consumer = client.connect(request()).await.unwrap(); - let consumer = client(&server).connect(request()).await.unwrap(); tokio::time::sleep(Duration::from_millis(500)).await; assert!( tokio::time::timeout(Duration::from_millis(100), consumer.closed()) @@ -485,15 +516,16 @@ mod tests { #[tokio::test] async fn an_outage_keeps_the_grant_until_expires() { + tokio::time::pause(); let log = Log::default(); - let server = server(log.clone(), |request| match request.event { - Event::Connect => ResponseTemplate::new(200) - .set_body_json(grant(Some(Duration::from_secs(3)), Some(Duration::from_secs(1)))), - _ => ResponseTemplate::new(503), - }) + let client = clock_server( + log.clone(), + grant(Some(Duration::from_secs(3)), Some(Duration::from_secs(1))), + false, + ) .await; + let consumer = client.connect(request()).await.unwrap(); - let consumer = client(&server).connect(request()).await.unwrap(); tokio::time::sleep(Duration::from_millis(1500)).await; assert!(log.revalidates() >= 1, "re-checks happened"); assert_eq!( @@ -517,16 +549,15 @@ mod tests { #[tokio::test] async fn expiry_fires_while_a_recheck_is_stalled() { - let server = server(Log::default(), |request| match request.event { - Event::Connect => ResponseTemplate::new(200) - .set_body_json(grant(Some(Duration::from_secs(3)), Some(Duration::from_secs(1)))), - _ => ResponseTemplate::new(200) - .set_body_json(grant(Some(Duration::from_secs(3600)), Some(Duration::from_secs(60)))) - .set_delay(Duration::from_secs(30)), - }) + tokio::time::pause(); + let client = clock_server( + Log::default(), + grant(Some(Duration::from_secs(3)), Some(Duration::from_secs(1))), + true, + ) .await; + let consumer = client.connect(request()).await.unwrap(); - let consumer = client(&server).connect(request()).await.unwrap(); let reason = tokio::time::timeout(Duration::from_secs(5), consumer.closed()) .await .expect("expired while the re-check was in flight"); diff --git a/rs/moq-auth/src/grant.rs b/rs/moq-auth/src/grant.rs index 9ae14ae375..954b60aae1 100644 --- a/rs/moq-auth/src/grant.rs +++ b/rs/moq-auth/src/grant.rs @@ -9,7 +9,7 @@ pub(crate) const CLOCK_SKEW: Duration = Duration::from_secs(5); /// How long until `at`. A deadline up to [`CLOCK_SKEW`] in the past still has the /// remaining window; anything older is zero. Future deadlines are unchanged, so a /// grant that expires in ten seconds still expires in ten seconds. -pub(crate) fn until(at: SystemTime) -> Duration { +fn until(at: SystemTime) -> Duration { match at.duration_since(SystemTime::now()) { Ok(remaining) => remaining, Err(late) => CLOCK_SKEW.saturating_sub(late.duration()), @@ -67,6 +67,12 @@ impl Grant { } } + /// Snapshot the expiry on Tokio's clock, allowing five seconds of past clock skew. + #[cfg(feature = "tokio")] + pub fn deadline(&self) -> Option { + self.expires.map(|at| tokio::time::Instant::now() + until(at)) + } + /// Refuse a grant that admits nothing, asks to be revalidated without a bound or /// at no interval, or has already expired. A few seconds of clock skew are /// tolerated so an auth server whose clock runs behind still admits. diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index 35c663301c..a3eb5dda48 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -4,7 +4,6 @@ //! certificate is reported to whoever decides as a fact. use std::sync::Arc; -use std::time::SystemTime; use axum::http; use moq_auth::{Bytes, Grant, Request, lease}; @@ -248,7 +247,7 @@ impl Lease { let grant = consumer.grant(); Self { token: Token::new(path, &grant), - expires: deadline(&grant), + expires: grant.deadline(), consumer, stats: Default::default(), } @@ -306,7 +305,7 @@ impl Lease { self.stats.set_tier(fresh.tier.clone()); self.token.tier = fresh.tier; } - self.expires = deadline(&grant); + self.expires = grant.deadline(); }, Err(reason) => return reason, }, @@ -323,12 +322,6 @@ impl Lease { } } -/// The grant's `expires` as a deadline on tokio's clock, counted from now. -fn deadline(grant: &Grant) -> Option { - let at = grant.expires?; - Some(tokio::time::Instant::now() + at.duration_since(SystemTime::now()).unwrap_or_default()) -} - enum Decider { Server(moq_auth::Client), Public(Grant), @@ -509,6 +502,7 @@ pub(crate) fn peer(identity: &moq_tokio::tls::PeerIdentity) -> Option Patterns { texts.iter().map(|text| text.parse().unwrap()).collect() @@ -643,6 +637,29 @@ mod tests { assert_eq!(reason, lease::Reason::Expired); } + #[tokio::test] + async fn a_grant_within_clock_skew_stays_live() { + tokio::time::pause(); + use std::time::Duration; + + let mut grant = Grant::new(patterns(&["**"]), patterns(&["**"])); + grant.expires = Some(SystemTime::now() - Duration::from_secs(1)); + grant.validate().expect("accepted inside the skew window"); + let mut lease = Lease::new("/room", lease::Consumer::fixed(grant)); + assert!( + tokio::time::timeout(Duration::from_secs(1), lease.ended()) + .await + .is_err(), + "still live inside the skew window" + ); + assert_eq!( + tokio::time::timeout(Duration::from_secs(4), lease.ended()) + .await + .expect("expired once the skew window ended"), + lease::Reason::Expired + ); + } + #[test] fn token_keeps_every_grant_pattern() { let mut grant = Grant::new(patterns(&["alice/**"]), patterns(&["**"])); From f63b295f23784dbf3e63a9a371e2181403398061 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 07:35:55 -0700 Subject: [PATCH 31/45] fix(net): retain retired counters in host snapshots (#4238) Co-authored-by: GPT-6 --- doc/bin/relay/http.md | 4 ++ rs/moq-net/src/stats.rs | 124 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 6 deletions(-) diff --git a/doc/bin/relay/http.md b/doc/bin/relay/http.md index 80460efb30..dcf73a4636 100644 --- a/doc/bin/relay/http.md +++ b/doc/bin/relay/http.md @@ -54,6 +54,10 @@ process ran out of a resource `accept` needs. Content dropped for drifting past a subscriber's budget is counted separately as `moq_relay_stale_bytes_total` and friends. Host CPU and memory belong to a node exporter. +Traffic and session counters accumulate for the node's lifetime, including +broadcasts and sessions that have ended. The stats publishing prefix (normally +`.stats`) is excluded to avoid counting the feed's own traffic. + With `--runtime-io-uring`, each QUIC worker thread also reports its own `moq_relay_uring_*` counters under a `worker` label: datagrams and syscalls (the ratios are the GRO/GSO batching and the syscall amortization the runtime diff --git a/rs/moq-net/src/stats.rs b/rs/moq-net/src/stats.rs index 7f84c93a37..d6cf90368e 100644 --- a/rs/moq-net/src/stats.rs +++ b/rs/moq-net/src/stats.rs @@ -794,6 +794,9 @@ pub struct Registry { /// State shared by every clone of a [`Registry`]. struct Shared { + /// Completed entries folded by tier before pruning. Lock before either map + /// so a snapshot sees each entry either here or live, never both or neither. + retired: Lock, entries: Lock>>, /// Connected-session gauges keyed by `(tier, auth root)`. Independent of any /// broadcast; surfaced on the per-tier session tracks. A tier's inner map is @@ -844,6 +847,7 @@ impl Registry { Self { exclude, shared: Some(Arc::new(Shared { + retired: Lock::default(), entries: Lock::default(), sessions: Default::default(), })), @@ -922,12 +926,14 @@ impl Registry { /// all-zero snapshot for a disabled registry. /// /// Unlike [`Registry::report`], this collapses per-broadcast detail into - /// node totals (what a `/metrics`-style scrape wants) and never prunes. + /// node lifetime totals (what a `/metrics`-style scrape wants) and never prunes. + /// Retired entries remain in these totals after [`Self::report`] prunes them. pub fn snapshot(&self) -> Snapshot { - let mut snap = Snapshot::default(); let Some(shared) = self.shared.as_ref() else { - return snap; + return Snapshot::default(); }; + let retired = shared.retired.lock(); + let mut snap = retired.clone(); { let entries = shared.entries.lock(); for entry in entries.values() { @@ -967,6 +973,7 @@ impl Registry { let Some(shared) = self.shared.as_ref() else { return; }; + let mut retired = shared.retired.lock(); { let mut entries = shared.entries.lock(); for (path, entry) in entries.iter() { @@ -989,7 +996,15 @@ impl Registry { return true; } let mut tiers = entry.tiers.lock().expect("stats tiers poisoned"); - tiers.retain(|_, counters| Arc::strong_count(counters) > 1); + tiers.retain(|tier, counters| { + if Arc::strong_count(counters) > 1 { + return true; + } + let totals = retired.traffic.entry(tier.clone()).or_default(); + totals[Role::Publisher.idx()].add(counters.publisher.snapshot()); + totals[Role::Subscriber.idx()].add(counters.subscriber.snapshot()); + false + }); !tiers.is_empty() }); } @@ -1004,8 +1019,18 @@ impl Registry { }); } } - for roots in sessions.values_mut() { - roots.retain(|_, counters| Arc::strong_count(counters) > 1); + for (tier, roots) in sessions.iter_mut() { + roots.retain(|_, counters| { + if Arc::strong_count(counters) > 1 { + return true; + } + retired + .sessions + .entry(tier.clone()) + .or_default() + .add(counters.snapshot()); + false + }); } sessions.retain(|_, roots| !roots.is_empty()); } @@ -1735,6 +1760,93 @@ mod tests { assert!(session_snapshot(&stats, &Tier::default(), "acme").is_none()); } + #[test] + fn snapshot_preserves_retired_counters() { + let stats = test_stats(); + let tier = Tier::default(); + let live = stats.tier(tier.clone()).session("live"); + let scope = live.egress("live/video"); + let _live_sub = scope.subscribe(); + for _ in 0..2 { + let session = stats.tier(tier.clone()).session("retired"); + let scope = session.egress("retired/video"); + let sub = scope.subscribe(); + scope.meter().bytes(100); + drop(sub); + drop(scope); + drop(session); + let before = stats.snapshot(); + drain(&stats); + assert_eq!(stats.snapshot(), before, "pruning must not reset host counters"); + } + let snap = stats.snapshot(); + let traffic = snap + .traffic() + .into_iter() + .find(|(_, role, _)| *role == Role::Publisher) + .unwrap() + .2; + assert_eq!(traffic.bytes, 200); + assert_eq!(traffic.subscriptions_started, 3); + assert_eq!(traffic.subscriptions_ended, 2); + let sessions = snap.sessions().into_iter().find(|(label, _)| label == &tier).unwrap().1; + assert_eq!(sessions.sessions_started, 3); + assert_eq!(sessions.sessions_ended, 2); + assert_eq!(stats.shared().entries.lock().len(), 1, "retired paths are still pruned"); + assert_eq!( + stats.shared().sessions.lock()[&tier].len(), + 1, + "retired roots are still pruned" + ); + let retired = stats.shared().retired.lock(); + assert_eq!(retired.traffic.len(), 1, "retain only a total per tier"); + assert_eq!(retired.sessions.len(), 1); + } + + #[cfg(not(target_family = "wasm"))] + #[test] + fn snapshot_and_report_transfer_counters_once() { + let stats = test_stats(); + let worker_stats = stats.clone(); + let worker = std::thread::spawn(move || { + for i in 0..256 { + let path = format!("root/{i}"); + let session = worker_stats.tier(Tier::default()).session(path.as_str()); + session.ingress(path.as_str()).meter().bytes(1); + drop(session); + drain(&worker_stats); + } + }); + let mut previous = 0; + while !worker.is_finished() { + let bytes: u64 = stats + .snapshot() + .traffic() + .iter() + .map(|(_, _, traffic)| traffic.bytes) + .sum(); + assert!( + bytes >= previous, + "retiring an entry must not double-count or lose its bytes" + ); + assert!(bytes <= 256); + previous = bytes; + } + worker.join().unwrap(); + let snap = stats.snapshot(); + assert_eq!( + snap.traffic().iter().map(|(_, _, traffic)| traffic.bytes).sum::(), + 256 + ); + assert_eq!(snap.sessions()[0].1.sessions_started, 256); + assert_eq!(snap.sessions()[0].1.sessions_ended, 256); + assert!(stats.shared().entries.lock().is_empty()); + assert!(stats.shared().sessions.lock().is_empty()); + let retired = stats.shared().retired.lock(); + assert_eq!(retired.traffic.len(), 1); + assert_eq!(retired.sessions.len(), 1); + } + #[test] fn paths_under_exclude_are_no_op() { // Our own stats broadcasts (and any sibling category under the same From 3431e3dac76602953a1af09125637ef1027c1131 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 07:36:49 -0700 Subject: [PATCH 32/45] fix(net): make JS origin refusals terminal to match moq-net (#4230) Co-authored-by: Claude Opus 5.5 --- doc/lib/js/net.md | 2 +- js/net/src/origin.test.ts | 73 ++++++++++++++++++-- js/net/src/origin.ts | 140 +++++++++++++++++++++++++------------- 3 files changed, 159 insertions(+), 56 deletions(-) diff --git a/doc/lib/js/net.md b/doc/lib/js/net.md index 833961ec74..daef5e58f3 100644 --- a/doc/lib/js/net.md +++ b/doc/lib/js/net.md @@ -47,7 +47,7 @@ for (;;) { } ``` -- **Origins** hold the broadcasts, not the connection: closing a session unannounces them but leaves them created for the next one. `origin.request(path)` resolves an announced local broadcast with no round trip, so a page that watches what it publishes reads its own copy, unless a cheaper route announces the same path. Create, populate, then `announce()` for an exact path; use `dynamic(prefix, route)` when the set of paths is not known: an exact-path subscribe before the tracks exist is refused, and nobody, local or remote, can see or reach a broadcast until it announces. +- **Origins** hold the broadcasts, not the connection: closing a session unannounces them but leaves them created for the next one. `origin.request(path)` resolves an announced local broadcast with no round trip, so a page that watches what it publishes reads its own copy, unless a cheaper route announces the same path. A refusal from the most specific route ends the request: `request.closed` settles with the handler's error and no broader route is asked. Create, populate, then `announce()` for an exact path; use `dynamic(prefix, route)` when the set of paths is not known: an exact-path subscribe before the tracks exist is refused, and nobody, local or remote, can see or reach a broadcast until it announces. - **Connections** race WebTransport against WebSocket. `new Connection({ url })` pools one connection per relay URL and reconnects with backoff, which the elements use. Supplying WebTransport/WebSocket options, discovery, delay, or a caller-owned origin selects a private loop; explicit `share: true` refuses those options. `closed` settles when the handle is released (`null` on a clean close); the failure that stopped retrying the current URL is `error`, and a new URL recovers the same handle. A connection owns one send-rate sampler and one `Bandwidth.Allocator`; publishers reserve against it so their encoder targets sum to the estimate instead of each matching it. - **Bandwidth** (`Bandwidth.Allocator`) divides the connection's send-rate estimate by track priority, max-min fair within a tier. An idle track claims nothing. The receive side is untouched. - **Discovery** by any pattern scope (`origin.announced(scope)`, such as `room/*/chat`; default everything). Each event's `prefix` is the covered prefix relative to the origin, `captures` reports what the scope's wildcards matched when the prefix pins them, and `kind` says whether it was announced, updated, or retracted. The consumer is an async iterable. `origin.broadcasts(scope)` is a live `Getter>` of the same covered prefixes for UIs that need the current set. A borrowed `Connection.origin` also exposes `dynamic(prefix, route)` for serving paths on demand. diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 47d044158e..488e41e273 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -1300,31 +1300,92 @@ test("a rejected request is not asked of the same route again", async () => { origin.close(); }); -test("a rejected request falls through to the next-best route", async () => { +test("a refusal is terminal, never falling through to a broader route", async () => { const origin = new Producer(); const consumer = origin.consume(); const narrow = origin.dynamic(Path.from("live")); + const wide = origin.dynamic(Path.from("")); + const wideRequests = wide.requested(); + const wideAsked = wideRequests.next(); + + const request = consumer.request(Path.from("live/cam")); + const { value: req } = await narrow.requested().next(); + const err = new Error("unserved"); + req?.reject(err); + await settle(); + + // The narrow route spoke for the path, so the broader one is never asked. + expect(await request.closed).toBe(err); + expect(request.active.peek()).toBeUndefined(); + expect(request.unroutable.peek()).toBe(true); + expect(await Promise.race([wideAsked.then(() => "asked"), settle().then(() => "idle")])).toBe("idle"); + + request.close(); + void wideRequests.return?.(); + narrow.close(); + wide.close(); + origin.close(); +}); + +test("a better route's refusal leaves the serving route in place", async () => { + const origin = new Producer(); + const consumer = origin.consume(); const served = new BroadcastProducer(); - served.createTrack("video"); const disposeWide = serve(origin, Path.from(""), provider(served)); const request = consumer.request(Path.from("live/cam")); + await settle(); + const before = request.active.peek(); + expect(before).toBeDefined(); + + // The narrower route is asked while the broad one keeps serving, then says no. + const narrow = origin.dynamic(Path.from("live")); const { value: req } = await narrow.requested().next(); + expect(request.active.peek()).toBe(before); req?.reject(new Error("unserved")); await settle(); - // The narrow route refused, so the broader one answers instead of the path black-holing. - const track = request.active.peek()?.track("video").subscribe(); - expect(track).toBeDefined(); - track?.close(); + expect(request.active.peek()).toBe(before); + expect(before?.closed.peek()).toBeUndefined(); + expect(request.closed.peek()).toBeUndefined(); request.close(); + expect(request.closed.peek()).toBeNull(); narrow.close(); disposeWide(); served.close(); origin.close(); }); +test("a refusal from a superseded route does not end the request", async () => { + const origin = new Producer(); + const consumer = origin.consume(); + const wide = origin.dynamic(Path.from("")); + const wideRequests = wide.requested(); + + const request = consumer.request(Path.from("live/cam")); + const { value: stale } = await wideRequests.next(); + + // A narrower route takes over the pending request before the broad one answers. + const narrow = origin.dynamic(Path.from("live")); + const { value: req } = await narrow.requested().next(); + stale?.reject(new Error("unserved")); + await settle(); + expect(request.closed.peek()).toBeUndefined(); + + const produced = new BroadcastProducer(); + req?.accept(produced); + await settle(); + expect(request.active.peek()).toBeDefined(); + + request.close(); + void wideRequests.return?.(); + narrow.close(); + wide.close(); + produced.close(); + origin.close(); +}); + test("close rejects queued requests with NoCapacity", async () => { const origin = new Producer(); const handle = origin.dynamic(Path.from("live")); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 28dd06e62d..3e9641828b 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -34,18 +34,21 @@ export { isAnonymous } from "./hop.ts"; * `answer` outlives a session: the answering session clears it when it dies and the next one * answers again, which is what makes a request span reconnects. * - * `refused` holds the route entries whose handler rejected the path. A refusal is - * authoritative: the entry is never asked again while it stands (a reconnect is a fresh - * entry), and the path resolves through the next-best route or to nothing. Without it a - * rejection would refresh the path straight back onto the same queue, and a handler that - * keeps saying no would be asked forever. + * `handles` holds the `closed` of each open {@link Requesting} on the path. + * + * A refusal is terminal. With nothing serving, the slot ends: every handle closes with the + * handler's error and the slot leaves the table, so the next request asks afresh. While + * another source still serves (a better route was asked and said no), the refuser joins + * `refused` and is skipped for as long as it stands (a reconnect is a fresh entry), so the + * current source carries on. A refusal never falls through to a broader prefix or another + * advertiser. * * @internal */ export interface RequestSlot { - count: number; blind: number; answer?: broadcast.Consumer; + readonly handles: Set>; readonly refused: Set; readonly route: Signal; } @@ -115,7 +118,7 @@ class ServeState { closed = new Once(); settled = new Signal(0); onChange: (path: Path.Valid) => void = () => {}; - onReject: (path: Path.Valid) => void = () => {}; + onReject: (path: Path.Valid, err: Error) => void = () => {}; enqueue(path: Path.Valid): void { if (this.closed.peek() !== undefined) return; @@ -157,7 +160,7 @@ class ServeState { if (this.pending.get(request.path) !== request) return; this.pending.delete(request.path); if (this.demanding.has(request.path)) this.rejected.set(request.path, err); - this.onReject(request.path); + this.onReject(request.path, err); this.settled.update((n) => n + 1); } @@ -303,31 +306,42 @@ class OriginState { slot.route.set(this.route(path, slot)); } - /** Record that `entry` refused `path` and re-route the requests watching it. */ - refuse(path: Path.Valid, entry: RouteEntry): void { + /** + * `entry` refused `path` with `err`. A request still serving another source skips the + * refuser; one with nothing serving ends with `err`. + */ + refuse(path: Path.Valid, entry: RouteEntry, err: Error): void { const slot = this.requests.peek()?.get(path); if (!slot) return; - slot.refused.add(entry); - slot.route.set(this.route(path, slot)); + // Only the route the request is waiting on speaks for it; a superseded one's answer is moot. + if (this.bestEntry(path, (candidate) => slot.refused.has(candidate)) !== entry) return; + + const serving = slot.route.peek(); + if (serving && serving.closed.peek() === undefined) { + slot.refused.add(entry); + slot.route.set(this.route(path, slot)); + return; + } + + this.requests.mutate((map) => { + if (map?.get(path) === slot) map.delete(path); + }); + slot.answer?.close(); + slot.answer = undefined; + slot.route.set(undefined); + this.releaseMaterialized(path); + for (const closed of slot.handles) closed.set(err); + slot.handles.clear(); } /** * Recompute every open request covered by `prefix`, after a route was inserted or * removed there: a route covers many paths, so a single-path refresh is not enough. - * Materialized broadcasts whose provider changed are released here too, so a - * retracted route's session subscription closes even when nothing reads it again. + * Every materialized broadcast belongs to an open request, so rerouting them also + * releases a retracted route's session subscription even when nothing reads it again. */ refreshPrefix(prefix: Path.Valid): void { - const requests = this.requests.peek(); - for (const [path, cached] of [...this.materialized]) { - if (!Path.hasPrefix(prefix, path)) continue; - const refused = requests?.get(path)?.refused; - if (cached.entry !== this.bestEntry(path, (entry) => refused?.has(entry) ?? false)) { - this.materialized.delete(path); - cached.front.close(); - } - } - for (const [path, slot] of requests ?? []) { + for (const [path, slot] of this.requests.peek() ?? []) { if (Path.hasPrefix(prefix, path)) slot.route.set(this.route(path, slot)); } } @@ -404,8 +418,9 @@ class OriginState { * the best covering route, or the blind answer. * * Materialization is lazy and cached per path: the first request under a route opens - * the providing session's subscription, repeats share it, and a provider change (the - * route retracting, a better session taking over) swaps it out. + * the providing session's subscription and repeats share it. A better route is made + * before the old one breaks: the current front keeps serving until the new route + * answers (then swaps) or refuses (then is skipped). A retracted route swaps at once. */ route(path: Path.Valid, slot: Pick): broadcast.Consumer | undefined { const entry = this.bestEntry(path, (candidate) => slot.refused.has(candidate)); @@ -416,24 +431,26 @@ class OriginState { return local; } - const cached = this.materialized.get(path); - if (cached && cached.entry === entry) { - if (cached.front.closed.peek() === undefined) return cached.front; + let cached = this.materialized.get(path); + if (cached && cached.front.closed.peek() !== undefined) { this.materialized.delete(path); - } else if (cached) { - this.materialized.delete(path); - cached.front.close(); + cached = undefined; + } + if (cached && cached.entry === entry) return cached.front; + if (!entry?.server) { + this.releaseMaterialized(path); + return slot.answer; } - if (!entry?.server) return slot.answer; const served = entry.server.served.get(path); if (served && served.closed.peek() === undefined) { + cached?.front.close(); this.materialized.set(path, { entry, front: served }); return served; } entry.server.enqueue(path); - return undefined; + return cached?.front; } } @@ -629,7 +646,7 @@ export class Producer implements Table { originated, server, }; - server.onReject = (path) => this.#state.refuse(path, entry); + server.onReject = (path, err) => this.#state.refuse(path, entry, err); let closed = false; this.#state.routes.mutate((routes) => { @@ -830,6 +847,7 @@ let makeRequesting: ( path: Path.Valid, active: Getter, unroutable: Getter, + closed: Once, dispose: Dispose, ) => Requesting; @@ -870,33 +888,43 @@ export class Requesting { * set, and false while a connection is still coming up, so the ordinary page-load window * before the first handshake reads as pending rather than as a missing broadcast. Waiting * on this is futile by definition; wait for an announcement instead, via the origin's - * `announced`. + * `announced`. True once the request is refused. */ readonly unroutable: Getter; + /** + * Settles with the error a route's handler refused the path with, or `null` once you + * {@link close} the request. A refusal is final: no other route is asked, and a fresh + * request is needed to try again. + */ + readonly closed: GetPromise; + #dispose: Dispose; - #closed = false; + #disposed = false; private constructor( path: Path.Valid, active: Getter, unroutable: Getter, + closed: GetPromise, dispose: Dispose, ) { this.path = path; this.active = active; this.unroutable = unroutable; + this.closed = closed; this.#dispose = dispose; } static { - makeRequesting = (path, active, unroutable, dispose) => new Requesting(path, active, unroutable, dispose); + makeRequesting = (path, active, unroutable, closed, dispose) => + new Requesting(path, active, unroutable, closed, dispose); } /** Withdraw the request. The path stays routed for any other open request. Idempotent. */ close(): void { - if (this.#closed) return; - this.#closed = true; + if (this.#disposed) return; + this.#disposed = true; this.#dispose(); } } @@ -1000,7 +1028,14 @@ export class Consumer { const requests = this.#state.requests.peek(); if (!requests) { // Closed origin: a request that can never resolve, and says so. - return makeRequesting(path, new Signal(undefined), getter(true), () => {}); + const closed = new Once(); + return makeRequesting( + path, + new Signal(undefined), + getter(true), + closed, + () => closed.set(null), + ); } let slot = requests.get(path); @@ -1012,8 +1047,8 @@ export class Consumer { // notify nobody. const refused = new Set(); const created: RequestSlot = { - count: 0, blind: 0, + handles: new Set(), refused, route: new Signal(this.#state.route(path, { refused })), }; @@ -1022,7 +1057,8 @@ export class Consumer { map?.set(path, created); }); } - slot.count += 1; + const closed = new Once(); + slot.handles.add(closed); let blind = !options.announced || this.#discovery.peek() === false; if (blind) slot.blind += 1; this.#state.requests.mutate(() => {}); @@ -1075,9 +1111,12 @@ export class Consumer { // Only meaningful while nothing is routed, so it reads the route rather than `active`: // the two cannot disagree, since a routed path always has an answerer-independent // answer. - const unroutable = new Derived([route, this.#state.answerers], (front, answerers) => !front && answerers === 0); + const unroutable = new Derived( + [route, this.#state.answerers, closed], + (front, answerers, ended) => ended !== undefined || (!front && answerers === 0), + ); - return makeRequesting(path, active, unroutable, () => { + return makeRequesting(path, active, unroutable, closed, () => { // Releases this request's handle; the route itself belongs to the table. released = true; unsubscribeDiscovery(); @@ -1086,18 +1125,21 @@ export class Consumer { handle = undefined; source = undefined; - taken.count -= 1; + taken.handles.delete(closed); + if (closed.peek() === undefined) closed.set(null); if (blind) taken.blind -= 1; this.#state.requests.mutate(() => {}); - if (taken.count > 0) return; + if (taken.handles.size > 0) return; // Defer the teardown a microtask: an effect whose rerun was triggered by the // answer resolving closes its old request and takes a new one in the same tick, // and tearing down in between would drop the answer it is about to read. queueMicrotask(() => { - if (taken.count > 0) return; + if (taken.handles.size > 0) return; + // A refused slot already tore itself down, and the path may hold a newer one. + if (this.#state.requests.peek()?.get(path) !== taken) return; this.#state.requests.mutate((map) => { - if (map?.get(path) === taken) map.delete(path); + map?.delete(path); }); taken.answer?.close(); taken.answer = undefined; From 33a00ce88b003fb43d61e31171190454a64291fa Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 07:48:18 -0700 Subject: [PATCH 33/45] docs(quest): plan BBR ECN and ACK cleanup fixes (#4245) Co-authored-by: GPT-6 --- quest/m1/README.md | 2 ++ quest/m1/bbr-ack-cleanup.md | 61 ++++++++++++++++++++++++++++++++ quest/m1/bbr-classic-ecn.md | 61 ++++++++++++++++++++++++++++++++ quest/m1/quic/bbr-loss-parity.md | 18 +++++----- quest/m1/quic/ecn-measure.md | 17 ++++++--- quest/m2/quic-ecn.md | 11 +++--- 6 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 quest/m1/bbr-ack-cleanup.md create mode 100644 quest/m1/bbr-classic-ecn.md diff --git a/quest/m1/README.md b/quest/m1/README.md index 022226b808..4e4c1dbb0e 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -17,6 +17,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. ## Quests +- [BBR classic ECN](/quest/m1/bbr-classic-ecn.md) - Startup and bandwidth probing respond to CE marks before the bottleneck drops packets - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached - [Dropped sources](/quest/m1/dropped-sources.md) - consumers see the producer's real error on every end path, never `Dropped` @@ -72,6 +73,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Scope track priority](/quest/m1/track-priority-scope.md) - priority orders one owner's streams, and a shared cluster session is fair across tenants - [Stream sessions](/quest/m1/uring-tcp/README.md) - serve WebSocket and HTTP from the io_uring workers, where io_uring pays off most - [IETF on the ring](/quest/m1/uring-ietf.md) - the io_uring workers serve moq-transport sessions too, so a uring relay drops no client protocol +- [BBR ACK cleanup](/quest/m1/bbr-ack-cleanup.md) - packet bookkeeping scales with completed entries instead of scanning the flight on every ACK - [Perf](/quest/m1/perf/README.md) - eliminate measured hot-path costs across moq-uring, kio, and the moq-net model - [#2924](/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md) - every listener on both runtimes shares one reloadable served identity, so rotation is atomic and generate works with workers - [#2964](/quest/m1/2964-quic-workers-dropping-one-split-server-resizes-the.md) - integrate the dev worker owner with hardened socket-group formation diff --git a/quest/m1/bbr-ack-cleanup.md b/quest/m1/bbr-ack-cleanup.md new file mode 100644 index 0000000000..02ad06e0f1 --- /dev/null +++ b/quest/m1/bbr-ack-cleanup.md @@ -0,0 +1,61 @@ +# [M] Make BBR ACK cleanup scale with completed packets + +## Goal + +Large BBR flights do not make every ACK scan all outstanding packets. +Packet bookkeeping scales with acknowledged, lost, and expired entries while +preserving bandwidth samples and congestion behavior. Ship the improvement +through MoQ's published dependency chain without changing public APIs or wire +behavior. + +## Plan + +The fix lives in moq-dev/noq. In released 1.3.1 (`ff9d2ab5`), +[`on_end_acks`](https://github.com/moq-dev/noq/blob/ff9d2ab518cfb155f9ebb9925f1c784665eac92a/noq-proto/src/congestion/bbr3/mod.rs#L1783) +runs `retain` over all tracked packets, then scans them again to mark stale +entries. Draining a flight with fixed-size ACK batches has quadratic total +cleanup work. [Google QUICHE](https://github.com/google/quiche/blob/c961965aa3ee8f2b6f05ebcac794f7854101adcd/quiche/quic/core/congestion_control/bandwidth_sampler.cc#L377) +uses packet-number lookup and obsolete-prefix reclamation; use that as a +reference without copying a TCP or single-space assumption into QUIC. + +Retain an optimized benchmark sweeping 1,000, 4,000, and 16,000 outstanding +1200-byte packets against ACK batches of 1, 8, and 32. Time the controller +callbacks, excluding fixture setup and the simulator's own queue searches. +The initial local median-of-three measurements at eight packets per ACK were +0.650, 11.310, and 162.873 ms respectively. These are workload evidence, not +portable timing thresholds or a network-throughput comparison with Google. +Add steady-flight cases so draining the table cannot hide recurring costs. + +Replace whole-flight cleanup with indexed packet state and incremental +reclamation. Let the implementation choose the simplest representation that +handles sparse packet numbers, reordering, and separate Initial, Handshake, +and Data spaces. Bound retained state without repeatedly sweeping live +entries or scanning large unused packet-number gaps. Keep the existing +sampling and congestion policies unchanged; loss-sample repair and ECN +response belong to their own quests. + +Extend existing tests for ordered, reordered, duplicate, and batched ACKs; +loss and spurious loss; expiry; overlapping packet numbers in different +spaces; and handshake-space discard. Compare samples and control outputs +against the unchanged baseline on valid callback traces, allowing only the +bookkeeping change. A late event for expired state must remain safe and must +not reuse another packet's metadata. + +Acceptance is the removal of the whole-flight factor from per-ACK work, +with measured scaling and bounded retained memory under reordering and loss. +Report CPU time and memory across both benchmark axes, including small +flights, and explain any remaining logarithmic or amortized cost. Keep the +regressions in fork CI and the benchmark matrix at least nightly. Do not +substitute one hardware-specific millisecond limit for the scaling check. + +Land the fix in the fork, offer it upstream or record why not, publish an +immutable fork release, and pin the corrected dependency chain here before +completing this quest. Do not wait for the broader QUIC stack release or the +ECN fix. Update internal packet-lifetime comments inline; no new user guide +is needed. + +## Related + +- [Classic ECN](/quest/m1/bbr-classic-ecn.md) - an independent correctness fix in the same controller; coordinate ownership of the shared file +- [Loss sampling](/quest/m1/quic/bbr-loss-parity.md) - preserve packet metadata needed by the separate loss-sample repair +- [Benchmark comparisons](/quest/m1/performance-comparisons.md) - reusable measurement guidance, not a prerequisite for this fix diff --git a/quest/m1/bbr-classic-ecn.md b/quest/m1/bbr-classic-ecn.md new file mode 100644 index 0000000000..d085ba2d8c --- /dev/null +++ b/quest/m1/bbr-classic-ecn.md @@ -0,0 +1,61 @@ +# [M] Make BBR respond to classic ECN + +## Goal + +BBRv3 responds to validated CE marks before a marking bottleneck has to drop +packets, including during Startup and ProbeUp. Keep ECT(0) enabled and ship +the corrected controller through MoQ's published dependency chain. L4S, +new configuration flags, and changing the default controller are out of scope. + +## Plan + +The fix lives in moq-dev/noq. In released 1.3.1 (`ff9d2ab5`), +[`on_congestion_event`](https://github.com/moq-dev/noq/blob/ff9d2ab518cfb155f9ebb9925f1c784665eac92a/noq-proto/src/congestion/bbr3/mod.rs#L1828) +passes CE to the lost-packet path with zero lost bytes. Startup and ProbeUp +skip the short-term loss response, while their high-loss checks see no lost +bytes. A controller reproduction sends eight rounds of 1, 2, 4, ..., 128 +1200-byte packets, 20 ms apart, acknowledging each round after 10 ms and +reporting CE after `on_end_acks`, as the transport does. Both marked and +unmarked controls remain in Startup with a 318,000-byte window and +42,167,347.2 bytes/s pacing. This is a controller result, not an AQM network +measurement. + +[Draft-06 section 3.7](https://www.ietf.org/archive/id/draft-ietf-ccwg-bbr-06.html#section-3.7) +requires an ECN-capable sender to treat CE as congestion, without prescribing +one BBR response. [Google Linux BBRv3](https://github.com/google/bbr/blob/90210de4b779d40496dee0b89081780eeddf2a60/net/ipv4/tcp_bbr.c#L1048) +has a separate Startup ECN response when its ECN mode is eligible. Choose a +classic response consistent with QUIC recovery: stop acceleration and reduce +the permitted sending load on new CE feedback, at most once per recovery +period. Do not fabricate lost bytes or apply repeated reductions for old CE +counts. Preserve the distinction between loss and CE, including undo of +spurious loss. Prefer the existing controller boundary; this fix does not +need a CE-fraction API or Google's L4S policy. + +Extend the existing shared BBR `Sim` with the failing reproduction, then test +through actual QUIC ECN validation and controller callbacks. Cover Startup, +ProbeUp, Cruise, ProbeRTT, repeated ACKs with no new CE, several CE-bearing +ACKs in one recovery period, a later period with new CE, simultaneous loss, +and invalid or missing ECN feedback. Acceptance requires a bounded decrease +in sending load under sustained CE, recovery after marking stops, and no +response on the unmarked control; merely changing an internal state is not +enough. Keep the default CUBIC path working. + +Retain a reproducible rate-limited marking-versus-dropping network case, +recording queue delay, goodput, loss, and response timing. Run deterministic +regressions in fork CI and the network case at least nightly. Provider +availability does not block this lab validation. Record the response rule +and its recovery-period boundary with the results. + +Land the fix in the fork, offer it upstream or record why not, publish an +immutable fork release, and pin the corrected dependency chain here before +completing this quest. Do not wait for the broader QUIC stack release or the +ACK cleanup fix. No wire change or new public API is intended; document any +necessary API change and apply the repository's branch rules. Update stale +controller and relay ECN documentation inline; no separate guide is needed. + +## Related + +- [ACK cleanup](/quest/m1/bbr-ack-cleanup.md) - an independent fix in the same controller; coordinate ownership of the shared file +- [Loss sampling](/quest/m1/quic/bbr-loss-parity.md) - the separate lost-packet sample repair +- [ECN measurement](/quest/m1/quic/ecn-measure.md) - measures the released response on the backbone +- [L4S](/quest/m2/quic-ecn.md) - a separate scalable ECN policy and opt-in configuration diff --git a/quest/m1/quic/bbr-loss-parity.md b/quest/m1/quic/bbr-loss-parity.md index 2bcee6f51f..647d1123a5 100644 --- a/quest/m1/quic/bbr-loss-parity.md +++ b/quest/m1/quic/bbr-loss-parity.md @@ -9,7 +9,7 @@ citations point at draft-06. ## Plan -Two gaps remain after the seven correctness fixes, both in +The lost-packet sample gap remains after the seven correctness fixes in `noq-proto/src/congestion/bbr3/mod.rs` in moq-dev/noq: - Loss handling reuses the shared ACK sample (`self.rs`) as scratch space, @@ -17,18 +17,18 @@ Two gaps remain after the seven correctness fixes, both in sample exists. [Google Linux](https://github.com/google/bbr/blob/90210de4b779d40496dee0b89081780eeddf2a60/net/ipv4/tcp_bbr.c)'s `bbr_handle_lost_packet` builds a local sample from the lost packet instead. Do the same so a loss never reads or clobbers ACK state. -- Undo restores ProbeUp directly; - [draft-06 section 5.5.11](https://www.ietf.org/archive/id/draft-ietf-ccwg-bbr-06.html#section-5.5.11) - goes back through Refill. + +Release 1.3.1 already restores a spurious ProbeUp exit through Refill, as +[draft-06 section 5.5.11](https://www.ietf.org/archive/id/draft-ietf-ccwg-bbr-06.html#section-5.5.11) +requires. Preserve that behavior and its regression. While in the file, move the remaining draft-05 links and renamed pseudocode identifiers (such as `probe_up_acked_per_inc`) to draft-06; comment-only. -This stacks on the seven fixes' noq branches until they merge, and does not -gate [the BBR release](/quest/m1/quic/bbr-release.md). Add a failing -regression for each behavior on the shared test `Sim`: a loss before the -first ACK sample, a loss between ACKs that must not alter the next ACK's -sample, and a spurious-loss undo that passes through Refill. Internal only; +Start from released 1.3.1 or newer, without waiting for the broader QUIC stack +release. Add failing regressions on the shared test `Sim`: a loss before the +first ACK sample, and a loss between ACKs that must not alter the next ACK's +sample. Retain the spurious-loss undo coverage through Refill. Internal only; no `Controller` or wire change. ## Related diff --git a/quest/m1/quic/ecn-measure.md b/quest/m1/quic/ecn-measure.md index 1865cabd3a..56b6658477 100644 --- a/quest/m1/quic/ecn-measure.md +++ b/quest/m1/quic/ecn-measure.md @@ -14,6 +14,11 @@ A manual procedure on Linux, run as root, with the commands and what to record written here so the dualpi2 run and any later provider re-check repeat it. +Use the released [classic BBR ECN fix](/quest/m1/bbr-classic-ecn.md) for the +controller-response verdict and record the exact dependency version. Provider +mark-survival captures alone do not establish a controller response; a result +from 1.3.1 is a defective baseline, not evidence that classic ECN cannot help. + - Bottleneck: two relays on the io_uring runtime across a netem link with a rate limit and delay, once with `fq_codel` marking (`ecn` on) and once with the same qdisc dropping (`noecn`). Measure queueing delay at the @@ -22,11 +27,15 @@ repeat it. goodput. - Providers: between a Linode relay and an OVH relay, `tcpdump -v` on each end shows the TOS byte of arriving packets. Record whether ECT(0) arrives - intact in both directions and whether any CE appears; ECN is only alive - when both directions preserve the marks, since a stripped return path - means ACKs without counts. Repeat the capture for cross-provider and - same-provider pairs. + intact in each data direction and whether any CE appears. Check that + the reverse ACKs report the received ECN counts; the ACK packets need not + themselves retain an ECN IP marking to carry those counts. Repeat the + capture for cross-provider and same-provider pairs. - Record the qdisc parameters, the moq-bench profile, the kernel version, and the tcpdump summaries beside the numbers in the L4S quest's Plan. If neither provider preserves the marks, say so there: L4S stays off and the marking response is only a lab result. + +## Required + +- [Classic BBR ECN](/quest/m1/bbr-classic-ecn.md) - the final response verdict needs the corrected released controller diff --git a/quest/m2/quic-ecn.md b/quest/m2/quic-ecn.md index 61a423e7a3..e4b0e9d24e 100644 --- a/quest/m2/quic-ecn.md +++ b/quest/m2/quic-ecn.md @@ -9,10 +9,12 @@ marks fall back to no ECN, and a viewer's session is unaffected. ## Plan -Classic ECN is already end to end on both runtimes; this quest is the -fork-side half. noq-proto has no ECN knob: `sending_ecn` is hardcoded on -per path and only an ACK without counts turns it off, so both `off` and -`ect1` need the fork. +ECT(0) marking and ACK ECN counts are carried end to end, but BBR's +[classic CE response](/quest/m1/bbr-classic-ecn.md) must be corrected before +it is used as the baseline. This quest adds the scalable policy separately. +noq-proto has no ECN knob: `sending_ecn` starts on per path and validation +failure or an ACK without counts turns it off, so both `off` and `ect1` +need the fork. - In the fork: an `Ect1` marking option and the accounting to keep the two codepoints apart, so an L4S response (proportional to the CE fraction per @@ -33,5 +35,6 @@ per path and only an ACK without counts turns it off, so both `off` and ## Required +- [Classic BBR ECN](/quest/m1/bbr-classic-ecn.md) - establish a corrected released classic response before comparing L4S - [Measure ECN on the backbone](/quest/m1/quic/ecn-measure.md) - the provider verdict this quest acts on From 05d7815ab2de3a04df4125c1f76753f08432a9bc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 07:50:57 -0700 Subject: [PATCH 34/45] docs(quest): drop proposed binding route APIs (#4236) Co-authored-by: GPT-6 --- doc/lib/c/index.md | 2 +- quest/m1/README.md | 2 +- quest/m1/binding-surface.md | 20 ++++++++------------ 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 7a3c5b08dd..c92f9c7d94 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -38,7 +38,7 @@ and `target/include/moq.h`. - **Raw audio encode.** `moq_audio_encoder_output.codec` names the codec (only `"opus"` today), and `frame_duration_us` sets the Opus frame length: 2500, 5000, 10000, 20000, 40000, or 60000, with 0 meaning the 20 ms default. - **Raw decode output.** `moq_video_decoder_output` selects the decoded CPU pixel format (`MOQ_VIDEO_PIXEL_FORMAT_I420` or `_RGBA`) and target size (`width`/`height`, both zero for native; otherwise even and non-zero). Unknown formats and invalid sizes fail `moq_decode_video` before subscribing; accepted requests deliver exactly that layout or fail on the terminal callback. - **Encoded video metadata.** `moq_video_init.hint` is a zero-initialized `moq_video_hint` with `has_*` flags for coded dimensions, bitrate (bits per second), frame rate, and latency preference. Hints seed a video codec track's catalog; detected dimensions take precedence. -- **Client config.** A zeroed `moq_client_config` means the defaults for every knob, which is what lets a new one be appended without disturbing callers. Fields cover protocol (`versions`), TLS (`tls_fingerprints`, `tls_roots`, `tls_cert`/`_key`, `tls_host_name`), transport (`bind`, `connect_timeout_us`, the Happy Eyeballs delays, `websocket_enabled`/`_delay_us`), and tuning (reconnect backoff, `quic_*`). Every duration is in microseconds. A knob whose default isn't zero carries a `has_*` flag, so setting `backoff_timeout_us = 0` needs `has_backoff_timeout = true` to mean "retry forever" rather than "use the default". `moq_client_defaults()` reports what a NULL config dials with. +- **Client config.** A zeroed `moq_client_config` means the defaults for every knob, which is what lets a new one be appended without disturbing callers. Fields cover protocol (`versions`), TLS (`tls_fingerprints`, `tls_roots`, `tls_cert`/`_key`, `tls_host_name`), transport (`bind`, `connect_timeout_us`, `failover_delay_us`/`resolution_delay_us` for Happy Eyeballs, `websocket_enabled`/`_delay_us`), and tuning (reconnect backoff, `quic_*`). Every duration is in microseconds. A knob whose default isn't zero carries a `has_*` flag, so setting `backoff_timeout_us = 0` needs `has_backoff_timeout = true` to mean "retry forever" rather than "use the default". `moq_client_defaults()` reports what a NULL config dials with. - **Server.** `moq_server_listen` binds before it returns (a bad address or certificate fails there) and hands each incoming session to `on_request` as a request handle. Read `moq_session_request_path` and `_query` to route and authenticate, then `moq_session_request_accept` (a session handle, with origins like `moq_session_connect`) or `moq_session_request_reject` with an HTTP-style code (401 and 403 become the protocol's unauthorized close). An accepted session reports `1` once SETUP completes and never reconnects. `moq_server_addr` reports an ephemeral port and `moq_server_fingerprints` the hashes a client pins for a `tls_generate` certificate. `moq_server_close` stops listening; its terminal callback fires once the sockets are released. - **Demand.** A watcher on a published track (`moq_publish_track_demand`, `moq_publish_media_demand`, `moq_encode_video_demand`, `moq_encode_audio_demand`) calls `on_demand` with `MOQ_DEMAND_USED` or `MOQ_DEMAND_UNUSED` right away and again on every change, so an encoder on a battery-powered device runs only while someone is watching. The first call is the current state, so a track that went unused before the watcher existed still reports it. `moq_publish_demand_cancel` stops it; the terminal callback still fires. A container has no single demand and is refused. Demand follows the last real subscriber: an origin that served the track drops its source copy on the unused edge, so a cache linger never delays it. Only a relay keeps what it already delivered warm for 30 seconds, and a returning subscriber is served that cache only once the publisher confirms it is still current. - **Requests.** `moq_publish_dynamic` serves subscriptions to tracks the broadcast never declared: each arrives as a request handle, read its name with `moq_track_request_name`, then `moq_track_request_accept` (a raw track handle), `moq_track_request_video` / `_audio` (the media handle `moq_publish_video` / `_audio` return), or `moq_track_request_abort` with an application code the subscriber sees. Without a live handler an unknown name is refused. `moq_publish_track_dynamic` does the same for fetches of groups a track no longer has cached, delivered as `moq_group_request_*` (`sequence`, `priority`, `frame_start`); `moq_group_request_accept` starts the producer at `frame_start` so written frames keep their group indices. Register it with `moq_track_request_dynamic` before accepting a track that was itself requested by a fetch, so that pending group survives the transition. Both handlers stop with `moq_publish_dynamic_cancel`. diff --git a/quest/m1/README.md b/quest/m1/README.md index 4e4c1dbb0e..a4d51473d9 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -26,7 +26,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Interop flakes](/quest/m1/interop-flakes.md) - the interop harness passes with other runs sharing the machine - [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race - [Origin narrowing](/quest/m1/origin-narrowing.md) - a live origin grant narrows in place and ends the subscriptions it no longer covers, the deafen boundary #2714 asked for -- [Binding surface](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper expose the decode delay, route source, and connection timing +- [Binding audio delay](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper configure and observe audio playout delay - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, audio, and video namespaces built from the handle below - [Track demand](/quest/m1/track-demand.md) - Rust and JS watch a track's subscribers through `demand()` alone - [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` is the one way to end a broadcast in every language, a permanent retraction that leaves in-flight tracks alone diff --git a/quest/m1/binding-surface.md b/quest/m1/binding-surface.md index 87e3937bba..e789e32710 100644 --- a/quest/m1/binding-surface.md +++ b/quest/m1/binding-surface.md @@ -1,29 +1,25 @@ -# [M] The bindings reach the new decode, route, and connection APIs +# [M] Audio decode delay in every binding ## Goal moq-ffi, libmoq, and every wrapper (Python, Go, Swift, Kotlin, Dart) expose -three surfaces that landed in Rust only: the audio decode delay -(`decode::Options::delay` and `Consumer::delay()`), where a route came from -(`Route::source()` and `origin::Consumer::local()`), and libmoq's connection -timing (`failover_delay_us`, `resolution_delay_us`) beside the WebSocket -fallback settings. moq.pro's Python sidecar reads route sources from here. +`decode::Options::delay` and `decode::Consumer::delay()` so applications can +configure and observe audio playout delay. ## Plan - One PR, each wrapper touched once, in its own idiom: durations as the language's duration type where the wrapper already uses one, handles over flat methods where a surface has more than one call. -- libmoq gets the decode delay and route source in `rs/libmoq` itself, with - tests, not only in the C docs; regenerate `moq.h`. The additions are - additive, so `cpp/obs/src` and `doc/bin/obs.md` change only if OBS uses them. +- Add the libmoq implementation and tests in `rs/libmoq`, then regenerate + `moq.h`. Keep the additions compatible with the published C ABI. - Update `doc/lib/{py,swift,kt,go,dart,c}` in the same PR. -- Test each surface in every wrapper that has tests. +- Test configuration and observed delay in every wrapper that has tests. ## Required -- Native decode delay (#3967) has merged +- [Audio jitter target](/quest/m0/audio-jitter-target/README.md) - lands the native decode delay API on `main`; #3967 merged into the questline only ## Related -- [FFI shape](/quest/m1/ffi-shape/README.md) - reshapes the flat methods this adds +- [FFI shape](/quest/m1/ffi-shape/README.md) - reshapes the binding namespaces From 760d8842efc31fadbdcb4e70581007c72a0f13bb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 07:59:30 -0700 Subject: [PATCH 35/45] test(relay): run the outage lease test on the real clock (#4244) Co-authored-by: Claude Opus 5.5 --- rs/moq-relay/tests/auth_lifetime.rs | 38 ++++++++++++----------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 63b2aa18a4..7d3922f23e 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -703,25 +703,28 @@ async fn http_routes_hold_a_lease() { /// An outage keeps the session until `expires`, then closes it as expired. /// -/// Paused: the relay times the lease and its re-checks on tokio's clock, so the -/// minutes below pass virtually. `expires` is wall-clock, so the relay's deadline -/// comes early by the setup's real time, which the slack absorbs. -#[tokio::test(start_paused = true)] +/// On the real clock: a paused one jumps to the next timer whenever the runtime +/// waits on a socket, and macOS delivers loopback asynchronously, so a virtual +/// timeout can fire before the relay answers. Load only delays the close, so +/// asserting it never lands before `expires` holds on a busy machine. +#[tokio::test] async fn an_outage_keeps_the_session_until_expires() { - const EXPIRES: Duration = Duration::from_secs(120); - const SLACK: Duration = Duration::from_secs(30); + // Whole seconds, as the grant crosses the wire, so the relay sees this exact instant. + let now = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(); + let expires = SystemTime::UNIX_EPOCH + Duration::from_secs(now.as_secs() + 3); - let mut grant = grant(EXPIRES); - grant.revalidate = Some(Duration::from_secs(10)); + let mut grant = grant(Duration::ZERO); + grant.expires = Some(expires); let script = Script::new(grant); - // Down from the start: a re-check that succeeded would restart the deadline - // at whatever the virtual clock had reached. script.on_revalidate(Answer::Status(503)); let (port, relay) = spawn_relay(build_auth(script.spawn().await)).await; let (pub_session, sub_session) = connect_and_round_trip(&room_url("tcp", port)).await; - // Through every failed re-check up to just short of expires, the session is up... - tokio::time::sleep(EXPIRES - SLACK).await; + assert_closed(pub_session, TIMEOUT, "publisher").await; + assert!( + SystemTime::now() >= expires, + "an outage must not close the publisher before expires" + ); let outages = script .seen .lock() @@ -730,16 +733,7 @@ async fn an_outage_keeps_the_session_until_expires() { .filter(|r| r.event == Event::Revalidate) .count(); assert!(outages > 0, "no re-check reached the server during the outage"); - assert!( - tokio::time::timeout(Duration::from_millis(100), pub_session.closed()) - .await - .is_err(), - "an outage must not close the publisher before expires" - ); - - // ...and it closes once the grant expires, not later. - assert_closed(pub_session, SLACK, "publisher").await; - assert_closed(sub_session, SLACK, "subscriber").await; + assert_closed(sub_session, TIMEOUT, "subscriber").await; let ends = tokio::time::timeout(TIMEOUT, async { loop { From cbc63ea91525a09f39ad91177d6718166d8181e9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 08:06:03 -0700 Subject: [PATCH 36/45] fix(libmoq): write moq.h and moq.pc into OUT_DIR only (#4243) Co-authored-by: Claude Opus 5.5 --- cpp/obs/justfile | 12 ++++-------- doc/lib/c/index.md | 8 ++++++-- nix/overlay.nix | 32 +++++++++---------------------- rs/justfile | 22 ++++++++++----------- rs/libmoq/CMakeLists.txt | 17 ++++++++-------- rs/libmoq/README.md | 10 ++++++++-- rs/libmoq/build.rs | 29 +++++++--------------------- rs/libmoq/build.sh | 9 +++++++-- rs/libmoq/cmake/cargo-build.cmake | 17 ++++++++++++++++ rs/libmoq/moq.pc.in | 13 +++++-------- test/interop/interop.sh | 14 ++++++++------ 11 files changed, 91 insertions(+), 92 deletions(-) create mode 100644 rs/libmoq/cmake/cargo-build.cmake diff --git a/cpp/obs/justfile b/cpp/obs/justfile index 99b658406f..1c1f59ed10 100644 --- a/cpp/obs/justfile +++ b/cpp/obs/justfile @@ -200,11 +200,9 @@ _includes: (cd ../.. && cargo check --locked --quiet -p libmoq) - # Ask cargo where its build script ran rather than reconstructing the path. - # CARGO_TARGET_DIR, a `--target` triple and a custom profile each move the - # generated header, and guessing wrong doesn't fail: it type-checks against - # whatever stale copy the default directory still holds. Replays the check - # above from cache, so it costs a process rather than a compile. + # rs/libmoq/build.rs writes the header into its OUT_DIR, so ask cargo where + # that is. Replays the check above from cache, so it costs a process rather + # than a compile. out_dir=$(cd ../.. && cargo check --locked -p libmoq --message-format=json | jq -r 'select(.reason == "build-script-executed") | select(.package_id | tostring | test("libmoq")) | .out_dir' | tail -1) @@ -212,9 +210,7 @@ _includes: echo "cargo reported no build script output for libmoq" >&2 exit 1 fi - # //build/libmoq-/out, and the header sits beside the - # profile directory. rs/libmoq/build.rs derives it from OUT_DIR the same way. - moq_include="$(dirname "$(dirname "$(dirname "$(dirname "$out_dir")")")")/include" + moq_include="$out_dir/include" if [ ! -f "$moq_include/moq.h" ]; then echo "$moq_include/moq.h missing after 'cargo check --locked -p libmoq'" >&2 exit 1 diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index c92f9c7d94..3614be1ef1 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -25,8 +25,12 @@ export PKG_CONFIG_PATH="moq-$ver-$target/lib/pkgconfig" cc app.c $(pkg-config --cflags --libs --static moq) -o app ``` -From source: `cargo build --release -p libmoq` writes `target/release/libmoq.a` -and `target/include/moq.h`. +From source, `add_subdirectory(rs/libmoq)` in CMake gives a `moq` target to +link. A bare `cargo build --release -p libmoq` writes `target/release/libmoq.a`, +and `moq.h` plus `moq.pc` land in the build script's `OUT_DIR` under `include/` +and `lib/pkgconfig/`, a hashed path that `--message-format=json` reports as +`out_dir`. That `moq.pc` expects the install layout, with `libmoq.a` in `lib/` +beside `pkgconfig/`. ## Shape of the API diff --git a/nix/overlay.nix b/nix/overlay.nix index b3fb9ce61b..961f1e72d6 100644 --- a/nix/overlay.nix +++ b/nix/overlay.nix @@ -167,29 +167,15 @@ let mkdir -p $out/lib/pkgconfig $out/include $out/lib/cmake/moq - # build.rs derives its output dir from OUT_DIR, so a cross --target - # build puts the staticlib and pkgconfig (under /lib/) below - # target//, and the shared header under target//include/. - # Keep the prefix target-aware so the native and cross outputs share one - # installPhase. - tdir="target''${CARGO_BUILD_TARGET:+/$CARGO_BUILD_TARGET}" - cp "$tdir/release/libmoq.a" $out/lib/ - cp "$tdir/include/moq.h" $out/include/ - cp "$tdir/release/lib/pkgconfig/moq.pc" $out/lib/pkgconfig/ - - # build.rs points libdir at the raw cargo target tree's profile dir - # (../.. from the .pc). The installPhase puts the staticlib in $out/lib - # alongside pkgconfig/, so rewrite libdir one level up. Match the whole - # line so this is independent of the profile name and the exact .pc - # template. Stays relocatable; no build-time path leaks into the store. - sed -i 's#^libdir=.*#libdir=''${pcfiledir}/..#' $out/lib/pkgconfig/moq.pc - - # Same relocation for includedir: the template points at the cargo tree's - # shared target/include (../../../ from the .pc). Here the header lives in - # $out/include, one level up from $out/lib, so it's ../../ from the .pc. - # Without this, pkg-config --cflags emits a bogus -I above $out and - # consumers fail with "moq.h: No such file or directory". - sed -i 's#^includedir=.*#includedir=''${pcfiledir}/../../include#' $out/lib/pkgconfig/moq.pc + # Ask cargo's build log where it put things instead of reconstructing the + # paths, which a cross --target build moves. build.rs lays out its + # OUT_DIR like this prefix, minus the staticlib. + jq=${final.lib.getExe final.jq} + lib=$($jq -r 'select(.reason == "compiler-artifact") | .filenames[] | select(endswith("/libmoq.a"))' "$cargoBuildLog") + gen=$($jq -r 'select(.reason == "build-script-executed") | select(.package_id | test("libmoq")) | .out_dir' "$cargoBuildLog") + cp "$lib" $out/lib/ + cp "$gen/include/moq.h" $out/include/ + cp "$gen/lib/pkgconfig/moq.pc" $out/lib/pkgconfig/ major_version="$(echo "${libmoqInfo.version}" | cut -d. -f1)" substitute ${../rs/libmoq/cmake/moq-config.cmake.in} \ diff --git a/rs/justfile b/rs/justfile index abeafecf82..a6bab8c090 100644 --- a/rs/justfile +++ b/rs/justfile @@ -750,22 +750,22 @@ c-tests: cargo build --locked -p libmoq - # Ask cargo where it put the staticlib rather than assuming target/debug: - # CARGO_TARGET_DIR and a configured build target (`target//debug`) - # both move it, and guessing wrong would link a stale copy or nothing. - # Replays the build above from cache, so it costs a process, not a compile. - lib=$(cargo build --locked -p libmoq --message-format=json | - jq -r 'select(.reason == "compiler-artifact") | .filenames[] | select(endswith("/libmoq.a"))' | - tail -1) + # Ask cargo where it put the staticlib and the header rather than assuming + # target/debug: CARGO_TARGET_DIR and a configured build target + # (`target//debug`) both move them, and guessing wrong would link a + # stale copy or nothing. Replays the build above from cache, so it costs a + # process, not a compile. + log=$(cargo build --locked -p libmoq --message-format=json) + lib=$(jq -r 'select(.reason == "compiler-artifact") | .filenames[] | select(endswith("/libmoq.a"))' <<<"$log" | tail -1) if [ -z "$lib" ]; then echo "cargo reported no libmoq.a for libmoq" >&2 exit 1 fi - # The header sits beside the profile directory: rs/libmoq/build.rs writes it - # to `/include`, one level above `//libmoq.a`. profile=$(dirname "$lib") - include="$(dirname "$profile")/include" - if [ ! -f "$include/moq.h" ]; then + # rs/libmoq/build.rs writes the header into its OUT_DIR. + out_dir=$(jq -r 'select(.reason == "build-script-executed") | select(.package_id | test("libmoq")) | .out_dir' <<<"$log" | tail -1) + include="$out_dir/include" + if [ -z "$out_dir" ] || [ ! -f "$include/moq.h" ]; then echo "$include/moq.h missing after 'cargo build --locked -p libmoq'" >&2 exit 1 fi diff --git a/rs/libmoq/CMakeLists.txt b/rs/libmoq/CMakeLists.txt index 50b7359811..9c14c080ad 100644 --- a/rs/libmoq/CMakeLists.txt +++ b/rs/libmoq/CMakeLists.txt @@ -36,13 +36,14 @@ if(BUILD_RUST_LIB) set(CARGO_BUILD_FLAG "--release") endif() - set(RUST_TARGET_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../target") - set(RUST_DIR "${RUST_TARGET_DIR}/${CARGO_BUILD_TYPE}") + set(RUST_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../../target/${CARGO_BUILD_TYPE}") set(RUST_LIB "${RUST_DIR}/${LIB_PREFIX}moq${LIB_SUFFIX}") - set(RUST_HEADER "${RUST_TARGET_DIR}/include/moq.h") + set(RUST_INCLUDE_DIR "${CMAKE_CURRENT_BINARY_DIR}/include") + set(RUST_HEADER "${RUST_INCLUDE_DIR}/moq.h") add_custom_target(rust_build ALL - COMMAND cargo build --locked ${CARGO_BUILD_FLAG} + COMMAND ${CMAKE_COMMAND} -DCARGO_BUILD_FLAG=${CARGO_BUILD_FLAG} -DHEADER=${RUST_HEADER} + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/cargo-build.cmake WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} BYPRODUCTS ${RUST_LIB} ${RUST_HEADER} COMMENT "Building Rust library" @@ -50,18 +51,18 @@ if(BUILD_RUST_LIB) ) else() set(RUST_DIR "${CMAKE_CURRENT_SOURCE_DIR}" CACHE PATH "Directory containing pre-built library and header") - set(RUST_TARGET_DIR "${RUST_DIR}") set(RUST_LIB "${RUST_DIR}/${LIB_PREFIX}moq${LIB_SUFFIX}") - set(RUST_HEADER "${RUST_DIR}/include/moq.h") + set(RUST_INCLUDE_DIR "${RUST_DIR}/include") + set(RUST_HEADER "${RUST_INCLUDE_DIR}/moq.h") endif() # Ensure the directories exist (cargo build creates them) file(MAKE_DIRECTORY ${RUST_DIR}) -file(MAKE_DIRECTORY ${RUST_TARGET_DIR}/include) +file(MAKE_DIRECTORY ${RUST_INCLUDE_DIR}) # Create interface library target that wraps the Rust static library add_library(moq INTERFACE) -target_include_directories(moq INTERFACE ${RUST_TARGET_DIR}/include) +target_include_directories(moq INTERFACE ${RUST_INCLUDE_DIR}) # Link the static library - must use target_link_libraries so it appears # after object files in the link command (linker order matters for static libs) diff --git a/rs/libmoq/README.md b/rs/libmoq/README.md index 635024d478..584f4cf328 100644 --- a/rs/libmoq/README.md +++ b/rs/libmoq/README.md @@ -11,8 +11,14 @@ cargo build --release This will: - Build the static library (`libmoq.a` on Unix-like systems, `moq.lib` on Windows) -- Generate the C header file at `target/include/moq.h` -- Generate the pkg-config file at `target/release/lib/pkgconfig/moq.pc` +- Generate the C header file at `$OUT_DIR/include/moq.h` +- Generate the pkg-config file at `$OUT_DIR/lib/pkgconfig/moq.pc` + +`OUT_DIR` is the build script's hashed output directory, which +`cargo build --message-format=json` reports as `out_dir` on the +`build-script-executed` message for libmoq. +`moq.pc` assumes the install layout (`lib/libmoq.a` beside `lib/pkgconfig/`), so +copy the staticlib into `$OUT_DIR/lib/` or a prefix before using it. There's also a [CMakeLists.txt](CMakeLists.txt) file that can be used to import/build the library. diff --git a/rs/libmoq/build.rs b/rs/libmoq/build.rs index 639c3521b4..3ba1a40b7f 100644 --- a/rs/libmoq/build.rs +++ b/rs/libmoq/build.rs @@ -22,17 +22,18 @@ const ENUMS: &[&str] = &[ fn main() { let crate_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); let version = env::var("CARGO_PKG_VERSION").unwrap(); - let profile_dir = profile_dir(); - let target_dir = profile_dir.parent().expect("profile dir has no parent"); + // Everything lands in OUT_DIR, laid out like the install prefix minus the + // staticlib. Cargo forbids writing anywhere else, and a build cache that + // replays this script (mbx) restores OUT_DIR and nothing more. Consumers ask + // cargo for the path (`build-script-executed` in --message-format=json). + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); // The `rerun-if-changed` below opts out of cargo's default "rerun when any // file in the package changes", so the sources cbindgen reads have to be // named explicitly or an edited signature leaves a stale header behind. println!("cargo:rerun-if-changed=src"); - // Generate C header into target/include/. The header is profile-independent, - // so a debug and release build in the same target tree can share it. - let include_dir = target_dir.join("include"); + let include_dir = out_dir.join("include"); fs::create_dir_all(&include_dir).expect("Failed to create include directory"); let header = include_dir.join(format!("{}.h", LIB_NAME)); let config = cbindgen::Config { @@ -59,10 +60,8 @@ fn main() { .expect("Unable to generate bindings") .write_to_file(&header); - // Generate the pkg-config file next to the staticlib under the profile dir. Scoping it per profile - // keeps debug and release builds from clobbering each other's moq.pc. let pc_in = PathBuf::from(&crate_dir).join(format!("{}.pc.in", LIB_NAME)); - let pkgconfig_dir = profile_dir.join("lib").join("pkgconfig"); + let pkgconfig_dir = out_dir.join("lib").join("pkgconfig"); fs::create_dir_all(&pkgconfig_dir).expect("Failed to create pkgconfig directory"); let pc_out = pkgconfig_dir.join(format!("{}.pc", LIB_NAME)); if let Ok(template) = fs::read_to_string(&pc_in) { @@ -105,17 +104,3 @@ fn native_libs(crate_dir: &str, target: &str) -> String { .collect::>() .join(" ") } - -fn profile_dir() -> PathBuf { - // OUT_DIR is set by Cargo based on whether --target is used: - // - With --target: target/{target}/{profile}/build/{crate}-{hash}/out - // - Without --target: target/{profile}/build/{crate}-{hash}/out - // Go up 3 levels to the profile dir. Deriving this from OUT_DIR also handles custom profiles, - // whose output directory can differ from Cargo's PROFILE value. - PathBuf::from(env::var("OUT_DIR").unwrap()) - .parent() // build/{crate}-{hash} - .and_then(|p| p.parent()) // build/ - .and_then(|p| p.parent()) // {profile}/ - .expect("Failed to get profile directory from OUT_DIR") - .to_path_buf() -} diff --git a/rs/libmoq/build.sh b/rs/libmoq/build.sh index dc40979258..7eb7ff5ba3 100755 --- a/rs/libmoq/build.sh +++ b/rs/libmoq/build.sh @@ -75,12 +75,17 @@ mkdir -p "$PACKAGE_DIR/include" "$PACKAGE_DIR/lib" if [[ "$TARGET" == *"-windows-"* ]]; then echo "Building libmoq for $TARGET via cargo (Windows path)..." - cargo build --locked --release --package libmoq --target "$TARGET" --manifest-path "$WORKSPACE_DIR/Cargo.toml" + BUILD_LOG="$(mktemp)" + trap 'rm -f "$BUILD_LOG"' EXIT + cargo build --locked --release --package libmoq --target "$TARGET" --manifest-path "$WORKSPACE_DIR/Cargo.toml" \ + --message-format=json-render-diagnostics >"$BUILD_LOG" TARGET_DIR="$WORKSPACE_DIR/target/$TARGET/release" LIB_FILE="moq.lib" cp "$TARGET_DIR/$LIB_FILE" "$PACKAGE_DIR/lib/" - cp "$WORKSPACE_DIR/target/$TARGET/include/moq.h" "$PACKAGE_DIR/include/" + # build.rs writes the header into its OUT_DIR, which only cargo's JSON names. + GEN_DIR=$(jq -r 'select(.reason == "build-script-executed") | select(.package_id | test("libmoq")) | .out_dir' "$BUILD_LOG" | tail -1) + cp "$GEN_DIR/include/moq.h" "$PACKAGE_DIR/include/" # Generate CMake config files from templates (no pkg-config on Windows). mkdir -p "$PACKAGE_DIR/lib/cmake/moq" diff --git a/rs/libmoq/cmake/cargo-build.cmake b/rs/libmoq/cmake/cargo-build.cmake new file mode 100644 index 0000000000..4a0316a31f --- /dev/null +++ b/rs/libmoq/cmake/cargo-build.cmake @@ -0,0 +1,17 @@ +# Run with `cmake -DCARGO_BUILD_FLAG=... -DHEADER=... -P cargo-build.cmake` from rs/libmoq. +# +# Builds libmoq, then copies moq.h to HEADER. build.rs writes the header into +# its OUT_DIR, whose hashed path only cargo's JSON messages name. +execute_process( + COMMAND cargo build --locked ${CARGO_BUILD_FLAG} --message-format=json-render-diagnostics + OUTPUT_VARIABLE _messages + COMMAND_ERROR_IS_FATAL ANY +) + +string(REGEX MATCH "{\"reason\":\"build-script-executed\",\"package_id\":\"[^\"]*/libmoq#[^\n]*" _message "${_messages}") +if(NOT _message) + message(FATAL_ERROR "cargo reported no build script output for libmoq") +endif() +string(JSON _out_dir GET "${_message}" out_dir) + +file(COPY_FILE "${_out_dir}/include/moq.h" "${HEADER}" ONLY_IF_DIFFERENT) diff --git a/rs/libmoq/moq.pc.in b/rs/libmoq/moq.pc.in index 4bb6f1d771..fc21229333 100644 --- a/rs/libmoq/moq.pc.in +++ b/rs/libmoq/moq.pc.in @@ -1,11 +1,8 @@ -# Paths are relative to this .pc file so the raw `cargo build` target tree is -# usable directly (e.g. PKG_CONFIG_PATH=target//lib/pkgconfig). The -# staticlib sits in the profile root two dirs up (target//); the header -# is shared across profiles in target/include/. -# Packagers that relocate into a conventional lib/+include/ layout rewrite -# libdir; see nix/overlay.nix. -libdir=${pcfiledir}/../.. -includedir=${pcfiledir}/../../../include +# Paths are relative to this .pc file, in the install layout the release +# tarballs and nix/overlay.nix ship: lib/{libmoq.a,pkgconfig/moq.pc} and +# include/moq.h under one prefix. +libdir=${pcfiledir}/.. +includedir=${pcfiledir}/../../include Name: moq Description: Media over QUIC C Interface diff --git a/test/interop/interop.sh b/test/interop/interop.sh index 713d4e6865..5400c55be4 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -281,10 +281,10 @@ prepare_go() { } # Build libmoq (the C staticlib + cbindgen header) and compile the C subscriber -# against it. cargo writes moq.h to $TARGET_BASE/include and libmoq.a to the -# profile dir. +# against it. cargo writes libmoq.a to the profile dir, and build.rs writes +# moq.h into its OUT_DIR, which only cargo's JSON messages name. prepare_c() { - local cc="${CC:-cc}" header lib os_libs + local cc="${CC:-cc}" out_dir header lib os_libs have "$cc" || { mark_broken c "no C compiler ($cc) on PATH" return @@ -292,12 +292,14 @@ prepare_c() { echo "building c client (workspace libmoq + cc)..." local flag=() [[ "$PROFILE" == "release" ]] && flag=(--release) - if ! (cd "$WORKSPACE" && cargo build --locked ${flag[@]+"${flag[@]}"} -p libmoq) >"$HARNESS_RUN/c-build.log" 2>&1; then + if ! (cd "$WORKSPACE" && cargo build --locked ${flag[@]+"${flag[@]}"} -p libmoq --message-format=json-render-diagnostics) >"$HARNESS_RUN/c-build.json" 2>"$HARNESS_RUN/c-build.log"; then mark_broken c "cargo build -p libmoq failed" sed 's/^/ /' "$HARNESS_RUN/c-build.log" >&2 || true return fi - header="$TARGET_BASE/include/moq.h" + out_dir=$(grep '"reason":"build-script-executed"' "$HARNESS_RUN/c-build.json" | grep libmoq | + sed -n 's/.*"out_dir":"\([^"]*\)".*/\1/p' | tail -1) || true + header="$out_dir/include/moq.h" lib="$TARGET_BASE/$PROFILE/libmoq.a" [[ -f "$header" && -f "$lib" ]] || { mark_broken c "libmoq artifacts missing ($header / $lib)" @@ -319,7 +321,7 @@ prepare_c() { esac done <"$native_libs" C_INTEROP="$HARNESS_RUN/c-interop" - if ! "$cc" "$CLIENTS/c/subscribe.c" -I"$TARGET_BASE/include" -L"$TARGET_BASE/$PROFILE" -lmoq "${os_libs[@]}" -o "$C_INTEROP" >"$HARNESS_RUN/c-compile.log" 2>&1; then + if ! "$cc" "$CLIENTS/c/subscribe.c" -I"$out_dir/include" -L"$TARGET_BASE/$PROFILE" -lmoq "${os_libs[@]}" -o "$C_INTEROP" >"$HARNESS_RUN/c-compile.log" 2>&1; then mark_broken c "cc compile failed" sed 's/^/ /' "$HARNESS_RUN/c-compile.log" >&2 || true fi From b08ba83e62dbfb52def3b97fd05f999aed9fdd93 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 08:06:55 -0700 Subject: [PATCH 37/45] bench(moq-net): model the dash aggregator's stats load in the session bench (#4233) Co-authored-by: Claude Opus 5.5 --- quest/m1/bench-relay.md | 6 + rs/moq-net/benches/session.rs | 225 +++++++++++++++++++++++++++++++--- 2 files changed, 212 insertions(+), 19 deletions(-) diff --git a/quest/m1/bench-relay.md b/quest/m1/bench-relay.md index 1cd12ddfe9..68609da539 100644 --- a/quest/m1/bench-relay.md +++ b/quest/m1/bench-relay.md @@ -18,3 +18,9 @@ than the bench is worth, say so and stop. Reuse the scenario, the publisher and subscriber sweeps, and the delivery accounting from `rs/moq-net/benches/session.rs` so the two results line up, and the difference is the relay layer. + +Run the dash shape (`session_dash_*`) first. In the model bench, the production +point (34 peer nodes x 5 projects x 24 tracks) costs about 100 ms of one core +per 1 s stats tick, for the whole mesh. In production, moving that one session +off a relay saved 50-90% of a core on that relay alone (2026-09-25). The gap is +the relay layer or the transport, not the model. diff --git a/rs/moq-net/benches/session.rs b/rs/moq-net/benches/session.rs index 947fa10727..62485be85b 100644 --- a/rs/moq-net/benches/session.rs +++ b/rs/moq-net/benches/session.rs @@ -15,6 +15,11 @@ //! `session_join_*` times a new viewer connecting, resolving a broadcast, and //! receiving its latest group, swept over what the relays already announce. //! +//! `session_dash_*` models the moq.pro dash aggregator: one session following +//! every node's per-project stats broadcast, each announced by a peer relay, at +//! one small frame per track per tick. Throughput counts tracks, so a flat +//! elements/s across a sweep means the cost per track stays constant. +//! //! Everything runs on one current-thread runtime, so the only work measured is //! the protocol and model code, never scheduling across threads. //! @@ -41,6 +46,12 @@ const FRAMES: usize = 4; const TRACK: &str = "video"; +/// Lite 06 is what the dash session and cluster peers negotiate today. +const DASH_VERSIONS: [&str; 2] = ["moq-lite-06", "moq-transport-22"]; + +/// A stats frame: a small JSON snapshot. +const DASH_FRAME: usize = 128; + /// Each endpoint gets its own bounded pool, as a relay configures one per /// process: without a byte target, every hop keeps each group for the whole /// expiry window and memory grows with the run length instead of the shape. @@ -151,9 +162,9 @@ fn runtime() -> tokio::runtime::Runtime { .unwrap() } -fn write_group(track: &track::Producer, payload: &Bytes) { +fn write_group(track: &track::Producer, payload: &Bytes, frames: usize) { let mut group = track.append_group().unwrap(); - for _ in 0..FRAMES { + for _ in 0..frames { group.write_frame(Timestamp::ZERO, payload.clone()).unwrap(); } group.finish().unwrap(); @@ -248,24 +259,19 @@ impl Cluster { producer } - /// Connect a viewer to `relay` and subscribe it to each of `broadcasts`. - async fn join(&mut self, relay: usize, broadcasts: impl Iterator) -> Viewer { + /// Connect a viewer session to `relay`. + async fn join(&mut self, relay: usize) -> Viewer { let origin = self.origin(CLIENT_CACHE); let mut options = MockConnectOptions::new(self.version); - options.server_publish = Some(self.relays[relay].consume()); + // Hidden included, as the dash's `.stats/` scope sees its subtree. + options.server_publish = Some(self.relays[relay].consume().with_hidden(true)); options.client_subscribe = Some(origin.clone()); let pair = connect_mock(options).await; - let mut subscribers = Vec::new(); - for broadcast in broadcasts { - let broadcast = origin.consume().routed_broadcast(path(broadcast)).await.unwrap(); - subscribers.push(broadcast.track(TRACK).unwrap().subscribe(None).await.unwrap()); - } - Viewer { - subscribers, + subscribers: Vec::new(), _pair: pair, - _origin: origin, + origin, } } } @@ -273,7 +279,18 @@ impl Cluster { struct Viewer { subscribers: Vec, _pair: MockPair, - _origin: origin::Producer, + origin: origin::Producer, +} + +impl Viewer { + /// Resolve `path` and subscribe to each of its `tracks`. + async fn watch(&mut self, path: &str, tracks: &[impl AsRef]) { + let broadcast = self.origin.consume().routed_broadcast(path).await.unwrap(); + for track in tracks { + self.subscribers + .push(broadcast.track(track.as_ref()).unwrap().subscribe(None).await.unwrap()); + } + } } /// A cluster with its viewers attached. @@ -313,8 +330,11 @@ impl Room { } // Offset by one so a viewer lands on a different relay than the // publisher it watches whenever there is more than one. - let relay = (viewer + 1) % shape.relays; - viewers.push(cluster.join(relay, broadcasts.into_iter()).await); + let mut viewer = cluster.join((viewer + 1) % shape.relays).await; + for broadcast in broadcasts { + viewer.watch(&path(broadcast), &[TRACK]).await; + } + viewers.push(viewer); } let mut room = Self { @@ -346,7 +366,7 @@ impl Room { return self.paced_round().await; } for &broadcast in &self.watched { - write_group(&self.cluster.tracks[broadcast], &self.payload); + write_group(&self.cluster.tracks[broadcast], &self.payload, FRAMES); } let mut bytes = 0; for viewer in &mut self.viewers { @@ -429,7 +449,7 @@ fn join(c: &mut Criterion, name: &str, shapes: impl IntoIterator) let cluster = Cluster::new(version, *shape).await; let payload = Bytes::from(vec![0; shape.frame]); for track in &cluster.tracks { - write_group(track, &payload); + write_group(track, &payload, FRAMES); } cluster }) @@ -446,7 +466,8 @@ fn join(c: &mut Criterion, name: &str, shapes: impl IntoIterator) for iter in 0..iters as usize { let broadcast = hosted[iter % hosted.len()]; let start = Instant::now(); - let mut viewer = cluster.join(relay, std::iter::once(broadcast)).await; + let mut viewer = cluster.join(relay).await; + viewer.watch(&path(broadcast), &[TRACK]).await; let bytes = read_group(&mut viewer.subscribers[0]).await; elapsed += start.elapsed(); assert_eq!(bytes, FRAMES * shape.frame); @@ -463,6 +484,131 @@ fn join(c: &mut Criterion, name: &str, shapes: impl IntoIterator) group.finish(); } +/// The moq.pro dash aggregator's load: one session on relay 0 following every +/// node's per-project stats broadcast. Every other relay is a node publishing +/// `.stats//node/` into its own origin, as `moq-relay`'s stats +/// producer does, so relay 0 reaches each one over a peer session. +#[derive(Clone, Copy)] +struct Dash { + /// Relays in the mesh: relay 0 hosts the dash session, the rest are nodes. + relays: usize, + /// Projects with a stats broadcast on every node. + projects: usize, + /// Tracks per stats broadcast. + tracks: usize, +} + +impl Dash { + fn total(&self) -> usize { + (self.relays - 1) * self.projects * self.tracks + } + + fn id(&self, version: &str) -> String { + format!( + "{version}/relays={}/projects={}/tracks={}/total={}", + self.relays, + self.projects, + self.tracks, + self.total() + ) + } +} + +/// A mesh of stats-publishing nodes with the dash session attached. +struct DashRoom { + _cluster: Cluster, + tracks: Vec, + _broadcasts: Vec, + dash: Viewer, + payload: Bytes, +} + +impl DashRoom { + async fn new(version: &str, shape: Dash) -> Self { + assert!(shape.relays > 1, "the dash needs a peer node"); + let mut cluster = Cluster::new( + version, + Shape { + relays: shape.relays, + publishers: 0, + viewers: 0, + ..Shape::BASE + }, + ) + .await; + + let names: Vec<_> = (0..shape.tracks).map(|track| format!("stat{track}")).collect(); + let mut tracks = Vec::new(); + let mut broadcasts = Vec::new(); + let mut paths = Vec::new(); + for node in 1..shape.relays { + for project in 0..shape.projects { + let path = format!(".stats/{project}/node/{node}"); + let broadcast = cluster.relays[node].publish(path.as_str(), Default::default()).unwrap(); + for name in &names { + tracks.push(broadcast.create_track(name.as_str(), None).unwrap()); + } + broadcasts.push(broadcast); + paths.push(path); + } + } + + let mut dash = cluster.join(0).await; + for path in &paths { + dash.watch(path, &names).await; + } + + let mut room = Self { + _cluster: cluster, + tracks, + _broadcasts: broadcasts, + dash, + payload: Bytes::from(vec![0; DASH_FRAME]), + }; + room.round().await; + room + } + + /// One stats tick: every track writes a single-frame group and the dash + /// reads it. + async fn round(&mut self) { + for track in &self.tracks { + write_group(track, &self.payload, 1); + } + let mut bytes = 0; + for subscriber in &mut self.dash.subscribers { + bytes += read_group(subscriber).await; + } + assert_eq!(bytes, self.tracks.len() * DASH_FRAME); + } + + async fn measure(&mut self, iters: u64) -> Duration { + let start = Instant::now(); + for _ in 0..iters { + self.round().await; + } + start.elapsed() + } +} + +fn dash(c: &mut Criterion, name: &str, shapes: impl IntoIterator) { + let rt = runtime(); + let shapes: Vec<_> = shapes.into_iter().collect(); + let mut group = c.benchmark_group(format!("session_dash_{name}")); + group.sample_size(10); + for version in DASH_VERSIONS { + for shape in &shapes { + let mut room = None; + group.throughput(Throughput::Elements(shape.total() as u64)); + group.bench_function(BenchmarkId::from_parameter(shape.id(version)), |b| { + let room = room.get_or_insert_with(|| rt.block_on(DashRoom::new(version, *shape))); + b.iter_custom(|iters| rt.block_on(room.measure(iters))) + }); + } + } + group.finish(); +} + /// Print allocations per viewer-group instead of timing, to compare across revisions. fn allocations() { const ROUNDS: usize = 256; @@ -565,6 +711,47 @@ fn session(c: &mut Criterion) { }), ); join(c, "relays", [1, 2, 8].map(|relays| Shape { relays, ..base })); + + // Production (2026-09): ~34 nodes x >=5 projects x ~24 tracks. + let prod = Dash { + relays: 35, + projects: 5, + tracks: 24, + }; + // More broadcasts at a fixed track count per broadcast. + dash( + c, + "projects", + [1, 4, 16].map(|projects| Dash { + relays: 5, + projects, + ..prod + }), + ); + // More tracks per broadcast at a fixed broadcast count. + dash( + c, + "tracks", + [6, 24, 96].map(|tracks| Dash { + relays: 5, + projects: 4, + tracks, + }), + ); + // More peer sessions carrying the same 32 broadcasts, then the production + // shape. + dash( + c, + "relays", + [2, 3, 5, 9, 17, 33] + .map(|relays| Dash { + relays, + projects: 32 / (relays - 1), + ..prod + }) + .into_iter() + .chain([prod]), + ); } criterion_group!(benches, session); From a5af30932c6f4f0a3d3f1986cd53a807823a42d8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 08:14:04 -0700 Subject: [PATCH 38/45] feat(net): scope origin announcement interest (#4234) Co-authored-by: GPT-6 Co-authored-by: Claude Opus 5.5 --- doc/concept/moq-lite.md | 12 +- js/net/bench/broadcasts.ts | 13 +- js/net/src/connection/forward.test.ts | 97 ++++++++- js/net/src/connection/forward.ts | 101 +++++----- js/net/src/connection/scope.test.ts | 55 ++++++ js/net/src/origin.test.ts | 95 +++++++++ js/net/src/origin.ts | 274 +++++++++++++++++++++----- js/net/src/wire.ts | 6 + quest/m1/README.md | 1 - quest/m1/js-origin-scope.md | 27 --- 10 files changed, 543 insertions(+), 138 deletions(-) create mode 100644 js/net/src/connection/scope.test.ts delete mode 100644 quest/m1/js-origin-scope.md diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index ce9e8118cf..3c3f4e5345 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -135,8 +135,8 @@ A subscriber watching under a root sees advertisements named relative to that root. The pattern scope filters which prefixes are visible without changing a route's prefix. Announce events carry the covered path, captures, and what happened to it: Rust -`announce::Update { path, captures: Option>, route, kind }` and -TypeScript `Announce.Update { path, captures, route, kind }`, where the kind is +`announce::Update { prefix, captures: Option>, route, kind }` and +TypeScript `Announce.Update { prefix, captures, route, kind }`, where the kind is announced, updated (a reprice in place), or retracted. Captures are present when the announced prefix pins every wildcard in the most-specific matching scope member. The Rust consumer is a `Stream` and the TypeScript one an async iterable. @@ -145,7 +145,13 @@ Announcements are hints; requests are the authority. When a subscriber asks for a covered path the advertiser will not serve, the advertiser refuses that request rather than narrowing the claim, and no message narrows a route. Token scope is any pattern union; the session asks for each member's literal head on -the prefix-only wire and filters locally. +the prefix-only wire and filters locally. In Rust and TypeScript, +`origin.scope(root, patterns)` narrows the handle's permissions and presents paths +relative to `root`. Nested scopes intersect with their parent. A session receiving +into that scoped origin asks for the literal heads of its allowed patterns, +coalescing duplicate or nested heads. An unscoped origin still asks for the empty +prefix, covering every namespace. These subscriptions include hidden routes; +each local announcement reader decides whether to show them. ```typescript import { Path } from "@moq/net"; diff --git a/js/net/bench/broadcasts.ts b/js/net/bench/broadcasts.ts index 90dae38c0c..903ab89762 100644 --- a/js/net/bench/broadcasts.ts +++ b/js/net/bench/broadcasts.ts @@ -8,18 +8,23 @@ const updates = 32; let checksum = 0; console.log("scope,routes,observers,update_us"); -for (const scope of ["unscoped", "distinct-scopes"] as const) { +for (const scope of ["unscoped", "distinct-scopes", "producer-scopes", "rooted-producers"] as const) { for (const routeCount of routeCounts) { for (const observerCount of observerCounts) { const origin = new Producer(); const handles = Array.from({ length: routeCount }, (_, index) => origin.dynamic(Path.from(`room/${index}`)), ); - const touched = Path.from("room/0"); + const touched = Path.from(scope === "rooted-producers" ? "0" : "room/0"); let notifications = 0; const disposes = Array.from({ length: observerCount }, (_, index) => - origin - .broadcasts(scope === "unscoped" ? undefined : Path.Pattern.parse(`room/**/tag-${index}`)) + (scope === "producer-scopes" + ? origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse(`room/**/tag-${index}`)])) + : scope === "rooted-producers" + ? origin.scope(Path.from("room"), new Path.Patterns([Path.Pattern.parse(`**/tag-${index}`)])) + : origin + ) + .broadcasts(scope === "distinct-scopes" ? Path.Pattern.parse(`room/**/tag-${index}`) : undefined) .subscribe((routes) => { if (routes.get(touched) === undefined) throw new Error("touched route disappeared"); notifications++; diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index d9d9b74481..f115c38ca4 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -39,7 +39,7 @@ class FakeSession { }); } - announced(): Announce.Consumer { + announced(_scope?: Path.Pattern, _options?: Announce.Options): Announce.Consumer { return this.announces.consume(); } @@ -188,3 +188,98 @@ test("a request replaced across one coalesced wakeup still gets answered", async second.close(); origin.close(); }); + +test("scoped origins subscribe to disjoint literal heads with hidden routes", () => { + const origin = new OriginProducer().scope( + Path.empty(), + new Path.Patterns(["room/*/chat", "room/nested/**", "other/**"].map(Path.Pattern.parse)), + ); + const session = new FakeSession(); + const interests: { scope: string | undefined; hidden: boolean | undefined }[] = []; + session.announced = (scope?: Path.Pattern, options?: Announce.Options) => { + interests.push({ scope: scope?.text, hidden: options?.hidden }); + return session.announces.consume(); + }; + forwardAnnounced(session.session, origin); + expect(interests).toEqual([ + { scope: "other/**", hidden: true }, + { scope: "room/**", hidden: true }, + ]); + session.die(); + origin.close(); +}); + +test("an unscoped origin retains its subscription to every namespace", () => { + const origin = new OriginProducer(); + const session = new FakeSession(); + const interests: string[] = []; + session.announced = (scope?: Path.Pattern) => { + interests.push(scope?.text ?? "**"); + return session.announces.consume(); + }; + forwardAnnounced(session.session, origin); + expect(interests).toEqual(["**"]); + session.die(); + origin.close(); +}); + +test("a scoped session filters announcements and blind requests under its root", async () => { + const origin = new OriginProducer(); + const scoped = origin.scope(Path.from("tenant"), new Path.Patterns([Path.Pattern.parse("room/*/chat")])); + const session = new FakeSession(); + forwardAnnounced(session.session, scoped); + for (const prefix of ["room/alice/video", "room/alice/chat"]) { + session.announces.append({ + prefix: Path.from(prefix), + captures: undefined, + kind: "announced", + route: Route.default, + }); + } + const allowed = origin.request(Path.from("tenant/room/bob/chat")); + const denied = origin.request(Path.from("tenant/room/bob/video")); + await settle(); + expect([...origin.broadcasts().peek().keys()]).toEqual([Path.from("tenant/room/alice/chat")]); + expect(session.consumes(Path.from("room/bob/chat"))).toBe(1); + expect(session.consumes(Path.from("room/bob/video"))).toBe(0); + const routed = origin.request(Path.from("tenant/room/alice/chat")); + await settle(); + expect(session.consumes(Path.from("room/alice/chat"))).toBe(1); + expect(routed.active.peek()).toBeDefined(); + allowed.close(); + denied.close(); + routed.close(); + session.die(); + origin.close(); +}); + +test("one failed scoped interest leaves the other routes live until the session ends", async () => { + const origin = new OriginProducer().scope( + Path.empty(), + new Path.Patterns(["a/**", "b/**"].map(Path.Pattern.parse)), + ); + const session = new FakeSession(); + const sources = new Map(); + session.announced = (scope?: Path.Pattern) => { + const producer = new Announce.Producer(); + sources.set(scope?.text ?? "**", producer); + return producer.consume(); + }; + forwardAnnounced(session.session, origin); + for (const name of ["a", "b"]) { + sources + .get(`${name}/**`) + ?.append({ prefix: Path.from(name), captures: undefined, kind: "announced", route: Route.default }); + } + await settle(); + expect(origin.broadcasts().peek().size).toBe(2); + sources.get("a/**")?.close(new Error("namespace rejected")); + await settle(); + expect([...origin.broadcasts().peek().keys()]).toEqual([Path.from("b")]); + expect(origin.discovery.peek()).toBe(false); + session.die(); + await settle(); + expect(origin.broadcasts().peek().size).toBe(0); + expect(origin.discovery.peek()).toBeUndefined(); + origin.close(); +}); diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index 66745398a9..f594d65fa5 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -6,7 +6,7 @@ import { type Dispose, race } from "@moq/signals"; import { isActive } from "../announced.ts"; import type { Dynamic, Producer as OriginProducer, RequestSlot } from "../origin.ts"; -import type * as Path from "../path.ts"; +import * as Path from "../path.ts"; import { wireOf } from "../wire.ts"; import type { Established } from "./established.ts"; @@ -35,6 +35,7 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi let detach = originWire.attach(conn.discovery); let dead = false; + let discovering = conn.discovery; void conn.closed.then(() => { dead = true; detach(); @@ -47,57 +48,61 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi return; } - // Hidden routes are mirrored too; each local reader opts in on its own. - const announced = conn.announced(undefined, { hidden: true }); - const inserted = new Map(); - - // End the stream the moment the session closes rather than waiting for the wire to - // error it, so the retractions below land promptly. - void conn.closed.then(() => announced.close()); - - void (async () => { - let failure: unknown; - try { - for (;;) { - const event = await announced.next(); - if (!event) break; - - if (isActive(event.kind)) { - const existing = inserted.get(event.prefix); - if (existing) { - existing.update(event.route); + for (const prefix of originWire.interests()) { + // Hidden routes are mirrored too; each local reader opts in on its own. + const announced = conn.announced(Path.Pattern.subtree(prefix), { hidden: true }); + const inserted = new Map(); + + // End the stream the moment the session closes rather than waiting for the wire to + // error it, so the retractions below land promptly. + void conn.closed.then(() => announced.close()); + + void (async () => { + let failure: unknown; + try { + for (;;) { + const event = await announced.next(); + if (!event) break; + if (!originWire.accepts(event.prefix)) continue; + + if (isActive(event.kind)) { + const existing = inserted.get(event.prefix); + if (existing) { + existing.update(event.route); + } else { + const handle = originWire.receive(event.prefix, event.route); + inserted.set(event.prefix, handle); + void drive(handle, conn); + } } else { - const handle = originWire.receive(event.prefix, event.route); - inserted.set(event.prefix, handle); - void drive(handle, conn); + const handle = inserted.get(event.prefix); + inserted.delete(event.prefix); + handle?.close(); } - } else { - const handle = inserted.get(event.prefix); - inserted.delete(event.prefix); - handle?.close(); + } + } catch (err) { + // The session died mid-stream, or the relay refused or reset the stream. The + // cleanup below retracts everything this stream fed either way. + failure = err; + } finally { + for (const handle of inserted.values()) handle.close(); + inserted.clear(); + announced.close(); + + // Discovery ended while the session lives, and nothing reopens the stream on this + // connection. Downgrade the attachment rather than leaving the origin claiming a + // discovery that no longer works: announcement-gated consumers would wait forever + // on a table this session can no longer fill. Now they fall back to standing + // requests, which this session still answers. + if (!dead && discovering) { + discovering = false; + console.warn("broadcast discovery failed; broadcasts resolve on request only.", failure); + detach(); + detach = originWire.attach(false); } } - } catch (err) { - // The session died mid-stream, or the relay refused or reset the stream. The - // cleanup below retracts everything this stream fed either way. - failure = err; - } finally { - for (const handle of inserted.values()) handle.close(); - inserted.clear(); - announced.close(); - - // Discovery ended while the session lives, and nothing reopens the stream on this - // connection. Downgrade the attachment rather than leaving the origin claiming a - // discovery that no longer works: announcement-gated consumers would wait forever - // on a table this session can no longer fill. Now they fall back to standing - // requests, which this session still answers. - if (!dead) { - console.warn("broadcast discovery failed; broadcasts resolve on request only.", failure); - detach(); - detach = originWire.attach(false); - } - } - })(); + })(); + } } /** diff --git a/js/net/src/connection/scope.test.ts b/js/net/src/connection/scope.test.ts new file mode 100644 index 0000000000..01bf4ab52a --- /dev/null +++ b/js/net/src/connection/scope.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from "bun:test"; +import * as Ietf from "../ietf/index.ts"; +import * as Lite from "../lite/index.ts"; +import { createMockTransportPair } from "../mock.ts"; +import { Producer } from "../origin.ts"; +import * as Path from "../path.ts"; +import { withTimeout } from "../util/timeout.ts"; +import { accept } from "./accept.ts"; +import { connect } from "./connect.ts"; + +for (const protocol of [Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_19]) { + test(`scoped origins discover multiple prefixes over ${protocol}`, async () => { + const pair = createMockTransportPair(protocol); + const url = new URL("https://localhost/test"); + const source = new Producer(); + const destination = new Producer(); + const publish = source.scope(Path.from("server"), new Path.Patterns([Path.Pattern.all()])); + const consume = destination.scope( + Path.from("client"), + new Path.Patterns(["room/**", "other/**"].map(Path.Pattern.parse)), + ); + const broadcasts = ["room/live", "room/.hidden", "other/live", "outside"].map((path) => { + const broadcast = publish.createBroadcast(Path.from(path)); + broadcast.announce(); + return broadcast; + }); + const [client, server] = await Promise.all([ + connect({ url, transport: pair.client, consume }), + accept({ url, transport: pair.server, publish: publish.consume() }), + ]); + const announced = destination.announced(undefined, { hidden: true }); + try { + const received: string[] = []; + for (let index = 0; index < 3; index++) { + const update = await withTimeout(announced.next(), 1000, "scoped announcement did not arrive"); + if (!update) throw new Error("announcement stream ended before replay"); + expect(update.kind).toBe("announced"); + received.push(update.prefix); + } + expect(received.sort()).toEqual(["client/other/live", "client/room/.hidden", "client/room/live"]); + expect([...consume.broadcasts().peek().keys()].sort()).toEqual([ + Path.from("other/live"), + Path.from("room/live"), + ]); + } finally { + announced.close(); + client.close(); + server.close(); + for (const broadcast of broadcasts) broadcast.close(); + source.close(); + destination.close(); + await Promise.all([client.closed, server.closed]); + } + }); +} diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index 488e41e273..d86b5e2180 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -1422,3 +1422,98 @@ test("announced filters by arbitrary patterns and reports captures", async () => broadcast.close(); origin.close(); }); + +test("a rooted producer shares the table and enforces its pattern union", async () => { + const origin = new Producer(); + const scoped = origin.scope(Path.from("tenant"), new Path.Patterns([Path.Pattern.parse("room/*")])); + const broadcast = publish(scoped, Path.from("room/alice")); + expect(origin.broadcasts().peek().has(Path.from("tenant/room/alice"))).toBe(true); + expect([...scoped.broadcasts().peek().keys()]).toEqual([Path.from("room/alice")]); + const request = scoped.consume().request(Path.from("room/alice")); + expect(request.path).toBe(Path.from("room/alice")); + expect(request.active.peek()).toBeDefined(); + expect(() => scoped.createBroadcast(Path.from("other"))).toThrow("outside the origin scope"); + expect(() => scoped.request(Path.from("room/alice/deep"))).toThrow("outside the origin scope"); + expect(() => scoped.dynamic(Path.from("other"))).toThrow("outside the origin scope"); + expect(() => scoped.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("other/**")]))).toThrow( + "do not overlap", + ); + expect(() => origin.scope(Path.empty(), new Path.Patterns())).toThrow("do not overlap"); + + const nested = scoped.scope(Path.from("room"), new Path.Patterns([Path.Pattern.all()])); + expect([...nested.broadcasts().peek().keys()]).toEqual([Path.from("alice")]); + expect(() => nested.createBroadcast(Path.from("alice/deep"))).toThrow("outside the origin scope"); + const announced = nested.announced(); + expect((await announced.next())?.prefix).toBe(Path.from("alice")); + announced.close(); + request.close(); + broadcast.close(); + origin.close(); +}); + +test("a scoped dynamic only serves allowed paths and presents relative requests", async () => { + const origin = new Producer(); + const scoped = origin.scope(Path.from("tenant"), new Path.Patterns([Path.Pattern.parse("room/*/chat")])); + const dynamic = scoped.dynamic(Path.from("room")); + expect(dynamic.prefix).toBe(Path.from("room")); + const denied = origin.request(Path.from("tenant/room/alice/video")); + expect(denied.unroutable.peek()).toBe(true); + const request = origin.request(Path.from("tenant/room/alice/chat")); + const requests = dynamic.requested(); + const pending = await requests.next(); + expect(pending.value?.path).toBe(Path.from("room/alice/chat")); + const broadcast = new BroadcastProducer(); + pending.value?.accept(broadcast); + expect(request.active.peek()).toBeDefined(); + dynamic.update({ cost: 2n }); + expect(origin.broadcasts().peek().get(Path.from("tenant/room"))?.cost.warm).toBe(2n); + await requests.return?.(); + dynamic.close(); + request.close(); + denied.close(); + broadcast.close(); + origin.close(); +}); + +test("scoped wire views filter and rebase advertisements and blind requests", () => { + const origin = new Producer(); + const scoped = origin.scope(Path.from("tenant"), new Path.Patterns([Path.Pattern.parse("room/**")])); + const local = publish(origin, Path.from("tenant/room/live")); + const hidden = publish(origin, Path.from("tenant/other/live")); + const allowed = origin.request(Path.from("tenant/room/missing")); + const denied = origin.request(Path.from("tenant/other/missing")); + expect([...(wireOf(scoped).requests.peek()?.keys() ?? [])]).toEqual([Path.from("room/missing")]); + expect([...(wireOf(scoped.consume()).advertised.peek()?.keys() ?? [])]).toEqual([Path.from("room/live")]); + expect(wireOf(scoped.consume()).local(Path.from("room/live"))).toBeDefined(); + expect(() => wireOf(scoped.consume()).local(Path.from("other/live"))).toThrow("outside the origin scope"); + allowed.close(); + denied.close(); + local.close(); + hidden.close(); + origin.close(); +}); + +test("a scoped dynamic is announced only to readers its scope can serve", () => { + const origin = new Producer(); + const dynamic = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("*/chat")])).dynamic(Path.empty()); + const chat = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/chat")])); + const video = origin.scope(Path.empty(), new Path.Patterns([Path.Pattern.parse("room/video")])); + expect([...chat.broadcasts().peek().keys()]).toEqual([Path.empty()]); + expect([...video.broadcasts().peek().keys()]).toEqual([]); + expect([...(wireOf(chat.consume()).advertised.peek()?.keys() ?? [])]).toEqual([Path.empty()]); + expect([...(wireOf(video.consume()).advertised.peek()?.keys() ?? [])]).toEqual([]); + dynamic.close(); + origin.close(); +}); + +test("a rooted reader presents the most specific covering route", () => { + const origin = new Producer(); + const narrow = origin.dynamic(Path.from("room/alice"), { cost: 9n }); + const broad = origin.dynamic(Path.from("room"), { cost: 1n }); + const rooted = origin.scope(Path.from("room/alice"), new Path.Patterns([Path.Pattern.all()])); + expect(rooted.broadcasts().peek().get(Path.empty())?.cost.warm).toBe(9n); + expect(wireOf(rooted.consume()).advertised.peek()?.get(Path.empty())?.route.cost.warm).toBe(9n); + broad.close(); + narrow.close(); + origin.close(); +}); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 3e9641828b..7a02699c94 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -22,6 +22,118 @@ import { type Advertised, registerWire, wireOf } from "./wire.ts"; export type { Cost, Hop, Route } from "./hop.ts"; export { isAnonymous } from "./hop.ts"; +/** The rooted permissions shared by a handle and every route it inserts. */ +class Scope { + static readonly all = new Scope(Path.empty()); + + readonly root: Path.Valid; + readonly allowed?: Path.Patterns; + + constructor(root: Path.Valid, allowed?: Path.Patterns) { + this.root = root; + this.allowed = allowed; + } + + narrow(root: Path.Valid, patterns: Path.Patterns): Scope { + const joined = Path.encode(Path.join(this.root, root)); + const rooted = patterns.rooted(joined); + const allowed = this.allowed?.intersect(rooted) ?? rooted; + if (allowed.size === 0) throw new Error("origin scopes do not overlap"); + return new Scope(joined, allowed); + } + + matches(path: Path.Valid): boolean { + return this.allowed?.matches(path) ?? true; + } + + path(path: Path.Valid): Path.Valid { + const joined = Path.encode(Path.join(this.root, path)); + if (!this.matches(joined)) throw new Error("path is outside the origin scope"); + return joined; + } + + prefix(prefix: Path.Valid): Path.Valid { + const joined = Path.encode(Path.join(this.root, prefix)); + if (this.allowed && !this.allowed.overlaps(Path.Pattern.subtree(joined))) { + throw new Error("prefix is outside the origin scope"); + } + return joined; + } + + patterns(pattern: Path.Pattern = Path.Pattern.all()): Path.Patterns { + const rooted = new Path.Patterns([pattern.rooted(this.root)]); + return this.allowed?.intersect(rooted) ?? rooted; + } + + /** Omit nested heads because the outer subscription already carries their routes. */ + heads(): Path.Valid[] { + if (!this.allowed) return [Path.empty()]; + const heads = [...new Set([...this.allowed.rebase(this.root)].map(scopeHead))].sort(); + return heads.filter((head) => !heads.some((other) => other !== head && Path.hasPrefix(other, head))); + } + + /** The exact paths within this scope, relative to its root. */ + projectPaths(values: ReadonlyMap | undefined): ReadonlyMap | undefined { + if (!values || this === Scope.all) return values; + const out = new Map(); + for (const [path, value] of values) { + if (!this.matches(path)) continue; + const relative = Path.stripPrefix(this.root, path); + if (relative !== null) out.set(relative, value); + } + return out; + } + + /** The advertised prefixes that may serve this scope, relative to its root. */ + projectRoutes( + values: ReadonlyMap | undefined, + ): ReadonlyMap | undefined { + if (!values || this === Scope.all) return values; + const out = new Map(); + const covering = new CoveringRoot(this.root); + for (const [path, value] of values) { + if (this.allowed && ![...this.allowed].some((pattern) => advertOverlaps(value, path, pattern))) continue; + const relative = covering.relative(path); + if (relative !== undefined) out.set(relative, value); + } + return out; + } +} + +/** Whether the route advertised at `prefix` may serve any path `pattern` admits. */ +function advertOverlaps(advert: Advertised, prefix: Path.Valid, pattern: Path.Pattern): boolean { + return advert.claim ? advert.claim.overlaps(pattern) : scopeOverlaps(pattern, prefix); +} + +/** The paths `entry` may serve beneath `prefix`, when its producer is scoped. */ +function claimOf(entry: RouteEntry, prefix: Path.Valid): Path.Patterns | undefined { + return entry.scope.allowed?.intersect(new Path.Patterns([Path.Pattern.subtree(prefix)])); +} + +/** + * Presents advertised prefixes relative to a root. Every prefix at or above the root + * collapses to the empty path, where the most specific one wins, since that is the route + * a request beneath the root resolves through. + */ +class CoveringRoot { + readonly #root: Path.Valid; + #covering?: Path.Valid; + + constructor(root: Path.Valid) { + this.#root = root; + } + + /** The presented path for `path`, or undefined when it is outside the root or a broader cover. */ + relative(path: Path.Valid): Path.Valid | undefined { + const relative = Path.stripPrefix(this.#root, path); + if (relative !== null && relative !== Path.empty()) return relative; + if (relative === null && !Path.hasPrefix(path, this.#root)) return undefined; + if (this.#covering !== undefined && !Path.hasPrefix(this.#covering, path)) return undefined; + this.#covering = path; + return Path.empty(); + } +} + /** * One requested path: the notify node for everything watching it. * @@ -65,6 +177,7 @@ export interface RequestSlot { */ export interface RouteEntry { readonly identity: object; + readonly scope: Scope; readonly route: Signal; readonly originated: boolean; readonly server?: ServeState; @@ -107,6 +220,12 @@ function noCapacity(): StreamError { /** A served route from {@link Producer.dynamic}: the queue a handler drains. */ class ServeState { + readonly root: Path.Valid; + + constructor(root: Path.Valid) { + this.root = root; + } + queue = new Signal([]); pending = new Map(); served = new Map(); @@ -126,7 +245,7 @@ class ServeState { if (this.pending.has(path)) return; const live = this.served.get(path); if (live && live.closed.peek() === undefined) return; - const request = makeRequest(path, this); + const request = makeRequest(Path.stripPrefix(this.root, path) ?? Path.empty(), this); this.pending.set(path, request); this.queue.mutate((queue) => { queue.push(request); @@ -134,33 +253,35 @@ class ServeState { } accept(request: Request, front: broadcast.Consumer): void { - if (this.closed.peek() !== undefined || this.pending.get(request.path) !== request) { + const path = Path.join(this.root, request.path); + if (this.closed.peek() !== undefined || this.pending.get(path) !== request) { front.close(); return; } - this.pending.delete(request.path); - const existing = this.served.get(request.path); + this.pending.delete(path); + const existing = this.served.get(path); if (existing && existing.closed.peek() === undefined) { front.close(); - this.onChange(request.path); + this.onChange(path); this.settled.update((n) => n + 1); return; } - this.served.set(request.path, front); + this.served.set(path, front); void front.closed.then(() => { - if (this.served.get(request.path) !== front) return; - this.served.delete(request.path); - this.onChange(request.path); + if (this.served.get(path) !== front) return; + this.served.delete(path); + this.onChange(path); }); - this.onChange(request.path); + this.onChange(path); this.settled.update((n) => n + 1); } reject(request: Request, err: Error): void { - if (this.pending.get(request.path) !== request) return; - this.pending.delete(request.path); - if (this.demanding.has(request.path)) this.rejected.set(request.path, err); - this.onReject(request.path, err); + const path = Path.join(this.root, request.path); + if (this.pending.get(path) !== request) return; + this.pending.delete(path); + if (this.demanding.has(path)) this.rejected.set(path, err); + this.onReject(path, err); this.settled.update((n) => n + 1); } @@ -246,7 +367,7 @@ class OriginState { for (const [path, routes] of this.routes.peek() ?? []) { const entry = preferredEntry(routes); if (!entry) continue; - const value = { identity: entry.identity, route: entry.route.peek() }; + const value = { identity: entry.identity, route: entry.route.peek(), claim: claimOf(entry, path) }; remote.set(path, value); available.set(path, value.route); } @@ -358,7 +479,8 @@ class OriginState { const next = new Map(); for (const [prefix, entries] of routes ?? []) { const mine = preferredEntry(entries, received); - if (mine) next.set(prefix, { identity: mine.identity, route: mine.route.peek() }); + if (mine) + next.set(prefix, { identity: mine.identity, route: mine.route.peek(), claim: claimOf(mine, prefix) }); } // A local broadcast and an originated dynamic at one path compete on cost, as they do for requests. for (const [path, route] of advertised ?? []) { @@ -389,7 +511,10 @@ class OriginState { let best: RouteEntry | undefined; for (const [prefix, entries] of this.routes.peek() ?? []) { if (!Path.hasPrefix(prefix, path)) continue; - const entry = preferredEntry(entries, skip); + const entry = preferredEntry( + entries, + (candidate) => !candidate.scope.matches(path) || (skip?.(candidate) ?? false), + ); if (!entry) continue; if (bestPrefix === undefined || prefix.length > bestPrefix.length) { bestPrefix = prefix; @@ -509,27 +634,47 @@ export interface RequestOptions { */ export class Producer implements Table { #state = new OriginState(); + #scope = Scope.all; + #requests?: Getter | undefined>; // The reader backing the passthroughs, so holding a Producer never requires the // consume().x() stutter for everyday reads. One instance, so `discovery` keeps its // identity across reads. - #reader = makeConsumer(this.#state); + #reader = makeConsumer(this.#state, this.#scope); constructor() { const thisProducer = this; registerWire(this, { receive: (prefix, route) => this.#receive(prefix, route), + interests: () => this.#scope.heads(), + accepts: (prefix) => + !this.#scope.allowed || + this.#scope.allowed.overlaps(Path.Pattern.subtree(Path.join(this.#scope.root, prefix))), attach: (discovery) => this.#attach(discovery), expect: () => this.#expect(), get requests() { - return thisProducer.#state.requests; + if (thisProducer.#scope === Scope.all) return thisProducer.#state.requests; + thisProducer.#requests ??= new Derived([thisProducer.#state.requests], (requests) => + thisProducer.#scope.projectPaths(requests), + ); + return thisProducer.#requests; }, changed: () => this.#changed(), - answer: (path, front) => this.#answer(path, front), + answer: (path, front) => this.#answer(this.#scope.path(path), front), routes: (path) => wireOf(this.#reader).routes(path), }); } + /** Narrow this handle to patterns beneath root, presenting paths relative to that root. */ + scope(root: Path.Valid, patterns: Path.Patterns): Producer { + const scope = this.#scope.narrow(root, patterns); + const producer = new Producer(); + producer.#state = this.#state; + producer.#scope = scope; + producer.#reader = makeConsumer(this.#state, scope); + return producer; + } + /** * Settles once the origin closes: `null` on a clean close, or the abort {@link Error}. * Peek it synchronously (`undefined` while open), observe it reactively, or `await` it. @@ -552,6 +697,7 @@ export class Producer implements Table { * a remote route at the same path on cost, winning ties. */ createBroadcast(path: Path.Valid): broadcast.Producer { + path = this.#scope.path(path); const created = this.#state.created; if (!created) throw new Error("origin is closed"); @@ -638,10 +784,12 @@ export class Producer implements Table { } #insertRoute(prefix: Path.Valid, route: Route, originated: boolean): Dynamic { - const server = new ServeState(); + prefix = this.#scope.prefix(prefix); + const server = new ServeState(this.#scope.root); server.onChange = (path) => this.#state.refresh(path); const entry: RouteEntry = { identity: {}, + scope: this.#scope, route: new Signal(route), originated, server, @@ -777,7 +925,7 @@ export class Producer implements Table { /** A read handle for this origin, the side a connection's `publish` option borrows. */ consume(): Consumer { - return makeConsumer(this.#state); + return makeConsumer(this.#state, this.#scope); } /** Whether every attached session announces into the table; see {@link Consumer.discovery}. */ @@ -838,7 +986,7 @@ export class Producer implements Table { // Constructs a Consumer from within this module without exposing a public constructor // that would leak the unexported OriginState. Assigned in the class's static block. -let makeConsumer: (state: OriginState) => Consumer; +let makeConsumer: (state: OriginState, scope: Scope) => Consumer; // Same for Requesting: a public constructor would let a caller forge a handle that no origin // ever registered, whose lifecycle guarantees are then false. `@internal` alone would not @@ -940,9 +1088,11 @@ export class Requesting { */ export class Consumer { #state: OriginState; + #scope: Scope; - private constructor(state: OriginState) { + private constructor(state: OriginState, scope: Scope) { this.#state = state; + this.#scope = scope; // True only when every attached session announces. One session that cannot means the // table is an incomplete picture, so a consumer gated on it has to keep its blind // fallback: the paths only that session carries never reach the table at all. @@ -950,20 +1100,20 @@ export class Consumer { total === 0 ? undefined : discovery === total, ); registerWire(this, { - routes: (path) => this.#routes(path), - get broadcasts() { - return state.local; - }, - get advertised() { - return state.originated; - }, - local: (path) => this.#local(path), - demand: (path) => this.#demand(path), + routes: (path) => this.#routes(scope.path(path)), + broadcasts: + scope === Scope.all ? state.local : new Derived([state.local], (local) => scope.projectPaths(local)), + advertised: + scope === Scope.all + ? state.originated + : new Derived([state.originated], (routes) => scope.projectRoutes(routes)), + local: (path) => this.#local(scope.path(path)), + demand: (path) => this.#demand(scope.path(path)), }); } static { - makeConsumer = (state) => new Consumer(state); + makeConsumer = (state, scope) => new Consumer(state, scope); } /** Settles once the origin closes; see {@link Producer.closed}. */ @@ -1025,12 +1175,14 @@ export class Consumer { * feeds from more than one connection. */ request(path: Path.Valid, options: RequestOptions = {}): Requesting { + const relative = path; + path = this.#scope.path(path); const requests = this.#state.requests.peek(); if (!requests) { // Closed origin: a request that can never resolve, and says so. const closed = new Once(); return makeRequesting( - path, + relative, new Signal(undefined), getter(true), closed, @@ -1116,7 +1268,7 @@ export class Consumer { (front, answerers, ended) => ended !== undefined || (!front && answerers === 0), ); - return makeRequesting(path, active, unroutable, closed, () => { + return makeRequesting(relative, active, unroutable, closed, () => { // Releases this request's handle; the route itself belongs to the table. released = true; unsubscribeDiscovery(); @@ -1158,10 +1310,11 @@ export class Consumer { */ broadcasts(scope?: Path.Pattern, options?: announce.Options): Getter> { const hidden = options?.hidden ?? false; - if (!scope) return hidden ? this.#state.available : this.#state.visible; + if (!scope && this.#scope === Scope.all) return hidden ? this.#state.available : this.#state.visible; + const patterns = this.#scope.patterns(scope); return new Derived([this.#state.available], () => { const routes = new Map(); - for (const [path, entry] of this.#listed(scope, hidden)) routes.set(path, entry.route); + for (const [path, entry] of this.#listed(patterns, hidden)) routes.set(path, entry.route); return routes; }); } @@ -1176,29 +1329,42 @@ export class Consumer { */ announced(scope: Path.Pattern = Path.Pattern.all(), options?: announce.Options): announce.Consumer { const producer = new announce.Producer(); - void this.#runAnnounced(producer, scope, options?.hidden ?? false); + void this.#runAnnounced(producer, this.#scope.patterns(scope), options?.hidden ?? false); return producer.consume(); } /** One snapshot shared by map readers and announcement-stream diffing. */ - #listed(scope: Path.Pattern, hidden: boolean): Map { + #listed(patterns: Path.Patterns, hidden: boolean): Map { const next = new Map(); + const covering = new CoveringRoot(this.#scope.root); const { remote, local } = this.#state.snapshot(); - const head = scopeHead(scope); - for (const [path, entry] of remote) { - if (!scopeOverlaps(scope, path)) continue; - if (!hidden && hiddenBelow(head, path)) continue; - next.set(path, { identity: entry.identity, route: entry.route, captures: scopeCaptures(scope, path) }); - } - for (const [path, entry] of local) { - if (!scope.matches(path)) continue; - if (!hidden && hiddenBelow(head, path)) continue; - next.set(path, { identity: entry.identity, route: entry.route, captures: scopeCaptures(scope, path) }); + const scopes = [...patterns] + .sort((a, b) => Path.compareSpecificity(b.specificity(), a.specificity())) + .map((pattern) => ({ pattern, head: scopeHead(pattern) })); + for (const [table, exact] of [ + [remote, false], + [local, true], + ] as const) { + for (const [path, entry] of table) { + const scope = scopes.find( + ({ pattern, head }) => + (exact ? pattern.matches(path) : advertOverlaps(entry, path, pattern)) && + (hidden || !hiddenBelow(head, path)), + ); + if (!scope) continue; + const relative = covering.relative(path); + if (relative === undefined) continue; + next.set(relative, { + identity: entry.identity, + route: entry.route, + captures: scopeCaptures(scope.pattern, path), + }); + } } return next; } - async #runAnnounced(producer: announce.Producer, scope: Path.Pattern, hidden: boolean): Promise { + async #runAnnounced(producer: announce.Producer, patterns: Path.Patterns, hidden: boolean): Promise { // Keyed by the presented path (from the origin, not the scope), valued by identity // plus route. Diffing identity rather than mere presence means a republish emits a // retraction then a fresh announcement; a re-price of the same identity emits an @@ -1212,7 +1378,7 @@ export class Consumer { const routes = this.#state.routes.peek(); if (local === undefined && advertisedLocal === undefined && routes === undefined) break; - const next = this.#listed(scope, hidden); + const next = this.#listed(patterns, hidden); for (const [path, snap] of active) { const cur = next.get(path); @@ -1335,7 +1501,7 @@ export class Dynamic { #closed = false; private constructor(prefix: Path.Valid, entry: RouteEntry, state: OriginState, retract: Dispose) { - this.prefix = prefix; + this.prefix = Path.stripPrefix(entry.scope.root, prefix) ?? Path.empty(); this.#entry = entry; this.#state = state; this.#retract = retract; @@ -1350,7 +1516,7 @@ export class Dynamic { if (this.#closed) throw new Error("dynamic is closed"); this.#entry.route.set(Route.normalize(route)); this.#state.rebuildOriginated(); - this.#state.refreshPrefix(this.prefix); + this.#state.refreshPrefix(Path.join(this.#entry.scope.root, this.prefix)); this.#state.routes.mutate(() => {}); } diff --git a/js/net/src/wire.ts b/js/net/src/wire.ts index 3348b172bc..1df7e62458 100644 --- a/js/net/src/wire.ts +++ b/js/net/src/wire.ts @@ -25,6 +25,10 @@ export interface Broadcast { /** The protocol-facing operations behind an origin producer. */ export interface OriginProducer { + /** Minimal root-relative namespace prefixes that cover this origin scope. */ + interests(): readonly Path.Valid[]; + /** Whether an advertised prefix overlaps the handle's allowed paths. */ + accepts(prefix: Path.Valid): boolean; receive( prefix: Path.Valid, route?: Route | { hops?: Route["hops"]; cost?: Route["cost"] | bigint }, @@ -51,6 +55,8 @@ export interface OriginConsumer { export interface Advertised { readonly identity: object; readonly route: Route; + /** The absolute paths a scoped route may serve beneath its prefix; unset for the whole subtree. */ + readonly claim?: Path.Patterns; } /** The protocol-facing operation behind an established session. */ diff --git a/quest/m1/README.md b/quest/m1/README.md index a4d51473d9..b663650619 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -32,7 +32,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` is the one way to end a broadcast in every language, a permanent retraction that leaves in-flight tracks alone - [Relay peer set](/quest/m1/relay-peer-set.md) - a wire consumer tells a client hop from a peer hop, and every mesh credential can mark a peer - [CLI inspection](/quest/m1/cli-inspect/README.md) - `moq ls` lists what is live and `moq fetch` reads a group over MoQ, and a guide shows how to inspect a relay -- [JS origin scope](/quest/m1/js-origin-scope.md) - a scoped `@moq/net` origin subscribes only to its allowed prefixes - [Session close](/quest/m1/session-close.md) - a graceful session end withdraws announces and waits one second for the ack - [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust - [Bindings caught up](/quest/m1/announce-live-bindings.md) - moq-ffi, libmoq, and every wrapper yield the same flat announce event, `Live` included diff --git a/quest/m1/js-origin-scope.md b/quest/m1/js-origin-scope.md deleted file mode 100644 index 9bc8530c60..0000000000 --- a/quest/m1/js-origin-scope.md +++ /dev/null @@ -1,27 +0,0 @@ -# [M] JS origin scope drives announce interest - -## Goal - -A `@moq/net` origin scoped to a prefix asks the peer only for that prefix. -An origin that was never scoped still asks for every namespace. A relay that -ignores an empty `SUBSCRIBE_NAMESPACE` then delivers a namespace the caller -actually named. - -## Plan - -`Producer.scope(root, patterns)` matches Rust `origin::Producer::scope`: the -root is stripped from paths, and the patterns are what the handle may publish -and subscribe under. `forwardAnnounced` sends one `SUBSCRIBE_NAMESPACE`, and -the moq-lite equivalent, per literal head of those patterns, the same rule as -Rust `interest_prefixes`. Hidden routes stay opted in on those subscriptions. - -An unscoped origin still allows `**`. Its head is the empty prefix, so the -session sends one subscription for everything, which is today's behavior. - -`doc/concept/moq-lite.md` says the prefixes a session asks for are the -origin's scope. No new page. - -## Related - -- [JS caught up](/quest/m1/js-announce-caught-up.md) - when the replay of those prefixes has landed -- [IETF announce count](/quest/m1/ietf-announce-count.md) - how an IETF consumer knows that replay is finished From aec87cca5692839e08861c7b650557eab3585f68 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 08:17:16 -0700 Subject: [PATCH 39/45] fix(net): reject undeclared subscription ends (#4231) Co-authored-by: GPT-6 --- .github/workflows/interop.yml | 4 ++ doc/concept/moq-lite.md | 5 +- js/net/src/ietf/subscriber.ts | 26 +++++----- js/net/src/ietf/tail.test.ts | 9 ++++ js/net/src/lite/subscriber.ts | 10 ++-- js/net/src/lite/tail.test.ts | 21 ++++++++ quest/m1/README.md | 1 - quest/m1/js-bare-fin.md | 18 ------- rs/moq-net/src/ietf/subscriber.rs | 47 +++++++++++++++++ rs/moq-net/src/lib.rs | 2 + rs/moq-net/src/lite/subscriber.rs | 85 +++++++++++++++++++++++++++++++ rs/moq-net/src/test_interop.rs | 16 ++++++ test/interop/README.md | 12 +++++ test/interop/bare-fin.ts | 84 ++++++++++++++++++++++++++++++ test/justfile | 4 ++ 15 files changed, 307 insertions(+), 37 deletions(-) delete mode 100644 quest/m1/js-bare-fin.md create mode 100644 rs/moq-net/src/test_interop.rs create mode 100644 test/interop/bare-fin.ts diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index 7116bd39c1..8cfe5d49fa 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -82,6 +82,10 @@ jobs: run: nix develop --command bash -c "bun install --frozen-lockfile && bunx playwright install --with-deps chromium" shell: bash -leo pipefail {0} + - name: Subscription termination interop + run: nix develop --command just test bare-fin + shell: bash -leo pipefail {0} + - name: Interop (full matrix) run: nix develop --command just test interop --all shell: bash -leo pipefail {0} diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 3c3f4e5345..9f14e3659a 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -67,7 +67,10 @@ can be neither discovered nor requested. A broadcast published locally competes with remote routes to its path on cost like any other route, winning only a tie. Retracting a route (an unannounce, or the peer's `ANNOUNCE_END`) stops new requests from resolving through it but leaves subscriptions already -in flight alone: each track runs to its own end, the publisher's FIN or reset. +in flight alone: each track runs to its own end or failure. On moq-lite 05 and +newer, a clean end requires `SUBSCRIBE_END` before the publisher's FIN. A FIN +without that declaration fails the subscription with `ProtocolViolation`; older +moq-lite versions use FIN alone. moq-transport requires `PUBLISH_DONE` before FIN. moq-transport sessions behave the same when a namespace is withdrawn. ### Hidden broadcasts diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 0294b35e3d..b36ccfebbd 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -656,26 +656,24 @@ export class Subscriber { * An error status aborts the track with it. A clean one leaves streams in flight, since * QUIC does not order them, so wait until the Stream Count many have been read, or a * bounded grace for the ones that never arrive (the draft says to use a timeout). The count - * is only a hint: a peer may send 0 regardless, so 0 waits out the grace. A request stream - * that ends without one ends the track the same way. + * is only a hint: a peer may send 0 regardless, so 0 waits out the grace. A request + * stream that FINs without PUBLISH_DONE is a protocol violation. */ async #runPublishDone(stream: Stream, subscription: Subscription): Promise { const version = this.#session.version; - let count: bigint | undefined; - if (!(await stream.reader.done())) { - const typeId = await stream.reader.u53(); - if (typeId !== PublishDone.id) { - throw new ProtocolViolation(`unexpected message on a subscription: 0x${typeId.toString(16)}`); - } - const done = await PublishDone.decode(stream.reader, version); - if (!publishDoneClean(done.statusCode, version)) { - throw new Error(`publish done: status=0x${done.statusCode.toString(16)} reason=${done.reasonPhrase}`); - } - count = done.streamCount; + if (await stream.reader.done()) throw new ProtocolViolation("subscribe stream ended without PUBLISH_DONE"); + const typeId = await stream.reader.u53(); + if (typeId !== PublishDone.id) { + throw new ProtocolViolation(`unexpected message on a subscription: 0x${typeId.toString(16)}`); + } + const done = await PublishDone.decode(stream.reader, version); + if (!publishDoneClean(done.statusCode, version)) { + throw new Error(`publish done: status=0x${done.statusCode.toString(16)} reason=${done.reasonPhrase}`); } + const count = done.streamCount; const { tail, track } = subscription; - const complete = () => count !== undefined && count > 0n && BigInt(tail.streams) >= count; + const complete = () => count > 0n && BigInt(tail.streams) >= count; await tail.settle(complete, TAIL_GRACE_MS, track.closed); } diff --git a/js/net/src/ietf/tail.test.ts b/js/net/src/ietf/tail.test.ts index d7a505f642..da233686e7 100644 --- a/js/net/src/ietf/tail.test.ts +++ b/js/net/src/ietf/tail.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { ProtocolViolation } from "../error.ts"; import type { Consumer as GroupConsumer } from "../group.ts"; import { createMockTransportPair } from "../mock.ts"; import * as Path from "../path.ts"; @@ -69,6 +70,7 @@ async function subscribed() { return { subscriber, reader, + fin: () => peer.writer.close(), done: async (statusCode: number, streamCount: bigint) => { await peer.writer.u53(PublishDone.id); await new PublishDone({ statusCode, streamCount, reasonPhrase: "done" }).encode(peer.writer, VERSION); @@ -175,3 +177,10 @@ test("END_OF_TRACK at object 0 ends the track before its group, which never exis expect(await reader.recvGroup()).toBeUndefined(); expect(reader.final()).toBe(2); }); + +test("a bare FIN without PUBLISH_DONE aborts the track", async () => { + const { reader, fin } = await subscribed(); + await fin(); + expect(await reader.closed).toBeInstanceOf(ProtocolViolation); + await expect(reader.recvGroup()).rejects.toThrow(ProtocolViolation); +}); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 913abf2998..b031f4044f 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -817,12 +817,16 @@ export class Subscriber { // Reads SUBSCRIBE_START/END/DROP on the subscribe stream until FIN (lite-05+), recording // the range the tail is accounted against. SUBSCRIBE_END declares the track's end right - // away, so a consumer learns it before the last groups arrive. Resolves on FIN and rejects - // when the stream is reset, so the track ends with the publisher's error rather than cleanly. + // away, so a consumer learns it before the last groups arrive. The publisher must declare + // the end before FIN; resets and malformed responses reject with their failure. async #runResponses(stream: Stream, entry: SubscribeEntry): Promise { for (;;) { const resp = await decodeSubscribeResponseMaybe(stream.reader, this.version); - if (!resp) return; + if (!resp) { + if (entry.end === undefined) + throw new ProtocolViolation("subscribe stream ended without SUBSCRIBE_END"); + return; + } if ("start" in resp) { entry.start = resp.start.group; diff --git a/js/net/src/lite/tail.test.ts b/js/net/src/lite/tail.test.ts index b74636c55c..4edfe88c5c 100644 --- a/js/net/src/lite/tail.test.ts +++ b/js/net/src/lite/tail.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { ProtocolViolation, StreamCode, StreamError } from "../error.ts"; import type { Consumer as GroupConsumer } from "../group.ts"; import { randomHop } from "../hop.ts"; import { createMockTransportPair } from "../mock.ts"; @@ -74,6 +75,7 @@ async function subscribed(maxAge = GRACE) { reader, respond: (resp: SubscribeResponse) => encodeSubscribeResponse(sub.writer, resp, VERSION), fin: () => sub.writer.close(), + reset: (error: Error) => sub.writer.reset(error), }; } @@ -217,3 +219,22 @@ test("a SUBSCRIBE_END below a group already received aborts the track", async () const closed = await reader.closed; expect(closed).toBeInstanceOf(Error); }); + +for (const started of [false, true]) { + test(`a bare FIN ${started ? "after SUBSCRIBE_START" : "without responses"} aborts the track`, async () => { + const { reader, respond, fin } = await subscribed(); + if (started) await respond({ start: new SubscribeStart(0) }); + await fin(); + expect(await reader.closed).toBeInstanceOf(ProtocolViolation); + await expect(reader.recvGroup()).rejects.toThrow(ProtocolViolation); + }); +} + +test("a subscribe stream reset preserves the publisher's failure", async () => { + const { reader, reset } = await subscribed(); + reset(new StreamError(StreamCode.NotFound)); + const closed = await reader.closed; + expect(closed).toBeInstanceOf(StreamError); + expect((closed as StreamError).code).toBe(StreamCode.NotFound); + await expect(reader.recvGroup()).rejects.toThrow(StreamError); +}); diff --git a/quest/m1/README.md b/quest/m1/README.md index b663650619..32ebdb63cc 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -21,7 +21,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached - [Dropped sources](/quest/m1/dropped-sources.md) - consumers see the producer's real error on every end path, never `Dropped` -- [JS bare FIN](/quest/m1/js-bare-fin.md) - a `@moq/net` subscriber aborts a track whose subscribe stream FINs before its declared end, like Rust - [Track tail interop](/quest/m1/track-tail-interop.md) - a Rust publisher ending a track with a group in flight is read to its end by the JS subscriber, and the reverse, in `just test interop` - [Interop flakes](/quest/m1/interop-flakes.md) - the interop harness passes with other runs sharing the machine - [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race diff --git a/quest/m1/js-bare-fin.md b/quest/m1/js-bare-fin.md deleted file mode 100644 index 212b0cdb53..0000000000 --- a/quest/m1/js-bare-fin.md +++ /dev/null @@ -1,18 +0,0 @@ -# [S] JS bare FIN - -## Goal - -A `@moq/net` subscriber aborts a track whose subscribe stream FINs before the -publisher declared its end, over moq-lite and IETF, as Rust does since #4083. -A bare FIN is a failed request, never a clean end. - -## Plan - -- The drafts agree: draft-19 section 3.3.2 treats a FIN before the required - messages (PUBLISH_DONE) as a failure, and the moq-lite draft has a publisher - FIN only after SUBSCRIBE_END. -- Abort with the error Rust uses, so both languages report the same thing. Add - an interop case where a publisher FINs without declaring an end, in both - directions. - -The clean-end path this tightens landed in #4086. diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index e01227f073..4ef0adc042 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -3045,6 +3045,53 @@ mod tests { use super::*; + async fn check_publish_fin(responses: Vec, clean: bool) { + use crate::lite::test_transport::ScriptedSession; + use crate::transport::poll::Session as _; + let mut session = ScriptedSession::eof(responses); + let (_, recv) = session.open_bi().await.unwrap(); + let mut reader = Reader::new(recv, Version::Draft19); + let result = Subscriber::::read_publish_done(&mut reader, Version::Draft19).await; + if clean { + assert_eq!(result.unwrap(), 0); + } else { + assert!(matches!(result, Err(Error::ProtocolViolation))); + } + } + + fn fin_responses(clean: bool) -> Vec { + use crate::coding::Encode; + let mut responses = Vec::new(); + if clean { + ietf::PublishDone::ID.encode(&mut responses, Version::Draft19).unwrap(); + ietf::PublishDone { + request_id: None, + status_code: ietf::PublishDoneStatus::TrackEnded.code(Version::Draft19), + stream_count: 0, + reason_phrase: "done".into(), + } + .encode(&mut responses, Version::Draft19) + .unwrap(); + } + responses + } + + #[tokio::test(start_paused = true)] + async fn bare_fin_requires_publish_done() { + for clean in [false, true] { + check_publish_fin(fin_responses(clean), clean).await; + } + } + + #[tokio::test(start_paused = true)] + #[ignore = "requires Bun; run by just test bare-fin in interop CI"] + async fn bare_fin_interop() { + for clean in [false, true] { + let responses = crate::test_interop::fin("moqt-19", false, clean, fin_responses(clean)); + check_publish_fin(responses, clean).await; + } + } + /// The tokio-backed test runtime. Its transport parameter is phantom, so one /// type serves every fake session in this module. diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index 0ec7ba1348..732ce41989 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -87,6 +87,8 @@ pub mod path; mod recv; mod setup; mod tail; +#[cfg(test)] +mod test_interop; mod util; mod version; diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 11fcce659f..69996539ca 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -1383,6 +1383,88 @@ mod tests { const VERSION: Version = Version::Lite05; + /// Drive the subscriber with a peer's response bytes followed by FIN. + async fn check_subscription_fin(version: Version, responses: Vec, clean: bool) { + let session = crate::lite::test_transport::ScriptedSession::eof(responses); + let origin = origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let subscriber = Subscriber::new(SubscriberConfig { + runtime: crate::time::Clock::tokio(), + session, + origin, + recv_bandwidth: None, + version, + peer_setup: Default::default(), + peer_hop: None, + cost: None, + going_away: Default::default(), + }); + let serve = TrackServe { + subscriber, + path: Path::new("room").to_owned(), + name: "video".to_string(), + }; + let broadcast = crate::broadcast::Info::new().produce(); + let request = broadcast.reserve_track("video").unwrap(); + let serving = ServeLoop::new(&serve, request, Default::default(), Some(Timescale::default())); + let mut reader = broadcast + .consume() + .track("video") + .unwrap() + .subscribe(None) + .await + .unwrap(); + let mut running = TrackServeRun { + serve, + state: TrackRunState::Serve(serving), + }; + assert!( + kio::Task::poll(&mut running, &kio::Waiter::noop()).is_ready(), + "{version:?}: FIN must settle immediately" + ); + if clean { + assert!(reader.recv_group().await.unwrap().is_none()); + } else { + assert!(matches!(reader.recv_group().await, Err(Error::ProtocolViolation))); + } + } + + fn fin_responses(version: Version, started: bool, clean: bool) -> Vec { + let mut responses = Vec::new(); + if started { + lite::SubscribeResponse::Start(lite::SubscribeStart { group: 0 }) + .encode(&mut responses, version) + .unwrap(); + } + if clean { + lite::SubscribeResponse::End(lite::SubscribeEnd { group: 0, streams: 0 }) + .encode(&mut responses, version) + .unwrap(); + } + responses + } + + #[tokio::test(start_paused = true)] + async fn bare_fin_requires_subscribe_end() { + for version in [Version::Lite05, Version::Lite06, Version::Lite07] { + for (started, clean) in [(false, false), (true, false), (false, true)] { + check_subscription_fin(version, fin_responses(version, started, clean), clean).await; + } + } + } + + #[tokio::test(start_paused = true)] + #[ignore = "requires Bun; run by just test bare-fin in interop CI"] + async fn bare_fin_interop() { + for version in [Version::Lite05, Version::Lite06, Version::Lite07] { + for (started, clean) in [(false, false), (true, false), (false, true)] { + let responses = fin_responses(version, started, clean); + let responses = + crate::test_interop::fin(crate::Version::from(version).alpn(), started, clean, responses); + check_subscription_fin(version, responses, clean).await; + } + } + } + /// Removing a subscription both stops delivery and releases the session's handle /// on the producer, so the track (its cached groups, its stats subscription) ends /// rather than outliving the subscription it belonged to. @@ -3633,6 +3715,9 @@ impl ServeLoop { continue; } Ok(None) => { + if serve.subscriber.version.has_track_stream() && active.end.is_none() { + return Poll::Ready(ServeEnd::GiveBack(Error::ProtocolViolation)); + } tracing::info!(broadcast = %serve.subscriber.log_path(&serve.path), track = %serve.name, "subscribe complete"); // Upstream FIN'd the subscription: the publisher only FINs // once the track's final sequence is known and every group diff --git a/rs/moq-net/src/test_interop.rs b/rs/moq-net/src/test_interop.rs new file mode 100644 index 0000000000..031113d1a2 --- /dev/null +++ b/rs/moq-net/src/test_interop.rs @@ -0,0 +1,16 @@ +//! Response bytes cross the language boundary; each subscriber runs on a mock transport. + +pub(crate) fn fin(version: &str, started: bool, clean: bool, responses: Vec) -> Vec { + let input = serde_json::json!({ "version": version, "started": started, "clean": clean, "responses": responses }); + let output = std::process::Command::new("bun") + .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/../../test/interop/bare-fin.ts")) + .arg(input.to_string()) + .output() + .expect("Bun must be installed for the bare-FIN interop test"); + assert!( + output.status.success(), + "JS subscriber failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("JS publisher returned response bytes") +} diff --git a/test/interop/README.md b/test/interop/README.md index e41a0d7179..ab969fc1a0 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -76,6 +76,9 @@ just test interop --all # Pick your own axes: just test interop --publishers rust,python --subscribers rust,c,js-native-bun +# Subscription termination: Rust/JS response bytes over in-memory transports. +just test bare-fin + # Negative control: no publisher, every subscriber must time out. just test interop-negative @@ -183,3 +186,12 @@ clients/ `.github/workflows/interop.yml` runs the full matrix nightly (and on demand, and on PRs that touch `test/interop/`). A red cell means a real interop break in the current tree. + +## Subscription termination + +`just test bare-fin` exchanges encoded subscription responses between Rust and +JS, then feeds them into each implementation's subscriber over an in-memory +transport. It checks bare FIN before and after SUBSCRIBE\_START on lite-05/06/07, +and FIN without PUBLISH\_DONE on IETF draft-19. Clean-end controls use the same +path. This tests response interoperability, not network delivery or relay behavior. +The interop workflow runs it alongside the real-transport matrix. diff --git a/test/interop/bare-fin.ts b/test/interop/bare-fin.ts new file mode 100644 index 0000000000..fc899157e1 --- /dev/null +++ b/test/interop/bare-fin.ts @@ -0,0 +1,84 @@ +/** Exchange subscription response bytes with Rust using in-memory transports. */ +import assert from "node:assert/strict"; +import { ProtocolViolation } from "../../js/net/src/error.ts"; +import { randomHop } from "../../js/net/src/hop.ts"; +import { NativeSession } from "../../js/net/src/ietf/adapter.ts"; +import { PublishDone } from "../../js/net/src/ietf/publish.ts"; +import { Subscribe as IetfSubscribe, SubscribeOk } from "../../js/net/src/ietf/subscribe.ts"; +import { Subscriber as IetfSubscriber } from "../../js/net/src/ietf/subscriber.ts"; +import { Version as IetfVersion } from "../../js/net/src/ietf/version.ts"; +import { StreamId } from "../../js/net/src/lite/stream.ts"; +import { encodeSubscribeResponse, Subscribe, SubscribeEnd, SubscribeStart } from "../../js/net/src/lite/subscribe.ts"; +import { Subscriber as LiteSubscriber } from "../../js/net/src/lite/subscriber.ts"; +import { Track, TrackInfo } from "../../js/net/src/lite/track.ts"; +import { Version as LiteVersion } from "../../js/net/src/lite/version.ts"; +import { createMockTransportPair } from "../../js/net/src/mock.ts"; +import * as Path from "../../js/net/src/path.ts"; +import { Stream, Writer } from "../../js/net/src/stream.ts"; +import type { Subscriber as TrackSubscriber } from "../../js/net/src/track.ts"; + +// Stdout is the response-byte channel back to Rust. +console.debug = console.error; +const input: { version: string; started: boolean; clean: boolean; responses: number[] } = JSON.parse(process.argv[2]); +const pair = createMockTransportPair(input.version); +const bytes: number[] = []; +const output = new Writer( + new WritableStream({ + write: (chunk) => { + bytes.push(...chunk); + }, + }), +); +const path = Path.from("room"); +const lite: Record = { + "moq-lite-05": LiteVersion.DRAFT_05, + "moq-lite-06": LiteVersion.DRAFT_06, + "moq-lite-07-wip": LiteVersion.DRAFT_07, +}; +const version = lite[input.version]; +let reader: TrackSubscriber; +let peer: Stream | undefined; +if (version !== undefined) { + const subscriber = new LiteSubscriber(pair.client, version, randomHop()); + reader = subscriber.consume(path).track("video").subscribe(); + const info = await Stream.accept(pair.server); + assert(info); + assert.equal(await info.reader.u53(), StreamId.Track); + await Track.decode(info.reader, version); + await new TrackInfo({}).encode(info.writer, version); + info.close(); + peer = await Stream.accept(pair.server); + assert(peer); + assert.equal(await peer.reader.u53(), StreamId.Subscribe); + await Subscribe.decode(peer.reader, version); + if (input.started) await encodeSubscribeResponse(output, { start: new SubscribeStart(0) }, version); + if (input.clean) await encodeSubscribeResponse(output, { end: new SubscribeEnd(0) }, version); +} else { + assert.equal(input.version, "moqt-19"); + const version = IetfVersion.DRAFT_19; + const subscriber = new IetfSubscriber({ session: new NativeSession(pair.client, version, true) }); + reader = subscriber.consume(path).track("video").subscribe(); + peer = await Stream.accept(pair.server, version); + assert(peer); + assert.equal(await peer.reader.u53(), IetfSubscribe.id); + const request = await IetfSubscribe.decode(peer.reader, version); + await peer.writer.u53(SubscribeOk.id); + await new SubscribeOk({ requestId: request.requestId, trackAlias: 0n }).encode(peer.writer, version); + if (input.clean) { + await output.u53(PublishDone.id); + await new PublishDone({ statusCode: 0x2, streamCount: 0n, reasonPhrase: "done" }).encode(output, version); + } +} +// These bytes came from Rust's encoder; only the omitted end message is malformed. +if (input.responses.length) await peer.writer.write(Uint8Array.from(input.responses)); +await peer.writer.close(); +const closed = await reader.closed; +if (input.clean) { + assert.equal(closed, null); + assert.equal(await reader.recvGroup(), undefined); +} else { + assert(closed instanceof ProtocolViolation); + await assert.rejects(reader.recvGroup(), ProtocolViolation); +} +// Rust feeds the JS-encoded bytes into its own subscriber, followed by FIN. +console.log(JSON.stringify(bytes)); diff --git a/test/justfile b/test/justfile index 91ba59ba80..5cb8ba2342 100644 --- a/test/justfile +++ b/test/justfile @@ -96,3 +96,7 @@ ts-eit *args: # Browser coverage for the @moq/wasm bindings. wasm *args: ./wasm/run.sh {{ args }} + +# Cross-language subscription termination over in-memory transports. +bare-fin: + just rs test -p moq-net --lib bare_fin_interop --run-ignored all From 7cb6e5733a41fa82413e37e97d8b6908528fa190 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 08:35:02 -0700 Subject: [PATCH 40/45] feat(mux): forward importer discontinuities through publishers (#4239) Co-authored-by: GPT-6 Co-authored-by: Claude Opus 5.5 --- dart/moq/lib/src/aliases.dart | 2 +- dart/moq_ffi/lib/src/moq.dart | 25 +++ dart/moq_ffi/lib/src/uniffi_runtime.dart | 2 + doc/bin/gstreamer.md | 5 + doc/lib/c/index.md | 2 + doc/lib/dart/index.md | 2 + doc/lib/go/index.md | 2 + doc/lib/kt/index.md | 2 + doc/lib/py/index.md | 2 + doc/lib/rs/moq-mux.md | 9 +- doc/lib/swift/index.md | 2 + go/wrapper/publish.go | 5 + .../kotlin/dev/moq/Aliases.kt | 2 +- py/moq-rs/moq/publish.py | 4 + py/moq-rs/tests/test_local.py | 2 + quest/m1/README.md | 1 - quest/m1/import-discontinuity.md | 20 --- rs/libmoq/src/api.rs | 12 ++ rs/libmoq/src/publish.rs | 7 + rs/libmoq/src/test.rs | 3 + rs/moq-ffi/src/producer.rs | 11 ++ rs/moq-ffi/src/test.rs | 2 + rs/moq-gst/src/sink/imp.rs | 66 +++++++- rs/moq-gst/src/sink/pad.rs | 145 +++++++++++++++++- rs/moq-mux/src/codec/av1/import.rs | 5 + rs/moq-mux/src/codec/flac/import.rs | 5 + rs/moq-mux/src/codec/mp3.rs | 5 + rs/moq-mux/src/codec/vp8/import.rs | 5 + rs/moq-mux/src/codec/vp9/import.rs | 5 + rs/moq-mux/src/import/track.rs | 67 ++++++++ swift/Sources/Moq/Media.swift | 5 + 31 files changed, 397 insertions(+), 35 deletions(-) delete mode 100644 quest/m1/import-discontinuity.md diff --git a/dart/moq/lib/src/aliases.dart b/dart/moq/lib/src/aliases.dart index 8727301bc0..fa998168f9 100644 --- a/dart/moq/lib/src/aliases.dart +++ b/dart/moq/lib/src/aliases.dart @@ -85,7 +85,7 @@ typedef GroupProducer = MoqGroupProducer; /// The read side of a single group: yields timestamped raw frames. typedef GroupConsumer = MoqGroupConsumer; -/// The write side of a media track fed pre-framed payloads. +/// The write side of a media track; discontinuity() marks a break between pre-framed payloads. typedef MediaProducer = MoqMediaProducer; /// The write side of a media track fed a raw byte stream, with frame boundaries inferred. diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 4ada2c2a1c..7016449c5d 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -6871,6 +6871,7 @@ class FfiConverterMoqGroupRequest { abstract class MoqMediaProducerInterface { void cut(); MoqTrackDemand demand(); + void discontinuity(); void finish(); void flush({required int timestampUs}); String name(); @@ -6923,6 +6924,15 @@ class MoqMediaProducer implements MoqMediaProducerInterface { ); } + void discontinuity() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqmediaproducer_discontinuity( + uniffiClonePointer(), + status, + ); + }, moqExceptionErrorHandler); + } + void finish() { return rustCall((status) { uniffi_moq_ffi_fn_method_moqmediaproducer_finish( @@ -10912,6 +10922,14 @@ external Pointer uniffi_moq_ffi_fn_method_moqmediaproducer_demand( Pointer uniffiStatus, ); +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqmediaproducer_discontinuity( + Pointer ptr, + Pointer uniffiStatus, +); + @Native, Pointer)>( assetId: _uniffiAssetId, ) @@ -12309,6 +12327,9 @@ external int uniffi_moq_ffi_checksum_method_moqmediaproducer_cut(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqmediaproducer_demand(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqmediaproducer_discontinuity(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqmediaproducer_finish(); @@ -12909,6 +12930,10 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqmediaproducer_demand() != 44491) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqmediaproducer_discontinuity() != + 37570) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqmediaproducer_finish() != 38480) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } diff --git a/dart/moq_ffi/lib/src/uniffi_runtime.dart b/dart/moq_ffi/lib/src/uniffi_runtime.dart index ab87a64ed3..1eefdf6293 100644 --- a/dart/moq_ffi/lib/src/uniffi_runtime.dart +++ b/dart/moq_ffi/lib/src/uniffi_runtime.dart @@ -8,6 +8,7 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; + import "package:ffi/ffi.dart"; class UniffiInternalError implements Exception { @@ -247,6 +248,7 @@ mixin FfiConverterPrimitive on FfiConverter { @override T lower(T value) => value; } + Uint8List createUint8ListFromInt(int value) { int length = value.bitLength ~/ 8 + 1; if (length != 4 && length != 8) { diff --git a/doc/bin/gstreamer.md b/doc/bin/gstreamer.md index c3695b214d..fa2cc877e5 100644 --- a/doc/bin/gstreamer.md +++ b/doc/bin/gstreamer.md @@ -83,6 +83,11 @@ default, for file, demuxed, and network media: their arrival reflects the disk or the network, not the original encoder, and a GStreamer segment cannot tell the two apart. Text and opaque pads refuse it. +After a pause, flush, or changed TIME segment, the next media buffer starts a new +timeline epoch and resets the handoff baseline. The pause does not inflate +advertised jitter, and previously measured maxima remain. Resumed timestamps +must continue forward on the broadcast media clock. + ## moqsrc Pads are named by kind and appear as the catalog announces renditions: diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 3614be1ef1..f5d152c8af 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -63,6 +63,8 @@ if (session < 0) For a locally encoded media track, call `moq_publish_media_flush(media, timestamp_us)` after `moq_publish_media_frame` with the same broadcast-clock PTS. The monotonic handoff time is sampled inside libmoq. Do not call it for file, pipe, or network imports; those remain clock-free. Invalid handles and unrepresentable timestamps return a negative error code. +Call `moq_publish_media_discontinuity(media)` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. + ## Connection stats Every field in `moq_connection_stats` carries a matching `_valid` flag, diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index 564d4e06ed..62f4f630ec 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -109,6 +109,8 @@ catalog and container types are there, so already-encoded frames flow through `MediaProducer.flush(timestampUs: ...)` records the handoff of a locally encoded frame on the broadcast media clock. Call it after `writeFrame` only for live encoder output; file, pipe, and network imports stay clock-free. `MediaProducer` aliases the generated FFI object, so its method is available directly. +Call `media.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. + ## Connection stats `session.stats()` returns a `ConnectionStats` snapshot. Each field is `null` diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index 001f5a50e4..d910f5d17c 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -71,6 +71,8 @@ broadcast.Finish() // keep the producer reachable while publishing, then finis For locally encoded media, call `MediaProducer.Flush(timestampUs)` after `WriteFrame` with the same broadcast-clock PTS. It measures catalog jitter at the transport handoff. File, pipe, and network imports should omit `Flush`; built-in encoders observe their own output. +Call `media.Discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. + The three advertising operations: `client.CreateBroadcast(path)` (or `origin.CreateBroadcast`) returns an unannounced producer, invisible to everyone; `broadcast.Announce(route)` / `broadcast.Unannounce()` own that exact-path diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index a241ad6b0c..5ba62e7061 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -53,6 +53,8 @@ Moq.connect("https://relay.example.com").use { moq -> `MediaProducer.flush(timestampUs)` records a locally encoded frame's transport handoff on the broadcast media clock. Call it after `writeFrame` for live encoder output; omit it for file, pipe, and network imports. `MediaProducer` is a typealias, so the generated method is available directly. +Call `media.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. + The three advertising operations: `moq.createBroadcast(path)` (or `origin.createBroadcast`) returns an unannounced producer, invisible to everyone; `broadcast.announce(route)` / `broadcast.unannounce()` own that exact-path diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index a41cb7255b..4d9eac1b70 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -71,6 +71,8 @@ asyncio.run(main()) For already-encoded live output, call `audio.flush(timestamp_us)` after each `audio.write_frame` with the same broadcast-clock PTS. It samples the transport handoff for catalog jitter. File, pipe, and network imports should omit `flush`; raw-pixel and PCM encoders inside the binding measure their own output. +Call `audio.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. + The three advertising operations, as the other bindings spell them: `client.create_broadcast(path)` (or `OriginProducer.create_broadcast`) returns an unannounced producer, invisible to everyone; `broadcast.announce(route)` / diff --git a/doc/lib/rs/moq-mux.md b/doc/lib/rs/moq-mux.md index 7be69723be..be641e9619 100644 --- a/doc/lib/rs/moq-mux.md +++ b/doc/lib/rs/moq-mux.md @@ -43,9 +43,12 @@ measure batch span or reorder delay for jitter. Locally encoded frames call `container::Producer::flush(timestamp, Instant::now())`; jitter is the spread above that track's own recent minimum lateness, and delay is how far that minimum trails the earliest track on the same catalog. Both are published as -soon as they rise. Generic imports remain clock-free. Invalid or decreasing -jitter or delay is rejected before the edit is retained, including while the -initial catalog is reserved. +soon as they rise. `import::Track::discontinuity()` marks a source seek or +pause, clears partial input, and restarts the flush baseline without lowering +advertised values. It forwards the container timeline marker, so resumed +timestamps must continue forward on the broadcast clock. Generic imports remain +clock-free. Invalid or decreasing jitter or delay is rejected before the edit is +retained, including while the initial catalog is reserved. Codec importers propagate catalog and media errors through their configuration and frame-writing methods. diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index a9aa8b3c44..cbbcefa6bc 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -57,6 +57,8 @@ session.shutdown() For already-encoded live output, call `audio.flush(timestampUs:)` after `writeFrame` with the same broadcast-clock PTS. It measures catalog jitter at the transport handoff. File, pipe, and network imports should omit `flush`; built-in encoders observe their own output. +Call `audio.discontinuity()` when the source seeks, pauses, or changes its time base. It publishes a timeline marker and restarts handoff measurement without lowering advertised jitter. Resume with timestamps that continue forward on the broadcast media clock; this does not permit timestamp rewinds. + The three advertising operations: `session.publish.createBroadcast(path:)` returns an unannounced producer, invisible to everyone; `broadcast.announce(route:)` / `broadcast.unannounce()` own that exact-path advertisement; diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index 6eccc6d8f6..a746212627 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -365,6 +365,11 @@ func (m *MediaProducer) Flush(timestampUs uint64) error { return m.inner.Flush(timestampUs) } +// Discontinuity marks a timeline break and restarts handoff measurement, preserving advertised jitter. +func (m *MediaProducer) Discontinuity() error { + return m.inner.Discontinuity() +} + // Cut draws a group boundary here. // // Audio has no boundary of its own (every packet is independently decodable), so this is diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index 38883f6cda..9c8639bce8 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -62,7 +62,7 @@ typealias GroupProducer = uniffi.moq.MoqGroupProducer typealias GroupConsumer = uniffi.moq.MoqGroupConsumer // Media (codec-aware) producers and consumers. -/** The write side of a media track fed pre-framed payloads. */ +/** The write side of a media track; discontinuity() marks a break between pre-framed payloads. */ typealias MediaProducer = uniffi.moq.MoqMediaProducer /** The write side of a media track fed a raw byte stream, with frame boundaries inferred. */ typealias MediaStreamProducer = uniffi.moq.MoqMediaStreamProducer diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index cbaf29ba13..10248a2bc9 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -102,6 +102,10 @@ def flush(self, timestamp_us: int) -> None: """ self._inner.flush(timestamp_us) + def discontinuity(self) -> None: + """Mark a timeline break and restart handoff measurement, preserving advertised jitter.""" + self._inner.discontinuity() + def cut(self) -> None: """Draw a group boundary here. diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index ad004a566f..004dc26d51 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -146,6 +146,8 @@ def test_publish_media_cut_and_seek(): # The same boundary, with the next group explicitly numbered. media.write_frame(b"opus frame", 60_000) media.seek(42) + media.discontinuity() + media.write_frame(b"resumed opus frame", 100_000) media.finish() broadcast.finish() diff --git a/quest/m1/README.md b/quest/m1/README.md index 32ebdb63cc..da6ecf33ba 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -38,7 +38,6 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [IETF announce count](/quest/m1/ietf-announce-count.md) - an opt-in moq-transport extension carries the replay count, so IETF announce consumers go live without a timer - [Publish delay](/quest/m1/publish-delay.md) - js/publish encoders advertise `delay` behind the earliest rendition, like moq-mux -- [Import discontinuity](/quest/m1/import-discontinuity.md) - a seek or pause resets the flush jitter baseline, from moqsink, libmoq, and moq-ffi - [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter` - [Moxygen compatibility](/quest/m1/moxygen/README.md) - one subgroup per group, whole-group FETCH, and one datagram per group, never a full moxygen pass - [JavaScript FETCH](/quest/m1/js-fetch.md) - generic on-demand group serving and IETF FETCH for browser publishers diff --git a/quest/m1/import-discontinuity.md b/quest/m1/import-discontinuity.md deleted file mode 100644 index c5c2fd6025..0000000000 --- a/quest/m1/import-discontinuity.md +++ /dev/null @@ -1,20 +0,0 @@ -# [S] Import discontinuity - -## Goal - -A publisher that seeks or pauses tells its importer so, and the flush jitter -measurement restarts instead of counting the break. `moqsink` does it on a -seek, and C and FFI publishers can do it too. - -## Plan - -- `moq_mux::container::Producer::discontinuity()` already resets the flush - baseline (`catalog/estimate.rs`). `import::Track` gains a `discontinuity()` - that forwards to it, and the codec importers without one gain it too. Only - the baseline resets; advertised values are never lowered. -- `moqsink` calls it when a pad re-anchors after a flush or a new segment - (`rs/moq-gst/src/sink/pad.rs`). Test that a seek on an `encoder` pad does - not raise the advertised jitter. -- Expose it as `moq_publish_media_discontinuity` in libmoq and as - `discontinuity()` in moq-ffi and its hand-written wrappers, per the - Cross-Package Sync table. This is additive, so it lands on `main`. diff --git a/rs/libmoq/src/api.rs b/rs/libmoq/src/api.rs index 66e353a052..13d263077a 100644 --- a/rs/libmoq/src/api.rs +++ b/rs/libmoq/src/api.rs @@ -2212,6 +2212,18 @@ pub extern "C" fn moq_publish_media_flush(media: u32, timestamp_us: u64) -> i32 }) } +/// Mark a timeline break and restart handoff measurement without lowering advertised jitter. +/// +/// Publishes a discontinuity marker; resumed frames must continue the broadcast media clock. +/// Returns zero on success, or a negative code on failure. +#[unsafe(no_mangle)] +pub extern "C" fn moq_publish_media_discontinuity(media: u32) -> i32 { + ffi::enter(move || { + let media = ffi::parse_id(media)?; + State::lock().publish.media_discontinuity(media) + }) +} + /// Replace the catalog properties shared by every video rendition. /// /// Rotation is clockwise and normalized to the nearest quarter turn. A field whose matching `has_*` flag is false is removed from the next catalog update. diff --git a/rs/libmoq/src/publish.rs b/rs/libmoq/src/publish.rs index fd7db2f681..951093f8a7 100644 --- a/rs/libmoq/src/publish.rs +++ b/rs/libmoq/src/publish.rs @@ -245,6 +245,13 @@ impl Publish { Ok(()) } + /// Mark a timeline break on this media importer. + pub fn media_discontinuity(&mut self, media: Id) -> Result<(), Error> { + let track = self.media.get_mut(media).ok_or(Error::MediaNotFound)?; + track.discontinuity()?; + Ok(()) + } + /// Draw a group boundary on this media importer. /// /// This ends the open group; the next frame starts a new one. Audio has no boundary of its own diff --git a/rs/libmoq/src/test.rs b/rs/libmoq/src/test.rs index 535d3574d8..9919eada4e 100644 --- a/rs/libmoq/src/test.rs +++ b/rs/libmoq/src/test.rs @@ -1953,10 +1953,13 @@ fn media_cut_bounds_audio_groups() { 0 ); assert_eq!(moq_publish_media_seek(media, 42), 0); + assert_eq!(moq_publish_media_discontinuity(media), 0); // Both report a missing importer rather than panicking on an unknown id. assert!(moq_publish_media_flush(9999, 0) < 0); assert!(moq_publish_media_flush(media, u64::MAX) < 0); + assert!(moq_publish_media_discontinuity(9999) < 0); + assert!(moq_publish_media_discontinuity(0) < 0); assert!(moq_publish_media_cut(9999) < 0); assert!(moq_publish_media_seek(9999, 0) < 0); diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 04822ca67c..54386f575a 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -971,6 +971,17 @@ impl MoqMediaProducer { Ok(()) } + /// Mark a timeline break and restart handoff measurement without lowering advertised jitter. + /// + /// Publishes a discontinuity marker; resumed frames must continue the broadcast media clock. + pub fn discontinuity(&self) -> Result<(), MoqError> { + let _guard = crate::ffi::enter(); + let mut guard = self.inner.lock().unwrap(); + let media = guard.as_mut().ok_or(MoqError::Closed)?; + media.import.discontinuity()?; + Ok(()) + } + /// Draw a group boundary here. /// /// Audio has no boundary of its own (every packet is independently decodable), so this is the diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 265dee6162..f07af2e68f 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -1368,7 +1368,9 @@ async fn dynamic_track_request_can_publish_media() { assert_eq!(frame.payload, payload); assert_eq!(frame.timestamp_us, 20_000); + media.discontinuity().unwrap(); media.finish().unwrap(); + assert!(matches!(media.discontinuity(), Err(MoqError::Closed))); } #[tokio::test] diff --git a/rs/moq-gst/src/sink/imp.rs b/rs/moq-gst/src/sink/imp.rs index e07f6c0178..d1c7d5685c 100644 --- a/rs/moq-gst/src/sink/imp.rs +++ b/rs/moq-gst/src/sink/imp.rs @@ -1022,7 +1022,15 @@ impl MoqSink { /// Close the gate: an EOS earned from here on waits for the next entry to PLAYING. fn leave_playing(&self) { - self.control.lock().unwrap().playing = false; + let mut control = self.control.lock().unwrap(); + if !std::mem::replace(&mut control.playing, false) { + return; + } + // Running time stops in PAUSED while the encoder handoff clock keeps advancing. + for pad in self.obj().sink_pads() { + let pad = pad.downcast::().expect("sink pad type"); + pad.lifecycle().media.discontinuity(); + } } fn publish_finished(&self, finished: Finished) { @@ -1092,6 +1100,62 @@ mod tests { glib::Object::builder::().build() } + #[test] + fn a_playing_pause_cycle_does_not_raise_encoder_jitter() { + gst::init().unwrap(); + let sink = sink(); + sink.set_property("url", "https://127.0.0.1:1"); + sink.set_property("broadcast", "pause-test"); + let pad = sink + .request_pad_simple("sink_0") + .unwrap() + .downcast::() + .unwrap(); + sink.set_state(gst::State::Paused).unwrap(); + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast, moq_mux::catalog::Config::default()).unwrap(); + let caps = gst::Caps::builder("audio/x-opus") + .field("rate", 48_000i32) + .field("channels", 2i32) + .build(); + assert!(matches!( + pad.lifecycle().media.observe_caps( + &broadcast, + &catalog, + ProducerOptions::new(&caps).with_track("audio").with_encoder(true) + ), + CapsOutcome::Active(_) + )); + let segment = gst::FormattedSegment::::new(); + pad.lifecycle().media.observe_segment(segment.upcast()); + sink.set_state(gst::State::Playing).unwrap(); + let anchor = Instant::now(); + for (pts, arrival) in [(0, 0), (20, 120), (40, 6_000), (60, 6_020)] { + if pts == 40 { + sink.set_state(gst::State::Paused).unwrap(); + sink.set_state(gst::State::Playing).unwrap(); + } + assert_eq!( + pad.lifecycle() + .media + .push_buffer( + Bytes::from_static(b"opus frame"), + Some(gst::ClockTime::from_mseconds(pts)), + None, + None, + anchor + Duration::from_millis(arrival) + ) + .unwrap(), + PushOutcome::Published + ); + } + assert_eq!( + catalog.snapshot().audio.renditions["audio"].jitter, + Some(Duration::from_millis(100)) + ); + sink.set_state(gst::State::Null).unwrap(); + } + fn spec(element: &super::super::MoqSink, name: &str) -> glib::ParamSpec { element.find_property(name).unwrap() } diff --git a/rs/moq-gst/src/sink/pad.rs b/rs/moq-gst/src/sink/pad.rs index 23ecdb1a33..dcbc84bec9 100644 --- a/rs/moq-gst/src/sink/pad.rs +++ b/rs/moq-gst/src/sink/pad.rs @@ -49,13 +49,27 @@ struct Media { /// irregularly a local encoder delivers. Imports leave it off: their arrival times describe the /// file or network, not the encoder. encoder: bool, + /// Apply a timeline break before the next valid frame, using the normal write error path. + discontinuity: bool, + /// A video break closed the group, and a pause resumes mid-GOP, so deltas drop until a keyframe + /// opens the next one. + keyframe: bool, } impl Media { - /// Publish one frame at `micros` on the media clock, handed over at `now`. - fn write(&mut self, data: &Bytes, micros: u64, now: Instant) -> Result<()> { + /// Publish one frame at `micros` on the media clock, handed over at `now`. Returns false when a + /// delta frame is dropped while waiting for the keyframe after a break. + fn write(&mut self, data: &Bytes, micros: u64, now: Instant) -> Result { + if std::mem::take(&mut self.discontinuity) { + self.track.discontinuity()?; + self.keyframe = !self.audio; + } let ts = hang::container::Timestamp::from_micros(micros).ok(); - self.track.decode(data, ts)?; + match self.track.decode(data, ts) { + Err(moq_mux::Error::MissingKeyframe(_)) if self.keyframe => return Ok(false), + result => result?, + } + self.keyframe = false; // One group (one QUIC stream) per audio packet, so the relay forwards it without waiting for // the next. if self.audio { @@ -66,7 +80,7 @@ impl Media { let ts = ts.context("encoder frame timestamp out of range")?; self.track.flush(ts, now)?; } - Ok(()) + Ok(true) } } @@ -464,7 +478,13 @@ impl Pad { } other => anyhow::bail!("unsupported caps: {other}"), }; - self.track = Some(Sink::Media(Box::new(Media { track, audio, encoder }))); + self.track = Some(Sink::Media(Box::new(Media { + track, + audio, + encoder, + discontinuity: false, + keyframe: false, + }))); self.caps = Some(caps.clone()); Ok(name) } @@ -590,7 +610,14 @@ impl Pad { self.segment_info = Some(info); match classify_segment(prev.as_ref(), &info) { Ok(()) => { - self.segment = segment.downcast::().ok(); + let segment = segment.downcast::().ok(); + if self.segment.is_some() + && self.segment != segment + && let Some(Sink::Media(media)) = self.track.as_mut() + { + media.discontinuity = true; + } + self.segment = segment; self.state = PadState::Active; } Err(reason) => { @@ -606,13 +633,23 @@ impl Pad { /// Re-anchor on FLUSH. A flushing seek rewinds running time, so the timeline must restart: dropping /// the segment moves the pad to NoSegment (the next SEGMENT is accepted fresh via `prev = None`). The - /// producer is kept (FLUSH is not EOS); the codec's partial-AU reset is a documented follow-up. + /// producer is kept (FLUSH is not EOS); its next frame declares the break and resets partial input. pub fn flush(&mut self) { + self.discontinuity(); self.state = PadState::NoSegment; self.segment = None; self.segment_info = None; } + /// Restart measurement on the next frame while retaining the current segment. + pub fn discontinuity(&mut self) { + if self.segment.is_some() + && let Some(Sink::Media(media)) = self.track.as_mut() + { + media.discontinuity = true; + } + } + /// Maps a buffer PTS to a MoQ timestamp without enforcing frame-level monotonicity: frames arrive in /// decode order and B-frames carry non-monotonic presentation timestamps, so a PTS regression is /// normal reordering. Timeline breaks are caught at the SEGMENT level (the `Invalid` state). @@ -666,7 +703,13 @@ impl Pad { match timestamp { Ok(micros) => { let result: Result<()> = match self.track.as_mut().expect("track present") { - Sink::Media(media) => media.write(&data, micros, now), + Sink::Media(media) => match media.write(&data, micros, now) { + Ok(false) => { + gst::debug!(CAT, "dropping delta frame until the keyframe after a break"); + return Ok(PushOutcome::Dropped); + } + result => result.map(|_| ()), + }, Sink::Text(text) => match std::str::from_utf8(&data) { // A cue with no duration would never be dismissed, so drop it rather than pin it // on screen; the demuxer supplies one for every real subtitle sample. @@ -1629,6 +1672,92 @@ mod tests { assert_eq!(jitter("import"), None, "an import's arrival never reaches the catalog"); } + #[test] + fn encoder_seek_does_not_measure_the_pause_as_jitter() { + gst::init().unwrap(); + for boundary in ["segment", "flush", "pause"] { + let (broadcast, catalog) = producers(); + let mut pad = Pad::new(); + pad.observe_caps( + &broadcast, + &catalog, + producer_options(&h264_caps(), Some("encoder")).with_encoder(true), + ); + let anchor = Instant::now(); + for (pts, arrival) in [(0, 0), (33, 33), (66, 166)] { + pad.observe_segment(time_segment()); + assert_eq!( + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::from_mseconds(pts)), + None, + None, + anchor + std::time::Duration::from_millis(arrival) + ) + .unwrap(), + PushOutcome::Published + ); + } + let before = catalog.snapshot().video.renditions["encoder"].jitter; + assert_eq!(before, Some(std::time::Duration::from_millis(100))); + match boundary { + "flush" => pad.flush(), + "pause" => pad.discontinuity(), + _ => {} + } + // Seek in the source while keeping the broadcast media clock moving forward. + for (pts, arrival) in [(30_000, 6_000), (30_033, 6_033)] { + let pts = if boundary == "pause" { + // Pausing preserves the segment while the running clock stops. + pts - 29_000 + } else { + pad.observe_segment(time_segment_at(30_000, 1_000)); + pts + }; + assert_eq!( + pad.push_buffer( + h264_keyframe_au(), + Some(gst::ClockTime::from_mseconds(pts)), + None, + None, + anchor + std::time::Duration::from_millis(arrival) + ) + .unwrap(), + PushOutcome::Published + ); + } + assert_eq!( + catalog.snapshot().video.renditions["encoder"].jitter, + before, + "boundary={boundary}" + ); + } + } + + // A pause resumes mid-GOP: the break closed the group, so deltas drop until the next keyframe + // instead of invalidating the pad. + #[test] + fn video_pause_drops_deltas_until_the_next_keyframe() { + gst::init().unwrap(); + let (broadcast, catalog) = producers(); + let mut pad = Pad::new(); + pad.observe_caps(&broadcast, &catalog, producer_options(&h264_caps(), Some("video"))); + pad.observe_segment(time_segment()); + let delta = Bytes::from_static(&[0, 0, 0, 1, 0x61, 0xe0, 0x12, 0x34]); + let now = Instant::now(); + let push = |pad: &mut Pad, data: Bytes, pts: u64| { + pad.push_buffer(data, Some(gst::ClockTime::from_mseconds(pts)), None, None, now) + .unwrap() + }; + assert_eq!(push(&mut pad, h264_keyframe_au(), 0), PushOutcome::Published); + assert_eq!(push(&mut pad, delta.clone(), 33), PushOutcome::Published); + pad.discontinuity(); + assert_eq!(push(&mut pad, delta.clone(), 66), PushOutcome::Dropped); + assert_eq!(push(&mut pad, delta.clone(), 100), PushOutcome::Dropped); + assert_eq!(push(&mut pad, h264_keyframe_au(), 133), PushOutcome::Published); + assert_eq!(push(&mut pad, delta, 166), PushOutcome::Published); + } + // Text and opaque tracks carry no codec jitter, so asking them to measure one is a mistake to report // rather than a setting to ignore. #[test] diff --git a/rs/moq-mux/src/codec/av1/import.rs b/rs/moq-mux/src/codec/av1/import.rs index dac785c759..913e41312b 100644 --- a/rs/moq-mux/src/codec/av1/import.rs +++ b/rs/moq-mux/src/codec/av1/import.rs @@ -224,6 +224,11 @@ impl Import { Ok(()) } + /// Mark a timeline break and restart measurement without lowering advertised values. + pub fn discontinuity(&mut self) -> crate::Result<()> { + self.track.discontinuity() + } + /// Close the current group and open the next one at `sequence`. pub fn seek(&mut self, sequence: u64) -> Result<()> { self.track.seek(sequence)?; diff --git a/rs/moq-mux/src/codec/flac/import.rs b/rs/moq-mux/src/codec/flac/import.rs index 39d68fa925..a2564adc44 100644 --- a/rs/moq-mux/src/codec/flac/import.rs +++ b/rs/moq-mux/src/codec/flac/import.rs @@ -70,6 +70,11 @@ impl Import { Ok(()) } + /// Mark a timeline break and restart measurement without lowering advertised values. + pub fn discontinuity(&mut self) -> crate::Result<()> { + self.track.discontinuity() + } + /// Close the current group and open the next one at `sequence`. pub fn seek(&mut self, sequence: u64) -> crate::Result<()> { self.track.seek(sequence)?; diff --git a/rs/moq-mux/src/codec/mp3.rs b/rs/moq-mux/src/codec/mp3.rs index c3cf45ac4e..f1cd26c703 100644 --- a/rs/moq-mux/src/codec/mp3.rs +++ b/rs/moq-mux/src/codec/mp3.rs @@ -158,6 +158,11 @@ impl Import { Ok(()) } + /// Mark a timeline break and restart measurement without lowering advertised values. + pub fn discontinuity(&mut self) -> crate::Result<()> { + self.track.discontinuity() + } + /// Close the current group and open the next one at `sequence`. pub fn seek(&mut self, sequence: u64) -> crate::Result<()> { self.track.seek(sequence)?; diff --git a/rs/moq-mux/src/codec/vp8/import.rs b/rs/moq-mux/src/codec/vp8/import.rs index 7eabb0a2f3..01837a89b9 100644 --- a/rs/moq-mux/src/codec/vp8/import.rs +++ b/rs/moq-mux/src/codec/vp8/import.rs @@ -141,6 +141,11 @@ impl Import { Ok(()) } + /// Mark a timeline break and restart measurement without lowering advertised values. + pub fn discontinuity(&mut self) -> crate::Result<()> { + self.track.discontinuity() + } + /// Close the current group and open the next one at `sequence`. pub fn seek(&mut self, sequence: u64) -> crate::Result<()> { self.track.seek(sequence)?; diff --git a/rs/moq-mux/src/codec/vp9/import.rs b/rs/moq-mux/src/codec/vp9/import.rs index 71f64b6433..8369e14c3e 100644 --- a/rs/moq-mux/src/codec/vp9/import.rs +++ b/rs/moq-mux/src/codec/vp9/import.rs @@ -141,6 +141,11 @@ impl Import { Ok(()) } + /// Mark a timeline break and restart measurement without lowering advertised values. + pub fn discontinuity(&mut self) -> crate::Result<()> { + self.track.discontinuity() + } + /// Close the current group and open the next one at `sequence`. pub fn seek(&mut self, sequence: u64) -> crate::Result<()> { self.track.seek(sequence)?; diff --git a/rs/moq-mux/src/import/track.rs b/rs/moq-mux/src/import/track.rs index 45212b9e46..4430b6d179 100644 --- a/rs/moq-mux/src/import/track.rs +++ b/rs/moq-mux/src/import/track.rs @@ -460,6 +460,44 @@ impl Track { } } + /// Mark a timeline break, clearing partial frames and restarting handoff measurement. + /// + /// Publishes a marker without lowering advertised values; resumed timestamps must continue forward. + pub fn discontinuity(&mut self) -> Result<()> { + self.group_start = None; + match self.kind { + TrackKind::Avc3 { + ref mut split, + ref mut import, + } => { + split.reset(); + import.discontinuity() + } + TrackKind::Avc1 { ref mut import, .. } => import.discontinuity(), + TrackKind::Hev1 { + ref mut split, + ref mut import, + } => { + split.reset(); + import.discontinuity() + } + TrackKind::Hvc1 { ref mut import, .. } => import.discontinuity(), + TrackKind::Av01 { + ref mut split, + ref mut import, + } => { + split.reset(); + import.discontinuity() + } + TrackKind::Vp8(ref mut import) => import.discontinuity(), + TrackKind::Vp9(ref mut import) => import.discontinuity(), + TrackKind::Aac(ref mut import) => import.discontinuity(), + TrackKind::Opus(ref mut import) => import.discontinuity(), + TrackKind::Mp3(ref mut import) => import.discontinuity(), + TrackKind::Flac(ref mut import) => import.discontinuity(), + } + } + /// Close the current group and open the next one at `sequence`. pub fn seek(&mut self, sequence: u64) -> Result<()> { self.group_start = None; @@ -990,6 +1028,35 @@ mod tests { assert_eq!(collect_groups(subscriber).await, vec![3, 2]); } + #[tokio::test(start_paused = true)] + async fn discontinuity_resets_audio_measurement_and_separates_groups() { + let (mut broadcast, catalog) = new_broadcast(); + let (import, subscriber) = opus_import(&mut broadcast, &catalog); + let mut import: Track = import.into(); + let anchor = std::time::Instant::now(); + for (pts, arrival) in [(0, 0), (20, 120)] { + let timestamp = Timestamp::from_micros(pts * 1_000).unwrap(); + import.decode(b"a", Some(timestamp)).unwrap(); + import + .flush(timestamp, anchor + Duration::from_millis(arrival)) + .unwrap(); + } + let before = catalog.snapshot().audio.renditions["audio"].jitter; + assert_eq!(before, Some(Duration::from_millis(100))); + import.discontinuity().unwrap(); + for (pts, arrival) in [(100, 6_000), (120, 6_020)] { + let timestamp = Timestamp::from_micros(pts * 1_000).unwrap(); + import.decode(b"b", Some(timestamp)).unwrap(); + import + .flush(timestamp, anchor + Duration::from_millis(arrival)) + .unwrap(); + } + assert_eq!(catalog.snapshot().audio.renditions["audio"].jitter, before); + import.finish().unwrap(); + // The middle group is the established empty-payload discontinuity marker. + assert_eq!(collect_groups(subscriber).await, vec![2, 1, 2]); + } + /// Cutting after every frame is still available, and is what a caller wanting the lowest /// latency does: one group (one QUIC stream) per packet, forwarded without waiting. #[tokio::test(start_paused = true)] diff --git a/swift/Sources/Moq/Media.swift b/swift/Sources/Moq/Media.swift index 13ab75acfc..d2494b20cd 100644 --- a/swift/Sources/Moq/Media.swift +++ b/swift/Sources/Moq/Media.swift @@ -136,6 +136,11 @@ public final class MediaProducer: Sendable { try ffi.flush(timestampUs: timestampUs) } + /// Mark a timeline break and restart handoff measurement, preserving advertised jitter. + public func discontinuity() throws { + try ffi.discontinuity() + } + /// Draw a group boundary here. /// /// Audio has no boundary of its own (every packet is independently decodable), so this is the From bb4494447f6562f34b3a50e46b54983442c80e72 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 08:37:50 -0700 Subject: [PATCH 41/45] chore: rename CLAUDE.md to AGENTS.md (#4235) Co-authored-by: Claude Opus 5.5 --- .claude/rules/prompting.md | 1 - .claude/skills/plan-quests/SKILL.md | 2 +- .claude/skills/spawn-quests/SKILL.md | 2 +- .claude/skills/start-quest/SKILL.md | 4 +- .config/nextest.toml | 2 +- .opencode/agent/review.md | 2 +- AGENTS.md | 114 +++++++++++++++++++++++++- CLAUDE.md | 113 ------------------------- PROMPTING.md | 4 +- drafts/{CLAUDE.md => AGENTS.md} | 0 js/{CLAUDE.md => AGENTS.md} | 2 +- justfile | 2 +- py/{CLAUDE.md => AGENTS.md} | 2 +- quest/{CLAUDE.md => AGENTS.md} | 2 +- quest/m1/broadcast-close/rust.md | 2 +- quest/m1/ffi-shape/json.md | 2 +- quest/m1/moq-c.md | 2 +- quest/m1/tooling/justfiles.md | 2 +- quest/m1/uring-tcp/stream.md | 2 +- quest/m2/mobile-ownership.md | 2 +- rs/{CLAUDE.md => AGENTS.md} | 4 +- rs/justfile | 2 +- rs/moq-ffi/{CLAUDE.md => AGENTS.md} | 0 rs/moq-mux/{CLAUDE.md => AGENTS.md} | 0 rs/moq-net/{CLAUDE.md => AGENTS.md} | 0 rs/moq-net/src/lite/version.rs | 26 +++--- rs/moq-relay/{CLAUDE.md => AGENTS.md} | 0 rs/moq-video/DESIGN-native-codecs.md | 2 +- rs/quest/Cargo.toml | 2 +- rs/quest/src/lib.rs | 8 +- rs/quest/src/main.rs | 4 +- rs/quest/src/ready.rs | 2 +- rs/quest/src/rules.rs | 6 +- rs/quest/tests/tree.rs | 38 ++++----- 34 files changed, 178 insertions(+), 180 deletions(-) mode change 120000 => 100644 AGENTS.md delete mode 100644 CLAUDE.md rename drafts/{CLAUDE.md => AGENTS.md} (100%) rename js/{CLAUDE.md => AGENTS.md} (98%) rename py/{CLAUDE.md => AGENTS.md} (92%) rename quest/{CLAUDE.md => AGENTS.md} (99%) rename rs/{CLAUDE.md => AGENTS.md} (97%) rename rs/moq-ffi/{CLAUDE.md => AGENTS.md} (100%) rename rs/moq-mux/{CLAUDE.md => AGENTS.md} (100%) rename rs/moq-net/{CLAUDE.md => AGENTS.md} (100%) rename rs/moq-relay/{CLAUDE.md => AGENTS.md} (100%) diff --git a/.claude/rules/prompting.md b/.claude/rules/prompting.md index 750a9e06cb..e373e9495f 100644 --- a/.claude/rules/prompting.md +++ b/.claude/rules/prompting.md @@ -1,6 +1,5 @@ --- paths: - - "**/CLAUDE.md" - "**/AGENTS.md" - "CONTRIBUTING.md" - "PROMPTING.md" diff --git a/.claude/skills/plan-quests/SKILL.md b/.claude/skills/plan-quests/SKILL.md index 0f55d0a72f..8ba5c9ff8e 100644 --- a/.claude/skills/plan-quests/SKILL.md +++ b/.claude/skills/plan-quests/SKILL.md @@ -3,7 +3,7 @@ name: plan-quests description: Scope, create, and publish a quest through an interactive grilling interview. Use when the user invokes /plan-quests, asks to plan a quest, or wants unsettled work split into quests. --- -Before you begin, read `quest/CLAUDE.md` completely. +Before you begin, read `quest/AGENTS.md` completely. Interview the user until you reach a shared understanding. diff --git a/.claude/skills/spawn-quests/SKILL.md b/.claude/skills/spawn-quests/SKILL.md index d8782dd12b..b8e0f44ffa 100644 --- a/.claude/skills/spawn-quests/SKILL.md +++ b/.claude/skills/spawn-quests/SKILL.md @@ -3,7 +3,7 @@ name: spawn-quests description: Spawn background agents work on quests in parallel. --- -Before you begin, read `quest/CLAUDE.md` completely. +Before you begin, read `quest/AGENTS.md` completely. Your goal is to execute, plan, and/or merge quests in parallel. If you are unsure of the best course of action, ask the user for clarification before proceeding. diff --git a/.claude/skills/start-quest/SKILL.md b/.claude/skills/start-quest/SKILL.md index 4ec47379e5..73d0911e40 100644 --- a/.claude/skills/start-quest/SKILL.md +++ b/.claude/skills/start-quest/SKILL.md @@ -3,7 +3,7 @@ name: start-quest description: Start work on a quest. --- -Before you begin, read `quest/CLAUDE.md` completely. +Before you begin, read `quest/AGENTS.md` completely. Your goal is to implement the quest, or as much of it as possible, and create a draft PR. The argument is the quest to work on. @@ -11,7 +11,7 @@ The argument is the quest to work on. If you are unsure on the best course of action, ask the user for direction. Confirm the quest is ready and unclaimed. -Claim it as `quest/CLAUDE.md` describes: `quest branch` names the branch and its bases, missing line branches get a draft PR, and the quest branch gets an empty commit. +Claim it as `quest/AGENTS.md` describes: `quest branch` names the branch and its bases, missing line branches get a draft PR, and the quest branch gets an empty commit. Implement the quest until it is complete, or some blocker is hit, then create a PR against the base. Keep scratch files (PR body, logs, notes) in the worktree's gitignored `.scratch/`. diff --git a/.config/nextest.toml b/.config/nextest.toml index 1d52e0c2ef..a79c667e71 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -16,7 +16,7 @@ slow-timeout = { period = "30s", terminate-after = 2 } # Retries hide flakes, and this repo treats an intermittent failure as a real -# race until proven otherwise (see Root Cause First in CLAUDE.md). +# race until proven otherwise (see Root Cause First in AGENTS.md). retries = 0 # `--all-targets` still compiles the criterion benches, which is what catches a diff --git a/.opencode/agent/review.md b/.opencode/agent/review.md index 4f9702b65d..785460f021 100644 --- a/.opencode/agent/review.md +++ b/.opencode/agent/review.md @@ -38,7 +38,7 @@ it like a prompt: terse, specific, and nothing the reader has to re-derive. ## Before reviewing Read `AGENTS.md`, `CONTRIBUTING.md`, and `PROMPTING.md` at the repo root, then -the nested `CLAUDE.md` beside any touched code. They hold the rules you enforce +the nested `AGENTS.md` beside any touched code. They hold the rules you enforce and how to write for an agent reader; cite them by file and heading. Confirm the base with `gh pr view --json baseRefName`, then diff diff --git a/AGENTS.md b/AGENTS.md deleted file mode 120000 index 681311eb9c..0000000000 --- a/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -CLAUDE.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..494dbdaa8f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,113 @@ +MoQ (Media over QUIC) is a live media delivery protocol providing real-time latency at massive scale. +This is a polyglot monorepo with Rust (server/native) and TypeScript (browser) implementations. + +# Context + +This file is split into nested `AGENTS.md` files based on the language/situation. + +- Read `CONTRIBUTING.md` when dealing with PRs. +- Read `PROMPTING.md` before instructing other agents via context, memory, plans, or reviews. +- Read `*/AGENTS.md` if the file exists before touching a root directory. +- Read (and keep up-to-date) `doc/*.md` for user-facing documentation. + +# Required + +- Pull the latest origin changes before working. +- Dig into the root cause and fix it at the source. Never work around a fixable bug with a retry, sleep, or timeout. +- Fail loud and early. Error on unsupported or malformed input rather than warn and continue: supported or refused. +- Reproduce bugs before fixing them. Land each fix with a regression test that fails without it, when one is easy. +- Keep the PR focused. No unrelated refactors, formatting churn, or drive-by changes; split when in doubt. +- Refactor aggressively for long-term maintainability, but re-evaluate the direction as you learn. +- Propose a course change, even suggest abandoning a PR, rather than finish a half-solution. +- When a decision is the maintainer's (API shape, naming, scope), ask with 2-3 options and a recommendation. +- All tests need to be wired into CI, at least a nightly. +- Never edit an `AGENTS.md`, `CONTRIBUTING.md`, or skill without being prompted, and read `PROMPTING.md` first. +- No em dashes. +- Match the existing conventions, patterns, and naming when possible. +- Fix any outdated docs and comments inline; don't add a separate PR for it. +- Add the AI marker `(Written by )` to any posts on GitHub, excluding commit messages that contain `Co-Authored-By:` trailers. +- Any AI comments may be challenged, and not confused with human maintainers. +- Prompt the user to decide when unsure, but always provide recommendations. + +# Guidelines + +- Prefer a maintained crate over hand-rolling non-core functionality. +- Anything that fans out (N publishers, M subscribers, routes, sessions) gets a benchmark swept over both axes, so a cost that grows with the table instead of the touched path shows up as a slope. +- New dependencies should use the newest, stable version. +- Do not bump package versions unless asked. Releases are cut separately. +- Comments should explain the non-obvious why, and never the history. +- Inline simple helpers. +- Question whether functionality is needed at all before adding it. +- Deleting code is better than adding code. +- Suggest follow up sessions and quests when finished, interactively prompting the user. +- Prefer the simple solution. +- Say it once, in the fewest words that hold up. +- Critically think, and decide if the complexity is worth it. Simple is better. +- Prefer a quest over a GitHub issue for work needing durable scope. +- Use interactive prompts when possible. +- Try to do stuff asynchronously. ex. ask about follow-ups while tests run. +- Try to recognize when you're stuck, or making minimal progress, and stop early. +- If the core problem is addressed, ship it instead of spinning your wheels on meaningless revisions. +- Add or extend a benchmark for any performance-sensitive change so later regressions show up, and measure optimizations instead of relying on intuition. + +# Public API + +The API is the most important thing to get right. +A bad shape costs a breaking change in every language, and the surface is huge. + +- Report the public API and wire impact of every change, in the PR description and whenever asked. +- Keep things private until a consumer needs them. Scrutinize every new exported item. +- Never add `foo_with_x`, `foo_checked`, or a compatibility shim. Make the breaking change to `foo` on `dev` instead. Additive changes stay on `main`. +- Let the type system make misuse unrepresentable +- Avoid callback parameters. Return a handle, an event, or a Producer/Consumer split. +- Avoid 4+ args; use a struct or object. +- Name by role, not today's implementation. +- When a name or shape feels awkward, propose alternatives with a recommendation instead of shipping it. +- Short names under a module namespace (`encode::Config`, not `EncoderConfig`). Mirror names across Rust, JS, and the bindings. +- Document every exported symbol in one plain line, the way you'd say it out loud. +- Prefer refactoring and simplification when working on the `dev` branch; many APIs have have not been published yet. + +MoQ is split into many layers as part of the public API. +The boundary between packages is extremely important to keep things modular and reusable. + +# Development + +PRs target `main`. +`dev` is reserved for semver-breaking API changes, except for `0.0.x` and unpublished/private packages. +Wire changes should be backwards compatible for any *published* drafts/versions. + +Before starting, `git fetch origin` and set the upstream to the base branch. +If a published API break requires `dev`, retarget the PR to `dev`, set the upstream to `origin/dev`, then rebase onto it. + +Use the Nix dev shell so tooling matches CI. +direnv loads it automatically, but if not: `nix develop --command ...`. + +```bash +just check # Lint, compile, and test what the branch changed +just fix # Auto-fix lint/formatting, same scope +``` + +These diff the branch against its base and only run the affected packages. + +# Cross-Package Sync + +| Change in | Also update | +|---|---| +| `rs/moq-ffi` | `rs/libmoq`, `{py,swift,kt,dart}/`, `go/wrapper/moq/*.go` (the `go/ffi` and `dart/moq_ffi` bindings regenerate automatically, but a new method needs a hand-written wrapper too, like `py/moq-rs` or `dart/moq`), `doc/lib/{py,swift,kt,go,dart,c}` | +| `rs/moq-net` wire/API | `js/net`, `doc/concept`, `drafts/draft-lcurley-moq-lite.md` (if the wire spec changes) | +| `rs/hang` catalog/container | `js/hang`, `doc/concept`, `drafts/draft-lcurley-moq-hang.md` (if the format spec changes) | +| `rs/moq-token` | `js/token` | +| `rs/moq-stats` wire (track names, frame shapes) | `doc/bin/relay/config.md` (stats section) | +| `rs/moq-relay` config/behavior | `doc/bin/relay/` | +| `rs/moq-cli` | `doc/bin/cli.md` | +| `rs/moq-token-cli` | `doc/bin/relay/auth.md`, `doc/lib/rs/moq-token.md`, `doc/lib/rs/index.md` | +| `rs/moq-gst` | `doc/bin/gstreamer.md` | +| `rs/libmoq` C ABI (`moq.h`) | `cpp/obs/src`, `doc/bin/obs.md` | +| `js/{watch,publish}` UI/API | `demo/web` if it consumes the API | +| a kramdown-rfc construct new to `drafts/` | `doc/.vitepress/drafts.ts`, which translates the drafts into `/draft/` site pages | + +Any wire-format change updates its matching IETF draft in the same PR, including framing, message fields, enum values, and version negotiation. Use the feature-specific draft for extensions and validate with `just drafts check`. See `drafts/AGENTS.md`. + +For wire, `moq-ffi`, or gateway changes, also run `just test interop --all` for cross-language interop; plain `interop` is Rust-only. + +When a CLI interface changes, search the whole repo for the binary name and update every example invocation, including docs and demo recipes. Check examples against `--help`. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ba39b50d64..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,113 +0,0 @@ -MoQ (Media over QUIC) is a live media delivery protocol providing real-time latency at massive scale. -This is a polyglot monorepo with Rust (server/native) and TypeScript (browser) implementations. - -# Context - -This file is split into nested `CLAUDE.md` files based on the language/situation. - -- Read `CONTRIBUTING.md` when dealing with PRs. -- Read `PROMPTING.md` before instructing other agents via context, memory, plans, or reviews. -- Read `*/CLAUDE.md` if the file exists before touching a root directory. -- Read (and keep up-to-date) `doc/*.md` for user-facing documentation. - -# Required - -- Pull the latest origin changes before working. -- Dig into the root cause and fix it at the source. Never work around a fixable bug with a retry, sleep, or timeout. -- Fail loud and early. Error on unsupported or malformed input rather than warn and continue: supported or refused. -- Reproduce bugs before fixing them. Land each fix with a regression test that fails without it, when one is easy. -- Keep the PR focused. No unrelated refactors, formatting churn, or drive-by changes; split when in doubt. -- Refactor aggressively for long-term maintainability, but re-evaluate the direction as you learn. -- Propose a course change, even suggest abandoning a PR, rather than finish a half-solution. -- When a decision is the maintainer's (API shape, naming, scope), ask with 2-3 options and a recommendation. -- All tests need to be wired into CI, at least a nightly. -- Never edit a `CLAUDE.md`, `CONTRIBUTING.md`, or skill without being prompted, and read `PROMPTING.md` first. -- No em dashes. -- Match the existing conventions, patterns, and naming when possible. -- Fix any outdated docs and comments inline; don't add a separate PR for it. -- Add the AI marker `(Written by )` to any posts on GitHub, excluding commit messages that contain `Co-Authored-By:` trailers. -- Any AI comments may be challenged, and not confused with human maintainers. -- Prompt the user to decide when unsure, but always provide recommendations. - -# Guidelines - -- Prefer a maintained crate over hand-rolling non-core functionality. -- Anything that fans out (N publishers, M subscribers, routes, sessions) gets a benchmark swept over both axes, so a cost that grows with the table instead of the touched path shows up as a slope. -- New dependencies should use the newest, stable version. -- Do not bump package versions unless asked. Releases are cut separately. -- Comments should explain the non-obvious why, and never the history. -- Inline simple helpers. -- Question whether functionality is needed at all before adding it. -- Deleting code is better than adding code. -- Suggest follow up sessions and quests when finished, interactively prompting the user. -- Prefer the simple solution. -- Say it once, in the fewest words that hold up. -- Critically think, and decide if the complexity is worth it. Simple is better. -- Prefer a quest over a GitHub issue for work needing durable scope. -- Use interactive prompts when possible. -- Try to do stuff asynchronously. ex. ask about follow-ups while tests run. -- Try to recognize when you're stuck, or making minimal progress, and stop early. -- If the core problem is addressed, ship it instead of spinning your wheels on meaningless revisions. -- Add or extend a benchmark for any performance-sensitive change so later regressions show up, and measure optimizations instead of relying on intuition. - -# Public API - -The API is the most important thing to get right. -A bad shape costs a breaking change in every language, and the surface is huge. - -- Report the public API and wire impact of every change, in the PR description and whenever asked. -- Keep things private until a consumer needs them. Scrutinize every new exported item. -- Never add `foo_with_x`, `foo_checked`, or a compatibility shim. Make the breaking change to `foo` on `dev` instead. Additive changes stay on `main`. -- Let the type system make misuse unrepresentable -- Avoid callback parameters. Return a handle, an event, or a Producer/Consumer split. -- Avoid 4+ args; use a struct or object. -- Name by role, not today's implementation. -- When a name or shape feels awkward, propose alternatives with a recommendation instead of shipping it. -- Short names under a module namespace (`encode::Config`, not `EncoderConfig`). Mirror names across Rust, JS, and the bindings. -- Document every exported symbol in one plain line, the way you'd say it out loud. -- Prefer refactoring and simplification when working on the `dev` branch; many APIs have have not been published yet. - -MoQ is split into many layers as part of the public API. -The boundary between packages is extremely important to keep things modular and reusable. - -# Development - -PRs target `main`. -`dev` is reserved for semver-breaking API changes, except for `0.0.x` and unpublished/private packages. -Wire changes should be backwards compatible for any *published* drafts/versions. - -Before starting, `git fetch origin` and set the upstream to the base branch. -If a published API break requires `dev`, retarget the PR to `dev`, set the upstream to `origin/dev`, then rebase onto it. - -Use the Nix dev shell so tooling matches CI. -direnv loads it automatically, but if not: `nix develop --command ...`. - -```bash -just check # Lint, compile, and test what the branch changed -just fix # Auto-fix lint/formatting, same scope -``` - -These diff the branch against its base and only run the affected packages. - -# Cross-Package Sync - -| Change in | Also update | -|---|---| -| `rs/moq-ffi` | `rs/libmoq`, `{py,swift,kt,dart}/`, `go/wrapper/moq/*.go` (the `go/ffi` and `dart/moq_ffi` bindings regenerate automatically, but a new method needs a hand-written wrapper too, like `py/moq-rs` or `dart/moq`), `doc/lib/{py,swift,kt,go,dart,c}` | -| `rs/moq-net` wire/API | `js/net`, `doc/concept`, `drafts/draft-lcurley-moq-lite.md` (if the wire spec changes) | -| `rs/hang` catalog/container | `js/hang`, `doc/concept`, `drafts/draft-lcurley-moq-hang.md` (if the format spec changes) | -| `rs/moq-token` | `js/token` | -| `rs/moq-stats` wire (track names, frame shapes) | `doc/bin/relay/config.md` (stats section) | -| `rs/moq-relay` config/behavior | `doc/bin/relay/` | -| `rs/moq-cli` | `doc/bin/cli.md` | -| `rs/moq-token-cli` | `doc/bin/relay/auth.md`, `doc/lib/rs/moq-token.md`, `doc/lib/rs/index.md` | -| `rs/moq-gst` | `doc/bin/gstreamer.md` | -| `rs/libmoq` C ABI (`moq.h`) | `cpp/obs/src`, `doc/bin/obs.md` | -| `js/{watch,publish}` UI/API | `demo/web` if it consumes the API | -| a kramdown-rfc construct new to `drafts/` | `doc/.vitepress/drafts.ts`, which translates the drafts into `/draft/` site pages | - -Any wire-format change updates its matching IETF draft in the same PR, including framing, message fields, enum values, and version negotiation. Use the feature-specific draft for extensions and validate with `just drafts check`. See `drafts/CLAUDE.md`. - -For wire, `moq-ffi`, or gateway changes, also run `just test interop --all` for cross-language interop; plain `interop` is Rust-only. - -When a CLI interface changes, search the whole repo for the binary name and update every example invocation, including docs and demo recipes. Check examples against `--help`. diff --git a/PROMPTING.md b/PROMPTING.md index 5d886bc253..36f7956fbb 100644 --- a/PROMPTING.md +++ b/PROMPTING.md @@ -18,13 +18,13 @@ Mocking up an API makes a lot of sense but again, it should be flexible enough t # Context -This includes editing any `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, etc. +This includes editing any `AGENTS.md`, `CONTRIBUTING.md`, etc. These files need to be light because they're loaded into every prompt. More specific context (ex. per language or project) should exist in optional files/folders. These base prompts are meant to fix regular agent mistakes. -If there is a history of bad practices, or sub-optimal behavior, update `CLAUDE.md` to steer away from them. +If there is a history of bad practices, or sub-optimal behavior, update `AGENTS.md` to steer away from them. If you think a slight tweak to the base prompt would help, propose it. Focus on best practices and conventions. diff --git a/drafts/CLAUDE.md b/drafts/AGENTS.md similarity index 100% rename from drafts/CLAUDE.md rename to drafts/AGENTS.md diff --git a/js/CLAUDE.md b/js/AGENTS.md similarity index 98% rename from js/CLAUDE.md rename to js/AGENTS.md index 9e0bf43c2a..f038f0c52d 100644 --- a/js/CLAUDE.md +++ b/js/AGENTS.md @@ -1,4 +1,4 @@ -The `/js` TypeScript workspace. Extends the root `CLAUDE.md`. +The `/js` TypeScript workspace. Extends the root `AGENTS.md`. # Packages diff --git a/justfile b/justfile index 22f4d7084c..9d8af7a663 100644 --- a/justfile +++ b/justfile @@ -248,7 +248,7 @@ install: # Resolve BASE: arg > $GITHUB_BASE_REF > upstream > origin/main. A branch's # upstream is the branch it merges into, which is the base a `dev`-targeted # branch needs. `git push -u` repoints upstream at the branch's own remote copy, -# which would diff HEAD against itself, so ignore that case (see CLAUDE.md). +# which would diff HEAD against itself, so ignore that case (see AGENTS.md). # GITHUB_BASE_REF outranks the upstream because a PR checkout has no upstream # configured, and the branch being merged into is exactly the base GitHub is # asking about. diff --git a/py/CLAUDE.md b/py/AGENTS.md similarity index 92% rename from py/CLAUDE.md rename to py/AGENTS.md index b14e3dc6c2..58bd95b8f0 100644 --- a/py/CLAUDE.md +++ b/py/AGENTS.md @@ -1,4 +1,4 @@ -The `/py` uv workspace. Extends the root `CLAUDE.md` and `rs/moq-ffi/CLAUDE.md`. +The `/py` uv workspace. Extends the root `AGENTS.md` and `rs/moq-ffi/AGENTS.md`. # Packages diff --git a/quest/CLAUDE.md b/quest/AGENTS.md similarity index 99% rename from quest/CLAUDE.md rename to quest/AGENTS.md index 2bb77f82bb..16eebac7aa 100644 --- a/quest/CLAUDE.md +++ b/quest/AGENTS.md @@ -20,7 +20,7 @@ durable scope or coordination. merges into its line's branch, a line into its parent's, and a milestone's direct children into `main`. Milestones have no branch. - A published API or wire break retargets to `dev` at PR time, per the root - `CLAUDE.md`; a quest's Plan may note it. + `AGENTS.md`; a quest's Plan may note it. - Every `Quests` list is ordered by priority. Insert at rank, never append. - Link with root-absolute paths. Finished documents are deleted; git history keeps them. Merge conflicts are expected; resolve them by aligning quests. diff --git a/quest/m1/broadcast-close/rust.md b/quest/m1/broadcast-close/rust.md index b2f8ea9acc..79348cb597 100644 --- a/quest/m1/broadcast-close/rust.md +++ b/quest/m1/broadcast-close/rust.md @@ -24,7 +24,7 @@ In `rs/moq-net/src/model/broadcast.rs`: new publisher at the path is never spliced into it, so a consumer that should not care about its source goes through an origin. - Mark `finish`, `abort`, and `Consumer::is_finished` `#[deprecated]` and - `#[doc(hidden)]` per `rs/CLAUDE.md`. `finish` forwards to `close`. Leave + `#[doc(hidden)]` per `rs/AGENTS.md`. `finish` forwards to `close`. Leave `abort`'s behavior alone until the `dev` removal. - Drop the "dropped without finish()" warning in `Drop for Alive`. - `SourceGuard` (the lite and IETF subscribers' source handle) closes on both diff --git a/quest/m1/ffi-shape/json.md b/quest/m1/ffi-shape/json.md index 191b3fa2c6..0ff6f35227 100644 --- a/quest/m1/ffi-shape/json.md +++ b/quest/m1/ffi-shape/json.md @@ -12,7 +12,7 @@ sets is what the other children copy. JSON is the smallest group, so it carries the setup cost: a Python submodule, a Go subpackage, a Kotlin package, a Dart library, and a Swift namespace, each wired into its package build, tests, and docs. Settle the pattern here and -write it down where the next child will find it (`rs/moq-ffi/CLAUDE.md` if it +write it down where the next child will find it (`rs/moq-ffi/AGENTS.md` if it is a convention, after reading `PROMPTING.md`). Mirror `moq-json`'s names (`snapshot`, `stream`) and constructors, which diff --git a/quest/m1/moq-c.md b/quest/m1/moq-c.md index 608a5bfcb7..ba18262125 100644 --- a/quest/m1/moq-c.md +++ b/quest/m1/moq-c.md @@ -19,7 +19,7 @@ release that points users at `moq-c`. tooling (release-plz, alert and nightly workflows, cachix) must follow the new name and tag; check every workflow that names libmoq. - Update every consumer and reference: `cpp/obs` (`find_package`), interop - clients, `doc/lib/c`, `doc/bin/obs.md`, the root `CLAUDE.md` Cross-Package + clients, `doc/lib/c`, `doc/bin/obs.md`, the root `AGENTS.md` Cross-Package Sync table, and quests that name libmoq. Grep the whole repository. - Publish a final `libmoq` release whose README and description point at `moq-c`, then stop publishing it. The maintainer cuts releases; the PR only diff --git a/quest/m1/tooling/justfiles.md b/quest/m1/tooling/justfiles.md index cae79df4d1..22597e1461 100644 --- a/quest/m1/tooling/justfiles.md +++ b/quest/m1/tooling/justfiles.md @@ -87,7 +87,7 @@ wanted), `_shell`, `_flake` as its one-liner, `clean`, the rs `package` and and `preset` bodies. Docs: `doc/setup/dev.md`, `CONTRIBUTING.md`, `test/README.md`, and the -`CLAUDE.md` mentions of `just wasm` follow the survivors. Verify with `just +`AGENTS.md` mentions of `just wasm` follow the survivors. Verify with `just check`, `just ci test`, and `just check --all`, and confirm every recipe name check.yml, cache.yml, nightly.yml, interop.yml, wasm.yml, obs.yml, swift.yml, and release-*.yml invoke still resolves. diff --git a/quest/m1/uring-tcp/stream.md b/quest/m1/uring-tcp/stream.md index 9aa3076b53..780f6d9c32 100644 --- a/quest/m1/uring-tcp/stream.md +++ b/quest/m1/uring-tcp/stream.md @@ -23,7 +23,7 @@ local task set, so a connection never leaves the thread that accepted it. hyper becomes a first direct dependency. Today it reaches the workspace only through `axum` and `axum-server`; add it to `[workspace.dependencies]` at the -newest stable version, per `rs/CLAUDE.md`. +newest stable version, per `rs/AGENTS.md`. Cover it the way the UDP path is covered: tests that run against whichever backend is compiled, an HTTP round trip through hyper on the worker, and a diff --git a/quest/m2/mobile-ownership.md b/quest/m2/mobile-ownership.md index f4f139e422..973b75bf56 100644 --- a/quest/m2/mobile-ownership.md +++ b/quest/m2/mobile-ownership.md @@ -18,7 +18,7 @@ capture quests in this questline start only once this is settled. Kotlin over `moq-ffi`, what the iroh-live reimplementation says about the Rust-native audience, and the copy cost of byte-array frames measured on a device. -- Record the verdict here and in `rs/moq-ffi/CLAUDE.md`, and re-estimate +- Record the verdict here and in `rs/moq-ffi/AGENTS.md`, and re-estimate [Android capture](/quest/m2/mobile-capture-android.md) and [iOS capture](/quest/m2/mobile-capture-ios.md) against it; both target `moq-video`, so replace them with the required platform-owned implementation diff --git a/rs/CLAUDE.md b/rs/AGENTS.md similarity index 97% rename from rs/CLAUDE.md rename to rs/AGENTS.md index 2c9e8d74fc..d2ba893ae8 100644 --- a/rs/CLAUDE.md +++ b/rs/AGENTS.md @@ -1,4 +1,4 @@ -The `/rs` Cargo workspace. Extends the root `CLAUDE.md`. +The `/rs` Cargo workspace. Extends the root `AGENTS.md`. # Crates @@ -8,7 +8,7 @@ One crate per component from the root list, named `moq-` (`hang` and - `moq-tokio`: configures the QUIC backends (Quinn/Quiche/Noq/Iroh) and the fallback transports for native binaries, on tokio. `moq-sock` holds the socket plumbing it shares with `moq-uring`, the experimental thread-per-core io\_uring runtime. - `moq-cli` builds the `moq` binary and owns the CLI surface for the gateway crates. Binaries never carry a `-cli` suffix. -`moq-net`, `moq-mux`, `moq-relay` (including the config conventions every binary shares), and `moq-ffi` have their own `CLAUDE.md`. +`moq-net`, `moq-mux`, `moq-relay` (including the config conventions every binary shares), and `moq-ffi` have their own `AGENTS.md`. # Producer / Consumer diff --git a/rs/justfile b/rs/justfile index a6bab8c090..93d4114d2e 100644 --- a/rs/justfile +++ b/rs/justfile @@ -730,7 +730,7 @@ relay-minimal: # # Outside nextest on purpose. The staticlib link alone takes ~2 minutes, past the # hang budget in `.config/nextest.toml` once the rest of the workspace builds -# beside it, and `rs/CLAUDE.md` says to make a slow test fast, not to raise the +# beside it, and `rs/AGENTS.md` says to make a slow test fast, not to raise the # ceiling. obs.yml runs this on every PR that touches rs/libmoq, right after it # has built the same debug libmoq.a for the plugin, and nightly.yml covers the # paths that filter misses. diff --git a/rs/moq-ffi/CLAUDE.md b/rs/moq-ffi/AGENTS.md similarity index 100% rename from rs/moq-ffi/CLAUDE.md rename to rs/moq-ffi/AGENTS.md diff --git a/rs/moq-mux/CLAUDE.md b/rs/moq-mux/AGENTS.md similarity index 100% rename from rs/moq-mux/CLAUDE.md rename to rs/moq-mux/AGENTS.md diff --git a/rs/moq-net/CLAUDE.md b/rs/moq-net/AGENTS.md similarity index 100% rename from rs/moq-net/CLAUDE.md rename to rs/moq-net/AGENTS.md diff --git a/rs/moq-net/src/lite/version.rs b/rs/moq-net/src/lite/version.rs index 3c81f2a994..33a1d52bd0 100644 --- a/rs/moq-net/src/lite/version.rs +++ b/rs/moq-net/src/lite/version.rs @@ -32,7 +32,7 @@ impl Version { /// every counted stream is open, in place of SUBSCRIBE_DROP. Added in lite-07. #[allow(clippy::match_like_matches_macro)] pub fn has_stream_count(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 => false, _ => true, @@ -46,7 +46,7 @@ impl Version { /// SUBSCRIBE_START/END, and per-frame timestamp prefixes. #[allow(clippy::match_like_matches_macro)] pub fn has_track_stream(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 => false, _ => true, @@ -58,7 +58,7 @@ impl Version { /// there and must not be sent. #[allow(clippy::match_like_matches_macro)] pub fn has_probe_rtt(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 => false, _ => true, @@ -70,7 +70,7 @@ impl Version { /// setup exchange (Lite01/02) and the no-setup drafts (Lite03/04) don't use it. #[allow(clippy::match_like_matches_macro)] pub fn has_setup_stream(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 => false, _ => true, @@ -83,7 +83,7 @@ impl Version { /// send or accept datagram bodies. #[allow(clippy::match_like_matches_macro)] pub fn has_datagrams(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 => false, _ => true, @@ -94,7 +94,7 @@ impl Version { /// from each announcement's hop chain. Added in lite-05. #[allow(clippy::match_like_matches_macro)] pub fn has_announce_ok(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 => false, _ => true, @@ -105,7 +105,7 @@ impl Version { /// shutdown and migration. Added in lite-04. #[allow(clippy::match_like_matches_macro)] pub fn has_goaway(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 => false, _ => true, @@ -117,7 +117,7 @@ impl Version { /// reference that id instead of repeating the path. Added in lite-06. #[allow(clippy::match_like_matches_macro)] pub fn has_announce_id(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 => false, _ => true, @@ -148,7 +148,7 @@ impl Version { /// next group before it can resume. #[allow(clippy::match_like_matches_macro)] pub fn has_frame_bounds(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 => false, _ => true, @@ -177,7 +177,7 @@ impl Version { /// of resolving it from the budget. #[allow(clippy::match_like_matches_macro)] pub(crate) fn resolves_start(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 => false, _ => true, @@ -188,7 +188,7 @@ impl Version { /// requests decode as not opted in. #[allow(clippy::match_like_matches_macro)] pub fn has_hidden(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 => false, _ => true, @@ -199,7 +199,7 @@ impl Version { /// from a live announcement on the same stream. Added in lite-07. #[allow(clippy::match_like_matches_macro)] pub fn has_announce_compression(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 => false, _ => true, @@ -212,7 +212,7 @@ impl Version { /// on hop count alone, exactly as before. #[allow(clippy::match_like_matches_macro)] pub fn has_route_cost(self) -> bool { - // Match form so future versions default forward (CLAUDE.md convention). + // Match form so future versions default forward (AGENTS.md convention). match self { Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 => false, _ => true, diff --git a/rs/moq-relay/CLAUDE.md b/rs/moq-relay/AGENTS.md similarity index 100% rename from rs/moq-relay/CLAUDE.md rename to rs/moq-relay/AGENTS.md diff --git a/rs/moq-video/DESIGN-native-codecs.md b/rs/moq-video/DESIGN-native-codecs.md index 33217db86f..590d0e815d 100644 --- a/rs/moq-video/DESIGN-native-codecs.md +++ b/rs/moq-video/DESIGN-native-codecs.md @@ -283,7 +283,7 @@ A backend "fails to open" (driver missing, no device) the same way an ffmpeg hardware). 4. **NVENC backend** on Linux behind its feature. 5. **Capture swap to nokhwa; drop `ffmpeg-next`.** Delete the ffmpeg capture + - scaler, remove the dep from `Cargo.toml`, update \[CLAUDE.md cross-package + scaler, remove the dep from `Cargo.toml`, update \[AGENTS.md cross-package notes], `doc/bin/cli.md`, the `capture` feature wiring in `moq-cli`, and the packaging recipes. After this ffmpeg is gone and the binary is GPU-accelerated on macOS/NVIDIA, software (openh264) elsewhere. diff --git a/rs/quest/Cargo.toml b/rs/quest/Cargo.toml index b0d189c967..0bef17e7ee 100644 --- a/rs/quest/Cargo.toml +++ b/rs/quest/Cargo.toml @@ -6,7 +6,7 @@ rust-version.workspace = true authors = ["Luke Curley"] license = "MIT OR Apache-2.0" publish = false -description = "Validator and readiness gate for the quest tree: the interlinked Markdown plans under quest/, whose contract is quest/CLAUDE.md. Enforces link resolution, the questline index, the heading vocabulary, and an acyclic Required graph, so a structural mistake fails the PR instead of the next reader, reports what blocks a quest so a flow does not reconstruct that by grepping, and names the branches a quest lands on." +description = "Validator and readiness gate for the quest tree: the interlinked Markdown plans under quest/, whose contract is quest/AGENTS.md. Enforces link resolution, the questline index, the heading vocabulary, and an acyclic Required graph, so a structural mistake fails the PR instead of the next reader, reports what blocks a quest so a flow does not reconstruct that by grepping, and names the branches a quest lands on." [[bin]] name = "quest" diff --git a/rs/quest/src/lib.rs b/rs/quest/src/lib.rs index 564dfb983e..2845f241ac 100644 --- a/rs/quest/src/lib.rs +++ b/rs/quest/src/lib.rs @@ -1,4 +1,4 @@ -//! The quest tree, whose contract is quest/CLAUDE.md: structural validation of +//! The quest tree, whose contract is quest/AGENTS.md: structural validation of //! it, whether a given quest can be started, and which branches carry it. //! //! The whole tree is validated on every run, never just the changed files: the @@ -20,9 +20,9 @@ pub use doc::Doc; pub use ready::Blocker; pub use rules::Finding; -/// CLAUDE.md is the contract rather than a quest: not executable work, so +/// AGENTS.md is the contract rather than a quest: not executable work, so /// neither indexed nor validated. -const NOT_QUESTS: [&str; 1] = ["CLAUDE.md"]; +const NOT_QUESTS: [&str; 1] = ["AGENTS.md"]; /// Every quest document under `/quest`, sorted, repository-relative. pub fn collect(root: &Path) -> Result> { @@ -36,7 +36,7 @@ fn walk(root: &Path, dir: &Path, out: &mut Vec) -> Result<()> { for entry in std::fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); - // `file_type` does not follow symlinks, so quest/CLAUDE.md is a file + // `file_type` does not follow symlinks, so quest/AGENTS.md is a file // here and a directory symlink can never make this recurse forever. let kind = entry.file_type()?; if kind.is_dir() { diff --git a/rs/quest/src/main.rs b/rs/quest/src/main.rs index 02b2d1cdb2..27135981cd 100644 --- a/rs/quest/src/main.rs +++ b/rs/quest/src/main.rs @@ -5,7 +5,7 @@ use anyhow::Result; use clap::{Parser, Subcommand}; /// The quest tree, the interlinked Markdown plans under quest/ whose contract is -/// quest/CLAUDE.md: validate its structure, report what blocks a quest, or name +/// quest/AGENTS.md: validate its structure, report what blocks a quest, or name /// the branches a quest lands on. #[derive(Parser)] #[command(version, about)] @@ -69,7 +69,7 @@ fn main() -> Result { // Blocked is not a verdict on the whole plan: the piece of it // that does not need the blocker is split into its own quest. eprintln!( - "quest: {} is blocked; split any independently landable piece into its own quest (quest/CLAUDE.md, Creation) rather than starting this one as it stands", + "quest: {} is blocked; split any independently landable piece into its own quest (quest/AGENTS.md, Creation) rather than starting this one as it stands", path.display() ); } diff --git a/rs/quest/src/ready.rs b/rs/quest/src/ready.rs index b1576ae444..62ade835ee 100644 --- a/rs/quest/src/ready.rs +++ b/rs/quest/src/ready.rs @@ -73,7 +73,7 @@ pub fn blockers(root: &Path, path: &Path) -> Result> { /// /// A questline is not listed while it still indexes children; a README with /// no `## Quests` left is the line's own remaining work and lists like any -/// other quest. The absence of a `## Required` heading is what quest/CLAUDE.md +/// other quest. The absence of a `## Required` heading is what quest/AGENTS.md /// defines as ready. pub fn quests(root: &Path) -> Result> { let docs = crate::load(root)?; diff --git a/rs/quest/src/rules.rs b/rs/quest/src/rules.rs index 0705747f15..923865ecf2 100644 --- a/rs/quest/src/rules.rs +++ b/rs/quest/src/rules.rs @@ -1,6 +1,6 @@ //! The rules, and the findings they produce. //! -//! The contract is quest/CLAUDE.md. Every rule here is one that has already +//! The contract is quest/AGENTS.md. Every rule here is one that has already //! been broken by hand, and the expensive failure is a rule that stops firing: //! a validator that quietly enforces nothing looks exactly like a clean tree. @@ -276,7 +276,7 @@ fn index<'a>(found: &mut Findings, known: &BTreeSet<&Path>, docs: &'a [Doc]) -> continue; }; // Existence is not enough: the index points readers at work to pick - // up, and quest/CLAUDE.md is a file under quest/ that is not a quest. + // up, and quest/AGENTS.md is a file under quest/ that is not a quest. if !known.contains(child.as_path()) { found.at( &doc.path, @@ -331,7 +331,7 @@ fn index<'a>(found: &mut Findings, known: &BTreeSet<&Path>, docs: &'a [Doc]) -> /// `Required` must be acyclic. A cycle is a set of quests none of which can /// ever start, and walking the links to rule one out is exactly the manual step -/// CLAUDE.md asks of an author before adding a blocker. +/// AGENTS.md asks of an author before adding a blocker. fn cycles(found: &mut Findings, docs: &[Doc], listed: &BTreeMap) { let mut blockers: BTreeMap<&Path, Vec> = BTreeMap::new(); diff --git a/rs/quest/tests/tree.rs b/rs/quest/tests/tree.rs index e1933be9aa..e5577755a3 100644 --- a/rs/quest/tests/tree.rs +++ b/rs/quest/tests/tree.rs @@ -167,16 +167,16 @@ fn dangling_absolute_link() { tree.rejects("link does not resolve: /quest/m0/line/gone.md"); } -/// Relative links escape the tree (CLAUDE.md points at ../CONTRIBUTING.md), so +/// Relative links escape the tree (AGENTS.md points at ../CONTRIBUTING.md), so /// they resolve against the LINKING FILE's directory. The pair of cases pins the /// direction: resolving against the wrong base would flip both verdicts. #[test] fn relative_link_resolves_against_the_linking_file() { let tree = Tree::new(); - tree.write("CLAUDE.md", "# Guide\n"); + tree.write("AGENTS.md", "# Guide\n"); tree.append( "quest/m0/line/one.md", - "\n## Plan\n\nSee [the guide](../../../CLAUDE.md).\n", + "\n## Plan\n\nSee [the guide](../../../AGENTS.md).\n", ); tree.accepts(); } @@ -184,14 +184,14 @@ fn relative_link_resolves_against_the_linking_file() { #[test] fn relative_link_above_the_repository_root() { let tree = Tree::new(); - tree.write("CLAUDE.md", "# Guide\n"); + tree.write("AGENTS.md", "# Guide\n"); // One `..` too many, which is exactly what a file flattened up a level // keeps: it still renders, and points at nothing. tree.append( "quest/m0/line/one.md", - "\n## Plan\n\nSee [the guide](../../../../CLAUDE.md).\n", + "\n## Plan\n\nSee [the guide](../../../../AGENTS.md).\n", ); - tree.rejects("link does not resolve: ../../../../CLAUDE.md"); + tree.rejects("link does not resolve: ../../../../AGENTS.md"); } /// Quests reference each other root-absolutely. A relative one renders fine, so @@ -203,7 +203,7 @@ fn relative_link_to_a_quest() { tree.rejects("link to a quest must be root-absolute: two.md (write /quest/m0/line/two.md)"); } -/// Templates inside fenced blocks are illustrations. Flagging CLAUDE.md's own +/// Templates inside fenced blocks are illustrations. Flagging AGENTS.md's own /// example would make this a check everyone learns to skip. #[test] fn fenced_templates_are_not_links() { @@ -359,13 +359,13 @@ fn relative_index_entry() { } /// The index points readers at work to pick up, so a target that merely exists -/// is not enough: quest/CLAUDE.md is a file under quest/ that is not a quest. +/// is not enough: quest/AGENTS.md is a file under quest/ that is not a quest. #[test] fn index_entry_that_is_not_a_quest() { let tree = Tree::new(); - tree.write("quest/CLAUDE.md", "# Contract\n"); - tree.append("quest/README.md", "- [Contract](/quest/CLAUDE.md)\n"); - tree.rejects("lists /quest/CLAUDE.md, which is not a quest document"); + tree.write("quest/AGENTS.md", "# Contract\n"); + tree.append("quest/README.md", "- [Contract](/quest/AGENTS.md)\n"); + tree.rejects("lists /quest/AGENTS.md, which is not a quest document"); } /// The index is a list of entries, not prose that happens to link. @@ -459,7 +459,7 @@ fn required_link_on_a_wrapped_bullet() { } /// The other half of that rule: an external condition with no link at all is the -/// shape CLAUDE.md prescribes, and must stay legal. +/// shape AGENTS.md prescribes, and must stay legal. #[test] fn required_external_condition() { let tree = Tree::new(); @@ -571,28 +571,28 @@ fn blockquoted_required_entry() { #[test] fn repeated_parent_components() { let tree = Tree::new(); - tree.write("CLAUDE.md", "# Guide\n"); + tree.write("AGENTS.md", "# Guide\n"); tree.append( "quest/m0/line/one.md", - "\n## Plan\n\nSee [the guide](../../../../../CLAUDE.md).\n", + "\n## Plan\n\nSee [the guide](../../../../../AGENTS.md).\n", ); - tree.rejects("link does not resolve: ../../../../../CLAUDE.md"); + tree.rejects("link does not resolve: ../../../../../AGENTS.md"); } /// Escaping the root must fail even when the joined path happens to exist: /// the repository's own directory name (or a sibling worktree) sits beside the -/// root, so `/..//CLAUDE.md` is a real file that readers of the +/// root, so `/..//AGENTS.md` is a real file that readers of the /// repository-relative link can never reach. #[test] fn escaped_link_resolving_beside_the_root() { let tree = Tree::new(); - tree.write("CLAUDE.md", "# Guide\n"); + tree.write("AGENTS.md", "# Guide\n"); let name = tree.path().file_name().unwrap().to_str().unwrap(); tree.append( "quest/m0/line/one.md", - &format!("\n## Plan\n\nSee [the guide](../../../../{name}/CLAUDE.md).\n"), + &format!("\n## Plan\n\nSee [the guide](../../../../{name}/AGENTS.md).\n"), ); - tree.rejects(&format!("link does not resolve: ../../../../{name}/CLAUDE.md")); + tree.rejects(&format!("link does not resolve: ../../../../{name}/AGENTS.md")); } /// A bullet is not an entry. `- TBD` satisfies "the heading has a list" while From 76571fa9f57b2200a326b8300696d69e915a80d4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 08:40:23 -0700 Subject: [PATCH 42/45] quest: origin narrowing joins auth, drop relay peer set, plan hop-list routing (#4158) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 2 - quest/m1/auth/README.md | 7 ++-- quest/m1/auth/narrowing.md | 58 ++++++++++++++++++++++++++ quest/m1/auth/relay-refresh.md | 4 +- quest/m1/auth/request-token.md | 2 +- quest/m1/origin-narrowing.md | 72 --------------------------------- quest/m1/relay-peer-set.md | 31 -------------- quest/m2/README.md | 1 + quest/m2/plan-routing-origin.md | 41 +++++++++++++++++++ rs/moq-relay/src/auth.rs | 2 +- 10 files changed, 108 insertions(+), 112 deletions(-) create mode 100644 quest/m1/auth/narrowing.md delete mode 100644 quest/m1/origin-narrowing.md delete mode 100644 quest/m1/relay-peer-set.md create mode 100644 quest/m2/plan-routing-origin.md diff --git a/quest/m1/README.md b/quest/m1/README.md index da6ecf33ba..d59dd8df2a 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -24,12 +24,10 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Track tail interop](/quest/m1/track-tail-interop.md) - a Rust publisher ending a track with a group in flight is read to its end by the JS subscriber, and the reverse, in `just test interop` - [Interop flakes](/quest/m1/interop-flakes.md) - the interop harness passes with other runs sharing the machine - [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race -- [Origin narrowing](/quest/m1/origin-narrowing.md) - a live origin grant narrows in place and ends the subscriptions it no longer covers, the deafen boundary #2714 asked for - [Binding audio delay](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper configure and observe audio playout delay - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, audio, and video namespaces built from the handle below - [Track demand](/quest/m1/track-demand.md) - Rust and JS watch a track's subscribers through `demand()` alone - [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` is the one way to end a broadcast in every language, a permanent retraction that leaves in-flight tracks alone -- [Relay peer set](/quest/m1/relay-peer-set.md) - a wire consumer tells a client hop from a peer hop, and every mesh credential can mark a peer - [CLI inspection](/quest/m1/cli-inspect/README.md) - `moq ls` lists what is live and `moq fetch` reads a group over MoQ, and a guide shows how to inspect a relay - [Session close](/quest/m1/session-close.md) - a graceful session end withdraws announces and waits one second for the ack - [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index 7042f61feb..6ec02ba9d1 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -39,7 +39,7 @@ Decisions settled while planning, recorded so review does not relitigate them: the union and cancels publications and subscriptions that lose authorization. Other authorized work continues on the same session. An empty union leaves the session connected with no access, so it can accept a fresh token. - [Origin narrowing](/quest/m1/origin-narrowing.md) owns the common resize + [Origin narrowing](/quest/m1/auth/narrowing.md) owns the common resize operation; relay token handling requires it rather than shipping a temporary close-on-shrink policy. - **A public grant contains publish patterns, subscribe patterns, and an @@ -96,6 +96,9 @@ existing lite-06 ALPN. each lite-06 cell's grant and that a publish outside it fails loud - [Unauthorized reset](/quest/m1/auth/unauthorized.md) - a subscription that loses access resets with a dedicated UNAUTHORIZED stream code +- [Origin narrowing](/quest/m1/auth/narrowing.md) - a live grant narrows in + place: subscriptions outside it reset, publishes outside it abort, and relay + revalidation stops closing the session - [Relay tokens](/quest/m1/auth/relay-refresh.md) - the relay verifies tokens sent in band, unions their grants, and cancels only work that loses access - [Request tokens](/quest/m1/auth/request-token.md) - an `AUTHORIZATION @@ -113,8 +116,6 @@ existing lite-06 ALPN. ## Related -- [Origin narrowing](/quest/m1/origin-narrowing.md) - resizes a live session - when the union shrinks, for revalidation and token expiry alike - [Pattern interest](/quest/m1/path-patterns.md) - moves AUTH's legacy wire prefixes to patterns along with ANNOUNCE_REQUEST - [Expiring media grants](/quest/m1/processor/grant-lease.md) - a worker's lease renewal is a new in-band token diff --git a/quest/m1/auth/narrowing.md b/quest/m1/auth/narrowing.md new file mode 100644 index 0000000000..0be54eceec --- /dev/null +++ b/quest/m1/auth/narrowing.md @@ -0,0 +1,58 @@ +# [M] A live origin grant narrows in place + +## Goal + +A `moq-net` session's grant can be replaced by a narrower pattern union +while the session is live: announce cursors retract the patterns that fell +outside, resolution refuses them, subscriptions under them reset with +`UNAUTHORIZED`, and broadcasts the session published under them abort. The +relay uses it on revalidation so a moderation decision (a deafened user's +audio path) takes effect on the session it targets, which a forked client +cannot bypass. This is the per-subscriber exclusion +[#2714](https://github.com/moq-dev/moq/issues/2714) asked for; the scope and +match half already landed in #3672 and #3746. + +A narrowing always succeeds. A changed root still closes the session. + +## Plan + +- Enforce per session, not per handout. The line's session gate for received + grants (`auth::Gate`, the lite publisher resetting subscriptions it denies, + `regrant` retracting announces no longer covered, and the IETF equivalent) + is the same mechanism pointed at the relay's own grant. Reuse it rather than + building a second one. Readers that bypass the session publisher (HTTP + `/fetch` holds its own lease and closes on it; moq-hls and WHEP enforce no + per-user grant today) are out of scope. +- #3971 built narrowing alone and was closed: it refused whenever anything + live sat in the removed part, which is exactly when a grant narrows, and it + kept the ceiling in a side map. Scopes today are copied by value into every + handle and cursor (`OriginScope` in `rs/moq-net/src/model/origin.rs`), so + narrowing needs shared state. If handles need a tree to share it, keep the + ceiling on the grant node itself (an `Arc` shared by clones and children). +- Publish side: routes and broadcasts the session published outside the + narrowed grant abort, so consumers see `Unauthorized` just as the subscribe + side does. Draining is not an option: a group may stay open as long as its + track. +- Relay revalidation uses it: a re-checked grant with the same root narrows + the session instead of closing it (`Lease::ended` reporting "grant + narrowed" in `rs/moq-relay/src/auth.rs`), on every accept path: native, + io_uring, and `moq --listen` share `connection::supervise`, while WebSocket + has its own copy of the loop. +- Prove the deafen case end to end: subscribe under a room prefix, narrow + with a grant that excludes that audio path, and assert the subscription + resets with `UNAUTHORIZED` and no further objects arrive, while a sibling + path under the same prefix keeps flowing. +- Benchmark the narrowing and per-session gate cost in + `rs/moq-net/benches/origin.rs`, swept over routes and subscribers, with a + session that never narrows as the baseline. + +Public API: additive on moq-net and on moq-relay's revalidate path. Wire: +none beyond the UNAUTHORIZED code the required quest adds. + +## Required + +- [Unauthorized reset](/quest/m1/auth/unauthorized.md) - the stream code a narrowed subscription resets with + +## Closes + +- [#2714](https://github.com/moq-dev/moq/issues/2714) - close this issue when the quest finishes diff --git a/quest/m1/auth/relay-refresh.md b/quest/m1/auth/relay-refresh.md index 36d14f7c07..cff68ecc51 100644 --- a/quest/m1/auth/relay-refresh.md +++ b/quest/m1/auth/relay-refresh.md @@ -34,7 +34,7 @@ expiry, an expiry that leaves the union intact ends only that token, and publish-only session never starts receiving announcements because a later token happened to carry subscribe prefixes, and swapped in whenever the set grows; keep it behind one function, and a shrinking union goes through - [Origin narrowing](/quest/m1/origin-narrowing.md) rather than a close. + [Origin narrowing](/quest/m1/auth/narrowing.md) rather than a close. - Expiry: the deadline today is the admitted lease's `closed()` inside one `tokio::select!` arm. Select on one lease per token in the set instead, and on any firing recompute the union without it: unchanged means `AUTH_ERROR { Expired }` on that token's stream and the session @@ -80,7 +80,7 @@ Additive. ## Required -- [Origin narrowing](/quest/m1/origin-narrowing.md) - the live re-scope a shrinking token union needs, so no temporary close-on-shrink policy ships +- [Origin narrowing](/quest/m1/auth/narrowing.md) - the live re-scope a shrinking token union needs, so no temporary close-on-shrink policy ships - [Pattern interest](/quest/m1/path-patterns.md) - AUTH can represent the complete grants relay revalidation returns - [Lite stream](/quest/m1/auth/lite.md) - supplies the AUTH stream and `auth::Request` this consumes diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md index c39486256a..c6125f8295 100644 --- a/quest/m1/auth/request-token.md +++ b/quest/m1/auth/request-token.md @@ -40,7 +40,7 @@ on the unknown key, and the legacy drafts silently ignore it. until it lapses. When a request's grant expires or is revoked, that request alone ends with `EXPIRED_AUTH_TOKEN` or `UNAUTHORIZED`. A session grant that shrinks cancels the requests it covered, as for any request, through - [Origin narrowing](/quest/m1/origin-narrowing.md). + [Origin narrowing](/quest/m1/auth/narrowing.md). - Relay: each such request attaches its own lease through the `Client::attach` path [Relay tokens](/quest/m1/auth/relay-refresh.md) builds, once per request, with no sharing across requests carrying the diff --git a/quest/m1/origin-narrowing.md b/quest/m1/origin-narrowing.md deleted file mode 100644 index a52d518409..0000000000 --- a/quest/m1/origin-narrowing.md +++ /dev/null @@ -1,72 +0,0 @@ -# [L] A live origin grant narrows in place - -## Goal - -A `moq-net` origin handle's grant can be replaced by a narrower pattern union -while the session is live: announce cursors retract the patterns that fell -outside, resolution refuses them, and broadcasts already handed out under -them end rather than draining. The relay uses it on revalidation so a -moderation decision (a deafened user's audio path) takes effect on the -session it targets, which a forked client cannot bypass. This is the -per-subscriber exclusion [#2714](https://github.com/moq-dev/moq/issues/2714) -asked for; the scope and match half already landed in #3672 and #3746. - -A narrowing always succeeds. Until it can end what it removes, the relay keeps -closing the session on a narrowed grant: that is simple, secure, and correct. - -## Plan - -- Narrowing and handout revocation ship together. #3971 built the narrowing - alone and was closed: it had to refuse whenever anything live sat in the - removed part (the session's own reads, another session's reads of the same - front, or its own publishes), which is exactly when a grant narrows, so it - only saved a reconnect for access nobody was using. -- `origin::Producer` and `origin::Consumer` gain one narrowing operation on - the handle, shared by every handle a session derived from it. A grant that - is not a subset of the current one is refused; widening is never a - narrowing. If handles need a tree to share it, keep the ceiling on the grant - node itself (an `Arc` shared by clones and children), not in a side map on - the origin. -- Revocation needs handouts attributed to the grant that requested them. - Decide where it is enforced first: - - **Model, per subscription.** A handout shares its state with the front - every other session reads, so ending one session's copy needs state per - handout reaching the track, subscription, and group readers. The cheapest - shape found rides the group expiry check (`GroupExpiry` in - `rs/moq-net/src/model/track.rs`), which runs only on a blocked read and - parks on the subscription's own state, so a private revoked signal wakes - parked readers for free. It still needs a registry per handout (a push per - subscribe on every relay session), a reason so readers see `Unauthorized` - rather than `Old`, and checks on ordered reads, datagrams, fetch, and peek. - The group in flight ends too: a group may stay open as long as its track, - so its boundary is no boundary at all. A transport that never learns about - the change still cannot keep reading. - - **Session.** The `moq-net` publisher watches one narrowing signal per - session and resets the subscriptions outside the grant. The cost is per - session, not per frame, but a transport reading the model directly is not - bound by it. -- Decide the publish side: whether a route or broadcast the session published - outside the narrowed grant is retracted or aborted. -- Relay revalidation uses it: a re-checked grant with the same root narrows - the session's origin handles instead of closing the session - (`Lease::ended` reporting "grant narrowed" in `rs/moq-relay/src/auth.rs`), - on every accept path (native, WebSocket, io_uring, `moq --listen`). A - changed root still closes. -- Prove the deafen case at the model layer: subscribe under a room prefix, - narrow with a grant that excludes that audio path, and assert the existing - subscription closes with `Unauthorized` and no further objects arrive, - while a sibling path under the same prefix keeps flowing. -- Benchmark the per-subscribe and routing cost in - `rs/moq-net/benches/origin.rs`, swept over routes and subscribers, with an - origin that never narrows as the baseline. - -Public API: additive on moq-net (one method) and on moq-relay's revalidate -path. Wire: none. - -## Closes - -- [#2714](https://github.com/moq-dev/moq/issues/2714) - close this issue when the quest finishes - -## Related - -- [In-band auth](/quest/m1/auth/README.md) - the token union that a narrowing later revalidates diff --git a/quest/m1/relay-peer-set.md b/quest/m1/relay-peer-set.md deleted file mode 100644 index 14a2e46b24..0000000000 --- a/quest/m1/relay-peer-set.md +++ /dev/null @@ -1,31 +0,0 @@ -# [M] A wire consumer tells a client hop from a peer hop - -## Goal - -A sidecar reading a relay over the wire knows whether a route entered on that -relay or came from one of its cluster peers, and every way a peer is admitted -can say it is one. In-process consumers already read `Route::source()` and -`origin::Consumer::local()`; a sidecar sees `[.., x, relay]` and cannot tell a -client `x` from a peer `x`. moq.pro's Python sidecar answers it today with a -hop-id bit prefix (`agent/moq_agent/matching.py`). - -## Plan - -- The relay publishes its cluster peer set in its stats broadcast: the hops of - the peers whose routes it currently holds, which is exactly what makes a - delivered route a peer's. Derive it from the origin's peer-marked routes - rather than from session bookkeeping, so the wire and in-process answers - cannot disagree. Name the frame's shape in `doc/bin/relay/config.md` (stats - section) so the sidecar and the producer agree, and land it after the - FlatBuffers stats flavor settles the track layout. -- `moq auth serve` can mark a peer (`peer: true` in the grant): an mTLS grant - for the cluster CA, and a JWT whose claims say so. Today only dials the relay - makes, LAN peers, and a custom auth server's grant mark one, so a stock mesh - counts inbound JWT and mTLS peers as clients. - -Public API: additive on moq-stats and moq-auth. Wire: the stats broadcast gains -a peer-set frame; the JWT claims may gain a field. - -## Related - -- [Route cost](/quest/m1/route-cost.md) - the other route fact JS lacks diff --git a/quest/m2/README.md b/quest/m2/README.md index d80049439f..80fa09dd1b 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -53,6 +53,7 @@ upstream release waits in [m4](/quest/m4/README.md). - [Compressed tracks](/quest/m2/flate/README.md) - any track compresses per group from every language, not only the JSON modes - [Binary delta stats](/quest/m2/stats-delta.md) - an on-demand varint delta flavor of every stats track, if relay encode CPU still matters after the JSON fixes - [#3115](/quest/m2/3115-moqsink-the-publication-has-no-generation-so-a-flush.md) - moqsink: a flushing restart after EOS opens a new publication generation +- [Plan: routing without a hop list](/quest/m2/plan-routing-origin.md) - whether announcements can drop the hop list and stay loop-free once stitching keys on the subscribe reply - [Redundant ingest](/quest/m2/redundant-ingest.md) - decide whether two publishers sharing one epoch may splice, and who declares the incumbent dead before the keep-alive does - [Multipath spike](/quest/m2/multipath-spike.md) - whether bonded contribution over multipath QUIC is worth building, given it needs noq on both ends - [Receive timestamps](/quest/m2/quic-receive-ts.md) - per-packet arrival times in ACKs, the feedback GCC and deadlines need diff --git a/quest/m2/plan-routing-origin.md b/quest/m2/plan-routing-origin.md new file mode 100644 index 0000000000..5a0c7f74f3 --- /dev/null +++ b/quest/m2/plan-routing-origin.md @@ -0,0 +1,41 @@ +# [M] Plan: routing without a hop list + +## Goal + +Decide whether moq-lite announcements can drop the full hop list and still +route loop-free, then write the decision into quests. Today every +announcement carries the Hop IDs it traversed. That path does three jobs: +loop prevention (a relay discards a path containing itself), request +exclusion (never serve a request back through the peer that made it), and +route identity (the first hop decides whether a failover may stitch +seamlessly). The hop list was designed before prefix claims, and once the Spread quest in +[Wildcard advertisements](/quest/m1/wildcard/README.md) moves stitching +identity onto the subscribe reply, only the first two jobs remain. + +## Plan + +Open questions, to settle with the maintainer: + +- Loop freedom without a path. Rising cost alone ("cost + 1, never + advertise lower") counts to infinity after a withdrawal, as two relays + offer each other the stale route at ever higher cost. Remembering each + route's immediate neighbor and never echoing a route back to it (split + horizon) stops two-relay loops, but not three-relay loops. It also hides + backups: a relay never learns an alternative that passes back through the + neighbor it came from. Babel's feasibility condition (RFC 8966: a + per-origin sequence number; a route is feasible when its sequence number is + newer, at any cost, or equal and cheaper than the best seen for it) is + loop-free with only the origin on the wire. Weigh it and + any simpler scheme against what the relay cluster actually needs. +- What replaces request exclusion, and whether it still matters once + announcements are loop-free. +- What the stats and sidecar consumers lose without a path, and whether + anything besides the origin must stay on the wire. +- The version it lands in (lite-07 is `moq-lite-07-wip` today) and the + bridge to versions that still carry a hop list. lite-07 already compresses + each announcement's hop chain against a live base (#4196, + `rs/moq-net/src/lite/compress.rs`); dropping the hop list removes that too. + +## Related + +- [Wildcard advertisements](/quest/m1/wildcard/README.md) - its Spread quest moves stitching identity to the subscribe reply, which this builds on diff --git a/rs/moq-relay/src/auth.rs b/rs/moq-relay/src/auth.rs index a3eb5dda48..57a5316b23 100644 --- a/rs/moq-relay/src/auth.rs +++ b/rs/moq-relay/src/auth.rs @@ -273,7 +273,7 @@ impl Lease { /// revoked, or was re-checked into one that no longer covers the token. /// /// A changed root or a narrower grant ends it: origin handles cannot yet narrow - /// a live scope in place (tracked by `quest/m1/origin-narrowing.md`). A flipped + /// a live scope in place (tracked by `quest/m1/auth/narrowing.md`). A flipped /// `peer` ends it too, since the routes it already announced would be /// misreported as entering here or from a peer. A changed tier keeps the /// session and moves its [stats](Self::with_stats) to the new tier. From a9f638e03ff7ddc207abc2850b2f3d0576b1abfa Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 08:41:28 -0700 Subject: [PATCH 43/45] docs(quest): plan quests for issues #4219, #4246-#4249 (#4250) Co-authored-by: Claude Opus 5.5 --- quest/m1/README.md | 5 +++++ quest/m1/bbr-idle-burst.md | 31 ++++++++++++++++++++++++++++ quest/m1/close-codes.md | 39 +++++++++++++++++++++++++++++++++++ quest/m1/js-group-guard.md | 24 +++++++++++++++++++++ quest/m1/js-timeline-scans.md | 33 +++++++++++++++++++++++++++++ quest/m1/opus-conceal.md | 29 ++++++++++++++++++++++++++ quest/m1/quic/qmux.md | 4 +++- 7 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 quest/m1/bbr-idle-burst.md create mode 100644 quest/m1/close-codes.md create mode 100644 quest/m1/js-group-guard.md create mode 100644 quest/m1/js-timeline-scans.md create mode 100644 quest/m1/opus-conceal.md diff --git a/quest/m1/README.md b/quest/m1/README.md index d59dd8df2a..4df3d10a25 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -21,6 +21,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [libmoq hidden opt-in](/quest/m1/libmoq-hidden.md) - `moq_origin_announced` takes a `hidden` flag so C callers can list `.`-named broadcasts - [lite-07 count settle](/quest/m1/lite-count-settle.md) - moq-lite-07 subscribers stop waiting for a subscription's tail once SUBSCRIBE_END's stream count is reached - [Dropped sources](/quest/m1/dropped-sources.md) - consumers see the producer's real error on every end path, never `Dropped` +- [JS group guard](/quest/m1/js-group-guard.md) - a `@moq/net` publisher abandons a group past its max age without an unhandled rejection - [Track tail interop](/quest/m1/track-tail-interop.md) - a Rust publisher ending a track with a group in flight is read to its end by the JS subscriber, and the reverse, in `just test interop` - [Interop flakes](/quest/m1/interop-flakes.md) - the interop harness passes with other runs sharing the machine - [Signal.race cleanup](/quest/m1/signal-race.md) - `Signal.race` releases its signal listeners when its result loses a race @@ -30,6 +31,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` is the one way to end a broadcast in every language, a permanent retraction that leaves in-flight tracks alone - [CLI inspection](/quest/m1/cli-inspect/README.md) - `moq ls` lists what is live and `moq fetch` reads a group over MoQ, and a guide shows how to inspect a relay - [Session close](/quest/m1/session-close.md) - a graceful session end withdraws announces and waits one second for the ack +- [Close codes](/quest/m1/close-codes.md) - a client sees the peer's application close code over WebSocket and raw QUIC, like WebTransport - [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust - [Bindings caught up](/quest/m1/announce-live-bindings.md) - moq-ffi, libmoq, and every wrapper yield the same flat announce event, `Live` included - [Optional max age](/quest/m1/ietf-max-age.md) - max age is optional, set only by the publisher, and crosses moq-transport as MAX_CACHE_DURATION @@ -50,6 +52,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [C++ through moq-ffi](/quest/m1/cpp/README.md) - generated C++ over moq-ffi with futures and expected-style errors, shipped as a tarball, vcpkg, and Conan, and adopted by the OBS plugin - [moq-c](/quest/m1/moq-c.md) - libmoq ships as `moq-c`, beside `moq-cpp`, with its C header and library unchanged - [OBS native codecs](/quest/m1/obs-moq-video/README.md) - remove FFmpeg decoding dependencies, deliver GPU frames, and use native audio/video encoders +- [Opus concealment](/quest/m1/opus-conceal.md) - a lost Opus packet conceals the last packet's length, not 120 ms - [Audio codecs](/quest/m1/audio-codecs/README.md) - platform audio codecs, explicit unsupported cases, and channel layouts up to 7.1 - [CMAF Opus](/quest/m1/cmaf-opus-dops.md) - fMP4 import and export keep the Opus pre-skip and gain - [NVDEC teardown](/quest/m1/nvdec-teardown.md) - dropping an NVDEC decoder no longer segfaults @@ -63,11 +66,13 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Own the QUIC stack](/quest/m1/quic/README.md) - the moq-noq fork carries ACK progress, reliable reset, hierarchical scheduling, deadlines, probing, keep-alive, peer limits, careful resume, ECN, and qmux +- [BBR idle burst](/quest/m1/bbr-idle-burst.md) - a BBRv3 burst after a long idle paces near the learned bandwidth, proven by a fork regression - [P2P](/quest/m1/p2p/README.md) - opted-in clients serve each other over data channels and iroh while the relay stays the rendezvous and the fallback, under application policy - [One port](/quest/m1/one-port/README.md) - a relay speaks QUIC, STUN, WebRTC media, and SRT on one UDP port and HTTP, RTMP, and RTMPS on one TCP port - [Scope track priority](/quest/m1/track-priority-scope.md) - priority orders one owner's streams, and a shared cluster session is fair across tenants - [Stream sessions](/quest/m1/uring-tcp/README.md) - serve WebSocket and HTTP from the io_uring workers, where io_uring pays off most - [IETF on the ring](/quest/m1/uring-ietf.md) - the io_uring workers serve moq-transport sessions too, so a uring relay drops no client protocol +- [JS timeline scans](/quest/m1/js-timeline-scans.md) - a `@moq/net` publisher's per-group cost stays flat as the retained window grows - [BBR ACK cleanup](/quest/m1/bbr-ack-cleanup.md) - packet bookkeeping scales with completed entries instead of scanning the flight on every ACK - [Perf](/quest/m1/perf/README.md) - eliminate measured hot-path costs across moq-uring, kio, and the moq-net model - [#2924](/quest/m1/2924-moq-relay-tls-rotation-is-not-atomic-across-thread-per.md) - every listener on both runtimes shares one reloadable served identity, so rotation is atomic and generate works with workers diff --git a/quest/m1/bbr-idle-burst.md b/quest/m1/bbr-idle-burst.md new file mode 100644 index 0000000000..480cbad830 --- /dev/null +++ b/quest/m1/bbr-idle-burst.md @@ -0,0 +1,31 @@ +# [S] BBR idle burst + +## Goal + +After a long keep-alive-only idle, a BBRv3 (`delay`) sender paces its next +burst near the bandwidth it learned before, not at one packet per RTT. A +regression test proves it. + +## Plan + +- The likely fix already shipped: moq-dev/noq#5 tells the controller of + starvation before the next send, released in moq-noq 1.3.1 and pinned by + #4206. The reporter ran 1.3.0. Nobody has reproduced the stall on either. +- In the fork, add a virtual-time transport test: learn the bandwidth, run + keep-alive only for about five minutes, send 250 KB, and assert the pacing + rate stays at or above about 0.9x the earlier max bandwidth. It must fail on + 1.3.0 and pass on 1.3.1. The existing label tests do not check the rate. +- If 1.3.1 still stalls, find the remaining cause (a stale `bw_shortterm` or a + ProbeRTT effect after idle) and fix it in the fork. Dropping the estimate + after long idle is a policy change for the m2 study, not this quest. +- The `iroh` feature uses upstream noq, which lacks the fix; offering it there + belongs to the upstream quest. + +## Closes + +- [#4219](https://github.com/moq-dev/moq/issues/4219) - the first send after an idle period is paced at a trickle + +## Related + +- [Upstream the fork](/quest/m1/quic/upstream.md) - offer the starvation fix to n0-computer/noq +- [BBR3 app-limited](/quest/m2/quic-bbr-app-limited.md) - broader media measurements diff --git a/quest/m1/close-codes.md b/quest/m1/close-codes.md new file mode 100644 index 0000000000..f1e3e9a34f --- /dev/null +++ b/quest/m1/close-codes.md @@ -0,0 +1,39 @@ +# [M] Close codes on every transport + +## Goal + +A client sees the application close code its peer sent, for example +`SessionError::App(4011)` from `Session::abort` or `Request::reject`, over +WebSocket (qmux) and raw QUIC (`moqt://`), as it already does over +WebTransport. Never `Transport("connection closed")` or its own `Internal`. + +## Plan + +Both bugs are upstream; fix them at the source, release, and bump the pins. + +- qmux 0.5.1 (`moq-dev/web-transport`) lets later writes overwrite the + recorded close in `session.rs`: the WS Close frame read after + APPLICATION_CLOSE (reader loop, backend `send_replace`), a local `close()` + after the peer closed, and a second peer APPLICATION_CLOSE. `accept_uni` and + `accept_bi` also return a bare `Closed`. Make the first close win, make + `close()` a no-op once closed, and have `accept_*` return the recorded + reason. Add a qmux test where APPLICATION_CLOSE and EOF arrive together. +- `web-transport-moq` 1.3.1 (`moq-dev/noq`) maps `ApplicationClosed` only + through the HTTP/3 code space in `error.rs`, so a raw `moqt://` code yields + no `session_error()`. Map raw QUIC codes directly. +- moq-net's `close(Internal)` after a transport error is correct: closing a + closed connection does nothing. Do not work around it here. +- One moq-tokio regression runs the issue's three cases (abort after accept, + abort then drop, reject during handshake) over `https://`, `ws://`, and + `moqt://`, and fails on the current pins. +- Check whether `@moq/net`'s qmux peer keeps the first close too; fix it in + the same PR if not. + +## Closes + +- [#4249](https://github.com/moq-dev/moq/issues/4249) - application close code is lost over the WebSocket (qmux) transport + +## Related + +- [qmux on noq](/quest/m1/quic/qmux.md) - the rewrite must keep first-close-wins +- [io_uring close](/quest/m1/quic/uring-close.md) - the same symptom class on the io_uring backend diff --git a/quest/m1/js-group-guard.md b/quest/m1/js-group-guard.md new file mode 100644 index 0000000000..eca7b3effb --- /dev/null +++ b/quest/m1/js-group-guard.md @@ -0,0 +1,24 @@ +# [XS] JS group guard + +## Goal + +A `@moq/net` publisher serving a group past the subscription's max age +abandons it without an unhandled rejection, over moq-lite and IETF. Node no +longer crashes on "group exceeded the subscription max age budget". + +## Plan + +- `#guard` in `js/net/src/group.ts` rejects early without attaching a handler + to the write it was handed, which the lite and IETF publishers have already + started. +- Pass a thunk (`() => stream.write(...)`) and check expiry before calling it, + as Rust's `poll_expired` does in `rs/moq-net/src/lite/publisher.rs`. This + also skips writing into a group about to be abandoned. A bare + `operation.catch(() => {})` was rejected: it still starts the write. +- `guardGroup` is internal; no public API change. +- Regression: serve an already-stale group and assert no `unhandledRejection` + fires. + +## Closes + +- [#4247](https://github.com/moq-dev/moq/issues/4247) - Group.Consumer max-age guard leaves the guarded write without a handler diff --git a/quest/m1/js-timeline-scans.md b/quest/m1/js-timeline-scans.md new file mode 100644 index 0000000000..b46d4c16c7 --- /dev/null +++ b/quest/m1/js-timeline-scans.md @@ -0,0 +1,33 @@ +# [M] JS timeline scans + +## Goal + +A `@moq/net` publisher's per-group cost stays flat as the retained window +grows. `Subscriber.#drift()`, `#reach()`, and the prune pass stop scanning +every cached group. + +## Plan + +- `js/net/src/track.ts` keeps the timeline in a `Map` of every cached group, + consumed ones included. `#drift` and `#reach` scan it per popped group and on + every guard re-evaluation, and `#prune`/`#schedulePrune` scan it per publish. +- Mirror Rust's `rs/moq-net/src/model/track.rs`: keep sequences sorted (append + fast path, binary insert otherwise). Drift walks backward to the first + stamped, non-errored group in range; reach binary-searches the successor. + Fold the prune scans in if the benchmark shows them. Memoizing per revision + was rejected: a new group still costs O(n) per subscriber. An incremental + edge was rejected: invalidation on aborts and cursor ends gets fiddly. +- Add `js/net/bench/track.ts`: microseconds per published group through + Producer, Subscriber, `tryRecvGroup`, and the guard, with no transport. Sweep + retained groups (about 25 to 1500) against subscribers (1 to 16), plus a case + with guards in flight. Wire it into `nightly.yml` beside `broadcasts.ts`. The + fix passes when the retained-groups axis is flat. + +## Closes + +- [#4246](https://github.com/moq-dev/moq/issues/4246) - per-group publishing cost grows with the retained window + +## Related + +- [Browser benchmarks](/quest/m1/browser-benchmarks.md) - the browser media path; this bench covers the track model +- [JS group guard](/quest/m1/js-group-guard.md) - the thunk there cuts guard calls, not the scan cost diff --git a/quest/m1/opus-conceal.md b/quest/m1/opus-conceal.md new file mode 100644 index 0000000000..507d45a24c --- /dev/null +++ b/quest/m1/opus-conceal.md @@ -0,0 +1,29 @@ +# [XS] Opus concealment length + +## Goal + +`moq_audio` `Decoder::decode(&[])` conceals one Opus packet, the length of the +last real one, instead of 120 ms. The API is unchanged and lands on main. + +## Plan + +- `rs/moq-audio/src/decode/decoder.rs` passes `max_frame_size` (120 ms) as + libopus's `frame_size` on every call, and libopus conceals exactly that many + samples. Record the last packet's sample count (`opus_packet_get_nb_samples`) + and pass it for an empty packet; it is always a multiple of 2.5 ms. +- Refuse loss before any packet has decoded, rather than surfacing libopus's + `BUFFER_TOO_SMALL`. +- Document on `decode` how much an empty packet conceals. +- Regression from the issue's repro: one lost 20 ms packet yields 20 ms. +- A `conceal(duration)` method and `decode(Option<&[u8]>)` were rejected: no + consumer needs a caller-chosen length, and the latter breaks the common case. + How platform backends signal loss belongs to the decode-backend seam. + +## Closes + +- [#4248](https://github.com/moq-dev/moq/issues/4248) - `Decoder::decode(&[])` conceals 120 ms of Opus audio for one lost packet + +## Related + +- [Audio loss recovery](/quest/m2/audio-loss-recovery.md) - the FEC versus concealment policy study +- [Decode backend](/quest/m1/audio-codecs/decode-backend.md) - where platform decoders signal loss diff --git a/quest/m1/quic/qmux.md b/quest/m1/quic/qmux.md index fef128ac3a..c67682ead8 100644 --- a/quest/m1/quic/qmux.md +++ b/quest/m1/quic/qmux.md @@ -38,7 +38,9 @@ Add the missing wire evidence before release: golden draft-02 vectors, bidirectional interoperability against the published `qmux` 0.5.x crate, and the TypeScript qmux/WebSocket peer used by `js/net`. Preserve rejection of prohibited QUIC frames, params-first setup, record-size validation, close and -reset semantics, keep-alive behavior, and bounded flow-control tests. +reset semantics (the first recorded close wins, per +[close codes](/quest/m1/close-codes.md)), keep-alive behavior, and bounded +flow-control tests. The crate lives in the fork's workspace as `moq-noq-qmux`, so the stream state machine internals it drives stay crate-private there; `moq-dev/web-transport`'s From 6a02f515dad456eca5dd4ad854f5245e240d77c8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 09:36:18 -0700 Subject: [PATCH 44/45] feat: end a broadcast with close() in every language (#4031) Co-authored-by: Claude Opus 5.5 --- cpp/obs/src/moq-output.cpp | 4 +- cpp/obs/test/moq-output-test.cpp | 2 +- dart/moq/test/moq_test.dart | 3 +- dart/moq_ffi/lib/src/moq.dart | 30 +- doc/lib/c/index.md | 2 +- doc/lib/dart/index.md | 3 +- doc/lib/go/index.md | 5 +- doc/lib/kt/index.md | 3 +- doc/lib/py/index.md | 4 +- doc/lib/rs/moq-net.md | 6 +- doc/lib/swift/index.md | 4 +- go/wrapper/example_test.go | 6 +- go/wrapper/moq_test.go | 46 +- go/wrapper/origin.go | 5 +- go/wrapper/publish.go | 12 +- go/wrapper/reconnect_test.go | 2 +- js/net/src/broadcast.ts | 12 +- js/net/src/origin.test.ts | 5 +- js/net/src/origin.ts | 4 +- .../jvmAndAndroidMain/kotlin/dev/moq/Moq.kt | 3 +- .../kotlin/dev/moq/Server.kt | 4 +- .../kotlin/dev/moq/SmokeTest.kt | 11 +- py/moq-rs/README.md | 6 +- py/moq-rs/moq/origin.py | 5 +- py/moq-rs/moq/publish.py | 13 +- py/moq-rs/tests/test_local.py | 35 +- py/moq-rs/tests/test_server.py | 8 +- quest/m1/README.md | 3 +- quest/m1/broadcast-close/README.md | 49 -- quest/m1/broadcast-close/bindings.md | 29 -- quest/m1/broadcast-close/rust.md | 49 -- .../remove.md => broadcast-remove.md} | 4 +- quest/m1/{broadcast-close => }/kotlin-end.md | 4 - quest/m1/session-close.md | 4 - rs/libmoq/README.md | 2 +- rs/libmoq/c-tests/decode-output.c | 4 +- rs/libmoq/src/api.rs | 21 +- rs/libmoq/src/publish.rs | 10 +- rs/libmoq/src/test.rs | 153 ++++--- rs/moq-bench/src/connection.rs | 4 +- rs/moq-boy/src/main.rs | 5 +- rs/moq-ffi/src/origin.rs | 2 +- rs/moq-ffi/src/producer.rs | 25 +- rs/moq-ffi/src/test.rs | 81 ++-- rs/moq-ffi/uniffi.toml | 5 + rs/moq-gst/src/sink/imp.rs | 5 +- rs/moq-gst/src/source/imp.rs | 2 +- rs/moq-hls/src/export/mod.rs | 6 +- rs/moq-hls/src/export/rendition.rs | 5 +- rs/moq-hls/src/server/mod.rs | 6 +- rs/moq-net/benches/origin.rs | 4 +- rs/moq-net/src/ietf/subscriber.rs | 98 ++-- rs/moq-net/src/lite/subscriber.rs | 24 +- rs/moq-net/src/model/broadcast.rs | 426 +++++++++--------- rs/moq-net/src/model/origin.rs | 25 +- rs/moq-net/tests/broadcast_close.rs | 72 +++ rs/moq-net/tests/finished_broadcast_mock.rs | 2 +- rs/moq-relay/src/cluster.rs | 4 +- rs/moq-rtc/src/server/mod.rs | 4 +- rs/moq-rtmp/src/dial.rs | 11 +- rs/moq-rtmp/src/server.rs | 11 +- rs/moq-srt/src/ts.rs | 11 +- rs/moq-stats/src/produce.rs | 5 +- rs/moq-tokio/examples/chat.rs | 6 +- rs/moq-tokio/examples/clock.rs | 5 +- rs/moq-tokio/src/origin.rs | 2 +- rs/moq-tokio/tests/broadcast.rs | 4 +- rs/moq-transcode/src/lib.rs | 2 +- swift/Sources/Moq/Broadcast.swift | 11 +- swift/Sources/Moq/Origin.swift | 6 +- swift/Tests/MoqTests/SmokeTests.swift | 33 +- 71 files changed, 761 insertions(+), 726 deletions(-) delete mode 100644 quest/m1/broadcast-close/README.md delete mode 100644 quest/m1/broadcast-close/bindings.md delete mode 100644 quest/m1/broadcast-close/rust.md rename quest/m1/{broadcast-close/remove.md => broadcast-remove.md} (73%) rename quest/m1/{broadcast-close => }/kotlin-end.md (81%) create mode 100644 rs/moq-ffi/uniffi.toml create mode 100644 rs/moq-net/tests/broadcast_close.rs diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index bbe39e6879..7a5e071951 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -301,10 +301,10 @@ void MoQOutput::Reset() } audio_tracks.clear(); - // Finish the broadcast so the origin unpublishes it immediately; Start() + // Close the broadcast so the origin retracts it immediately; Start() // creates a fresh one on restart. if (broadcast > 0) { - moq_publish_finish(broadcast); + moq_publish_close(broadcast); broadcast = 0; } } diff --git a/cpp/obs/test/moq-output-test.cpp b/cpp/obs/test/moq-output-test.cpp index e28039ea60..c20c9d30c8 100644 --- a/cpp/obs/test/moq-output-test.cpp +++ b/cpp/obs/test/moq-output-test.cpp @@ -233,7 +233,7 @@ int32_t moq_publish_announce(uint32_t, const moq_route *) return 0; } -int32_t moq_publish_finish(uint32_t) +int32_t moq_publish_close(uint32_t) { return 0; } diff --git a/dart/moq/test/moq_test.dart b/dart/moq/test/moq_test.dart index 8fcc824ab2..278456ee19 100644 --- a/dart/moq/test/moq_test.dart +++ b/dart/moq/test/moq_test.dart @@ -102,7 +102,8 @@ void main() { client.close(); serverSession.cancel(code: 0); track.finish(); - broadcast.finish(); + broadcast.close(); + broadcast.close(); // a second close is a no-op server.close(); }); diff --git a/dart/moq_ffi/lib/src/moq.dart b/dart/moq_ffi/lib/src/moq.dart index 7016449c5d..ef1383523e 100644 --- a/dart/moq_ffi/lib/src/moq.dart +++ b/dart/moq_ffi/lib/src/moq.dart @@ -8,9 +8,7 @@ import "dart:ffi"; import "dart:io" show Platform, File, Directory; import "dart:isolate"; import "dart:typed_data"; - import "package:ffi/ffi.dart"; - import "uniffi_runtime.dart"; export "uniffi_runtime.dart"; @@ -6125,6 +6123,7 @@ abstract class MoqBroadcastProducerInterface { required MoqJsonStreamConfig config, }); void announce({required MoqRoute route}); + void close(); MoqBroadcastConsumer consume(); MoqBroadcastDynamic dynamic_(); void finish(); @@ -6266,6 +6265,15 @@ class MoqBroadcastProducer implements MoqBroadcastProducerInterface { }, moqExceptionErrorHandler); } + void close() { + return rustCall((status) { + uniffi_moq_ffi_fn_method_moqbroadcastproducer_close( + uniffiClonePointer(), + status, + ); + }, moqExceptionErrorHandler); + } + MoqBroadcastConsumer consume() { return rustCallWithLifter( (status) => uniffi_moq_ffi_fn_method_moqbroadcastproducer_consume( @@ -10540,6 +10548,14 @@ external void uniffi_moq_ffi_fn_method_moqbroadcastproducer_announce( Pointer uniffiStatus, ); +@Native, Pointer)>( + assetId: _uniffiAssetId, +) +external void uniffi_moq_ffi_fn_method_moqbroadcastproducer_close( + Pointer ptr, + Pointer uniffiStatus, +); + @Native Function(Pointer, Pointer)>( assetId: _uniffiAssetId, ) @@ -12220,6 +12236,9 @@ uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_stream(); @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_announce(); +@Native(assetId: _uniffiAssetId) +external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_close(); + @Native(assetId: _uniffiAssetId) external int uniffi_moq_ffi_checksum_method_moqbroadcastproducer_consume(); @@ -12748,7 +12767,7 @@ void _checkApiChecksums() { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqannouncedbroadcast_available() != - 42497) { + 37458) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqannouncedbroadcast_cancel() != 63175) { @@ -12820,13 +12839,16 @@ void _checkApiChecksums() { if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_announce() != 13700) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } + if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_close() != 19191) { + throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); + } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_consume() != 27634) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_dynamic() != 55635) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } - if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_finish() != 7183) { + if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_finish() != 29562) { throw UniffiInternalError.panicked("UniFFI API checksum mismatch"); } if (uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_audio() != diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index f5d152c8af..e3fca369d4 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -46,7 +46,7 @@ beside `pkgconfig/`. - **Server.** `moq_server_listen` binds before it returns (a bad address or certificate fails there) and hands each incoming session to `on_request` as a request handle. Read `moq_session_request_path` and `_query` to route and authenticate, then `moq_session_request_accept` (a session handle, with origins like `moq_session_connect`) or `moq_session_request_reject` with an HTTP-style code (401 and 403 become the protocol's unauthorized close). An accepted session reports `1` once SETUP completes and never reconnects. `moq_server_addr` reports an ephemeral port and `moq_server_fingerprints` the hashes a client pins for a `tls_generate` certificate. `moq_server_close` stops listening; its terminal callback fires once the sockets are released. - **Demand.** A watcher on a published track (`moq_publish_track_demand`, `moq_publish_media_demand`, `moq_encode_video_demand`, `moq_encode_audio_demand`) calls `on_demand` with `MOQ_DEMAND_USED` or `MOQ_DEMAND_UNUSED` right away and again on every change, so an encoder on a battery-powered device runs only while someone is watching. The first call is the current state, so a track that went unused before the watcher existed still reports it. `moq_publish_demand_cancel` stops it; the terminal callback still fires. A container has no single demand and is refused. Demand follows the last real subscriber: an origin that served the track drops its source copy on the unused edge, so a cache linger never delays it. Only a relay keeps what it already delivered warm for 30 seconds, and a returning subscriber is served that cache only once the publisher confirms it is still current. - **Requests.** `moq_publish_dynamic` serves subscriptions to tracks the broadcast never declared: each arrives as a request handle, read its name with `moq_track_request_name`, then `moq_track_request_accept` (a raw track handle), `moq_track_request_video` / `_audio` (the media handle `moq_publish_video` / `_audio` return), or `moq_track_request_abort` with an application code the subscriber sees. Without a live handler an unknown name is refused. `moq_publish_track_dynamic` does the same for fetches of groups a track no longer has cached, delivered as `moq_group_request_*` (`sequence`, `priority`, `frame_start`); `moq_group_request_accept` starts the producer at `frame_start` so written frames keep their group indices. Register it with `moq_track_request_dynamic` before accepting a track that was itself requested by a fetch, so that pending group survives the transition. Both handlers stop with `moq_publish_dynamic_cancel`. -- **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON and binary data tracks (snapshot or stream, each advertised in the catalog for as long as it lives), group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (unannounced producer, invisible to everyone), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a literal prefix and an optional relative pattern filter; `moq_announce_update.prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts); name the dot segment in `prefix` to list them. +- **Everything the bindings can do** ([list](/lib/#what-every-binding-can-do)): media publish and consume with the catalog managed for you, raw pixels and PCM with the codec inside (`moq_encode_video`, `moq_encode_audio`, and the `moq_decode_*` mirrors), raw tracks with timestamps and datagrams, JSON and binary data tracks (snapshot or stream, each advertised in the catalog for as long as it lives), group fetch, catalog sections, shared video properties, and stalled hints. The three advertising operations are `moq_origin_create_broadcast` (unannounced producer, invisible to everyone), `moq_publish_announce` / `moq_publish_unannounce` (exact-path advertisement), `moq_publish_close` (ends the broadcast for good and releases its handle; `moq_publish_finish` is its deprecated alias), and `moq_origin_dynamic` (a claim over a path prefix and everything beneath it; `""` for everything). A route is a capability, not an inventory. `moq_origin_announced` takes a literal prefix and an optional relative pattern filter; `moq_announce_update.prefix` stays relative to the origin, while `captures` reports what each wildcard matched when `has_captures` is true. Paths with a `.`-prefixed segment below the prefix are [hidden](/concept/moq-lite#hidden-broadcasts); name the dot segment in `prefix` to list them. ```c moq_client_config config; diff --git a/doc/lib/dart/index.md b/doc/lib/dart/index.md index 62f4f630ec..b27b6b786c 100644 --- a/doc/lib/dart/index.md +++ b/doc/lib/dart/index.md @@ -65,7 +65,8 @@ await for (final request in server.requests()) { The three advertising operations: `moq.createBroadcast(path)` (or `origin.createBroadcast`) returns an unannounced producer, invisible to everyone; `broadcast.announce(route:)` / `broadcast.unannounce()` own that exact-path -advertisement; `origin.dynamic_(prefix:, route:)` claims `prefix` and +advertisement, and `broadcast.close()` ends the broadcast for good (a second +call is a no-op; `finish()` is its deprecated alias); `origin.dynamic_(prefix:, route:)` claims `prefix` and every path beneath it (`''` for everything; Dart spells the origin method `dynamic_` because `dynamic` is reserved). Hold the returned handle while the claim should stay advertised, and reject the requests you will not serve. A diff --git a/doc/lib/go/index.md b/doc/lib/go/index.md index d910f5d17c..3c63e18451 100644 --- a/doc/lib/go/index.md +++ b/doc/lib/go/index.md @@ -66,7 +66,7 @@ video, _ := broadcast.EncodeVideo( ) _ = video.Write(moq.VideoFrame{TimestampUs: pts, Data: rgba}) _ = broadcast.Announce(moq.Route{}) -broadcast.Finish() // keep the producer reachable while publishing, then finish explicitly +broadcast.Close() // keep the producer reachable while publishing, then close explicitly ``` For locally encoded media, call `MediaProducer.Flush(timestampUs)` after `WriteFrame` with the same broadcast-clock PTS. It measures catalog jitter at the transport handoff. File, pipe, and network imports should omit `Flush`; built-in encoders observe their own output. @@ -76,7 +76,8 @@ Call `media.Discontinuity()` when the source seeks, pauses, or changes its time The three advertising operations: `client.CreateBroadcast(path)` (or `origin.CreateBroadcast`) returns an unannounced producer, invisible to everyone; `broadcast.Announce(route)` / `broadcast.Unannounce()` own that exact-path -advertisement; `origin.Dynamic(prefix, route)` claims `prefix` and every +advertisement, and `broadcast.Close()` ends the broadcast for good (a second +call is a no-op; `Finish` is its deprecated alias); `origin.Dynamic(prefix, route)` claims `prefix` and every path beneath it (`""` for everything). Hold the returned `OriginDynamic` while the claim should stay advertised, and reject the requests you will not serve. A route is a capability, not an inventory. `Announced(options)` combines diff --git a/doc/lib/kt/index.md b/doc/lib/kt/index.md index 5ba62e7061..710cd9ebdd 100644 --- a/doc/lib/kt/index.md +++ b/doc/lib/kt/index.md @@ -58,7 +58,8 @@ Call `media.discontinuity()` when the source seeks, pauses, or changes its time The three advertising operations: `moq.createBroadcast(path)` (or `origin.createBroadcast`) returns an unannounced producer, invisible to everyone; `broadcast.announce(route)` / `broadcast.unannounce()` own that exact-path -advertisement; `origin.dynamic(prefix, route)` claims `prefix` and every +advertisement, and `broadcast.close()` (or `use { }`) releases the producer, +ending the broadcast once no `dynamic()` handle remains; `origin.dynamic(prefix, route)` claims `prefix` and every path beneath it (`""` for everything). Hold the returned `OriginDynamic` while the claim should stay advertised, and reject the requests you will not serve. A route is a capability, not an inventory. `announcements(config)` takes diff --git a/doc/lib/py/index.md b/doc/lib/py/index.md index 4d9eac1b70..79820f7f68 100644 --- a/doc/lib/py/index.md +++ b/doc/lib/py/index.md @@ -76,7 +76,9 @@ Call `audio.discontinuity()` when the source seeks, pauses, or changes its time The three advertising operations, as the other bindings spell them: `client.create_broadcast(path)` (or `OriginProducer.create_broadcast`) returns an unannounced producer, invisible to everyone; `broadcast.announce(route)` / -`broadcast.unannounce()` own that exact-path advertisement; +`broadcast.unannounce()` own that exact-path advertisement, and +`broadcast.close()` ends the broadcast for good (a second call is a no-op; +`finish()` is its deprecated alias); `origin.dynamic(prefix, route)` claims `prefix` and every path beneath it (`""` for everything). Hold the returned handle while the claim should stay advertised, and reject the requests you will not serve. A route is a diff --git a/doc/lib/rs/moq-net.md b/doc/lib/rs/moq-net.md index 5f6cb60673..bd775dbe4b 100644 --- a/doc/lib/rs/moq-net.md +++ b/doc/lib/rs/moq-net.md @@ -114,8 +114,12 @@ Three operations, on an origin: - `broadcast.announce(route)` / `broadcast.unannounce()` own that advertisement. Announcing again re-prices the standing route, which competes on cost with remote routes at the same path (a tie goes to the local - broadcast). The route retracts on `unannounce()`, `finish()`, or the last + broadcast). The route retracts on `unannounce()`, `close()`, or the last producer dropping; tracks already in flight carry on to their own end. +- `broadcast.close()` ends the broadcast for good: it retracts, leaves local + discovery, and answers every later track lookup with `Unroutable`. Tracks + already subscribed carry on to their own end. It can never be announced + again. Dropping the last producer does the same. - `origin.dynamic(prefix, route)` claims `prefix` and every path beneath it (`""` claims everything). Hold the returned `origin::Dynamic` while the claim should stay advertised; drop it to retract. A request beneath it with diff --git a/doc/lib/swift/index.md b/doc/lib/swift/index.md index cbbcefa6bc..1d5b7c13c2 100644 --- a/doc/lib/swift/index.md +++ b/doc/lib/swift/index.md @@ -61,7 +61,9 @@ Call `audio.discontinuity()` when the source seeks, pauses, or changes its time The three advertising operations: `session.publish.createBroadcast(path:)` returns an unannounced producer, invisible to everyone; `broadcast.announce(route:)` / -`broadcast.unannounce()` own that exact-path advertisement; +`broadcast.unannounce()` own that exact-path advertisement, and +`broadcast.close()` ends the broadcast for good (a second call is a no-op; +`finish()` is its deprecated alias); `session.publish.dynamic(prefix:route:)` claims `prefix` and every path beneath it (`""` for everything). Hold the returned `OriginDynamic` while the claim should stay advertised, and reject the requests you will not serve. A diff --git a/go/wrapper/example_test.go b/go/wrapper/example_test.go index 84eef29901..60c3c40d29 100644 --- a/go/wrapper/example_test.go +++ b/go/wrapper/example_test.go @@ -53,8 +53,8 @@ func ExampleClient_CreateBroadcast() { if err != nil { log.Fatal(err) } - // Finishing unpublishes the broadcast immediately. - defer broadcast.Finish() + // Closing ends the broadcast for good. + defer broadcast.Close() media, err := broadcast.PublishAudio(moq.AudioFormatOpus, opusHead()) if err != nil { @@ -91,7 +91,7 @@ func ExampleBroadcastProducer_PublishVideo_videoHint() { if err != nil { log.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() media, err := broadcast.PublishVideo(moq.VideoFormatAvc3, nil, moq.WithVideoHint(moq.VideoHint{})) if err != nil { diff --git a/go/wrapper/moq_test.go b/go/wrapper/moq_test.go index 0a91ad7927..936fd7e5df 100644 --- a/go/wrapper/moq_test.go +++ b/go/wrapper/moq_test.go @@ -115,7 +115,7 @@ func TestDynamicBroadcastRequest(t *testing.T) { if err := track.Finish(); err != nil { t.Fatal(err) } - if err := served.Finish(); err != nil { + if err := served.Close(); err != nil { t.Fatal(err) } } @@ -135,11 +135,27 @@ func TestPublishAudioLifecycle(t *testing.T) { if err := media.Finish(); err != nil { t.Fatal(err) } - if err := broadcast.Finish(); err != nil { + if err := broadcast.Close(); err != nil { t.Fatal(err) } } +func TestBroadcastCloseTwiceIsNoop(t *testing.T) { + broadcast, err := moq.NewBroadcastProducer() + if err != nil { + t.Fatal(err) + } + if err := broadcast.Close(); err != nil { + t.Fatal(err) + } + if err := broadcast.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } + if _, err := broadcast.PublishTrack("events", nil); err == nil { + t.Fatal("PublishTrack after Close succeeded") + } +} + func TestEncodeAudioWithOpusObject(t *testing.T) { // The producer retains the codec, so releasing the codec and the // config in either order must still encode. @@ -179,7 +195,7 @@ func TestEncodeAudioWithOpusObject(t *testing.T) { if err := producer.Finish(); err != nil { t.Fatal(err) } - if err := broadcast.Finish(); err != nil { + if err := broadcast.Close(); err != nil { t.Fatal(err) } } @@ -221,7 +237,7 @@ func TestEncodeAudioFrameDurations(t *testing.T) { t.Fatalf("err = %v, want ErrAudio: 2 ms is not an opus frame duration", err) } - if err := broadcast.Finish(); err != nil { + if err := broadcast.Close(); err != nil { t.Fatal(err) } } @@ -235,7 +251,7 @@ func TestVideoPropertiesUseDefaultedFields(t *testing.T) { if err := broadcast.SetVideoProperties(moq.VideoProperties{Rotation: &rotation}); err != nil { t.Fatal(err) } - if err := broadcast.Finish(); err != nil { + if err := broadcast.Close(); err != nil { t.Fatal(err) } } @@ -350,7 +366,7 @@ func TestDecodeVideoFormat(t *testing.T) { if err := video.Finish(); err != nil { t.Fatal(err) } - if err := broadcast.Finish(); err != nil { + if err := broadcast.Close(); err != nil { t.Fatal(err) } } @@ -806,7 +822,7 @@ func TestDynamicTrackRequest(t *testing.T) { if err != nil { t.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() dynamic, err := broadcast.Dynamic() if err != nil { @@ -882,7 +898,7 @@ func TestDynamicTrackRequestCanPublishAudio(t *testing.T) { if err != nil { t.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() dynamic, err := broadcast.Dynamic() if err != nil { @@ -974,7 +990,7 @@ func TestRecvGroupCancelRace(t *testing.T) { if err != nil { t.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() var wg sync.WaitGroup for i := 0; i < 16; i++ { @@ -1007,7 +1023,7 @@ func TestConsumerCancelConcurrent(t *testing.T) { if err != nil { t.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() track, err := broadcast.PublishTrack("x", nil) if err != nil { @@ -1075,7 +1091,7 @@ func TestRequestBroadcastCancelKeepsTheOrigin(t *testing.T) { if err != nil { t.Fatal(err) } - defer served.Finish() + defer served.Close() resolved := make(chan error, 1) go func() { @@ -1108,7 +1124,7 @@ func TestSubscribeTrackCancelKeepsTheBroadcast(t *testing.T) { if err != nil { t.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() dynamic, err := broadcast.Dynamic() if err != nil { @@ -1177,7 +1193,7 @@ func TestUsedCancelKeepsTheTrack(t *testing.T) { if err != nil { t.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() track, err := broadcast.PublishTrack("status", nil) if err != nil { @@ -1224,7 +1240,7 @@ func TestCancelDoesNotLeakGoroutines(t *testing.T) { if err != nil { t.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() dynamic, err := broadcast.Dynamic() if err != nil { @@ -1338,7 +1354,7 @@ func TestBroadcastIsReachableOnlyWhileAnnounced(t *testing.T) { if _, err := consumer.RequestBroadcast(ctx, "live"); err != nil { t.Fatal(err) } - if err := broadcast.Finish(); err != nil { + if err := broadcast.Close(); err != nil { t.Fatal(err) } } diff --git a/go/wrapper/origin.go b/go/wrapper/origin.go index 67eb77e040..78642147c1 100644 --- a/go/wrapper/origin.go +++ b/go/wrapper/origin.go @@ -49,9 +49,8 @@ func (o *OriginProducer) Dynamic(prefix string, route Route) (*OriginDynamic, er // // The broadcast is invisible and unroutable, for this origin's consumers and // peers alike, until [BroadcastProducer.Announce]. Announce it after -// populating tracks. Finish unpublishes immediately, while dropping the -// producer without finishing also unpublishes but reads to subscribers as a -// failure rather than a deliberate end. +// populating tracks. [BroadcastProducer.Close] ends it for good; dropping the +// last handle does the same. func (o *OriginProducer) CreateBroadcast(path string) (*BroadcastProducer, error) { inner, err := o.inner.CreateBroadcast(path) if err != nil { diff --git a/go/wrapper/publish.go b/go/wrapper/publish.go index a746212627..5b668b3e43 100644 --- a/go/wrapper/publish.go +++ b/go/wrapper/publish.go @@ -262,9 +262,17 @@ func (b *BroadcastProducer) RemoveCatalogSection(name string) error { return b.inner.RemoveCatalogSection(name) } -// Finish closes the broadcast. +// Close ends the broadcast for good: it retracts and serves no new tracks. +// Tracks already subscribed carry on to their own end. Closing again is a no-op. +func (b *BroadcastProducer) Close() error { + return b.inner.Close() +} + +// Finish ends the broadcast. +// +// Deprecated: use [BroadcastProducer.Close]; a broadcast end carries no cause. func (b *BroadcastProducer) Finish() error { - return b.inner.Finish() + return b.inner.Close() } // BroadcastDynamic is a stream of subscriber-requested tracks. diff --git a/go/wrapper/reconnect_test.go b/go/wrapper/reconnect_test.go index 72e71536d2..762a46c38d 100644 --- a/go/wrapper/reconnect_test.go +++ b/go/wrapper/reconnect_test.go @@ -166,7 +166,7 @@ func TestReconnectAcrossRelayRestart(t *testing.T) { if err != nil { t.Fatal(err) } - defer broadcast.Finish() + defer broadcast.Close() track, err := broadcast.PublishTrack("data", nil) if err != nil { diff --git a/js/net/src/broadcast.ts b/js/net/src/broadcast.ts index 32bc42ad8e..b744b042bb 100644 --- a/js/net/src/broadcast.ts +++ b/js/net/src/broadcast.ts @@ -266,7 +266,10 @@ export class Producer { this.#announcer?.unannounce(); } - /** Close the broadcast, optionally with an error to abort waiters. Idempotent. */ + /** End the broadcast for good: retract it, serve no new tracks, and refuse a later {@link announce}. Idempotent. */ + close(): void; + /** @deprecated A broadcast end carries no cause; call `close()` without one. */ + close(abort?: Error): void; close(abort?: Error) { this.#announcer?.unannounce(); this.#announcer = undefined; @@ -356,9 +359,12 @@ export class Consumer { } /** - * Release this handle. The broadcast is closed (optionally with an error to abort waiters) - * once this was the last live handle; while other {@link clone}s remain open it stays live. + * Release this handle. The broadcast is closed once this was the last live handle; + * while other {@link clone}s remain open it stays live. */ + close(): void; + /** @deprecated A broadcast end carries no cause; call `close()` without one. */ + close(abort?: Error): void; close(abort?: Error) { if (this.#closed) return; this.#closed = true; diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index d86b5e2180..7ad40d0fa9 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -271,8 +271,9 @@ test("closing the origin closes every routed broadcast", async () => { expect(origin.closed.peek()).toBe(abort); expect(consumer.closed.peek()).toBe(abort); - expect(a.closed.peek()).toBe(abort); - expect(b.closed.peek()).toBe(abort); + // A broadcast end carries no cause, so the routed broadcasts close cleanly. + expect(a.closed.peek()).toBeNull(); + expect(b.closed.peek()).toBeNull(); expect(wireOf(consumer).routes(Path.from("a"))).toBe(false); expect(() => publish(origin, Path.from("late"))).toThrow(); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index 7a02699c94..af463eac79 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -298,7 +298,7 @@ class ServeState { finishRequest(request, err); } for (const [path, front] of this.served) { - front.close(abort); + front.close(); this.onChange(path); } this.served.clear(); @@ -953,7 +953,7 @@ export class Producer implements Table { if (this.#state.closed.peek() !== undefined) return; this.#state.closed.set(abort ?? null); for (const front of this.#state.created?.values() ?? []) { - front.close(abort); + front.close(); } this.#state.created = undefined; this.#state.local.update(() => undefined); diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt index 728c381c8e..dec946bc2f 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Moq.kt @@ -22,7 +22,8 @@ class Moq internal constructor( /** * Create an unannounced broadcast at [path], invisible to everyone until announced. * - * Advertise it with `announce` after populating tracks. `finish()` unpublishes immediately. + * Advertise it with `announce` after populating tracks. `close()` (or `use`) ends it once no + * `dynamic()` handle remains. */ fun createBroadcast(path: String): BroadcastProducer = session.publish().createBroadcast(path) diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt index e085291a69..9257ba51dd 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Server.kt @@ -33,8 +33,8 @@ class Server internal constructor( * Create a live broadcast at [path], served to incoming sessions. * * The origin announces the path so subscribers can discover it, becoming visible - * Advertise it with `announce` after populating tracks. `finish()` - * unpublishes immediately. + * Advertise it with `announce` after populating tracks. `close()` (or `use`) + * ends it once no `dynamic()` handle remains. */ fun createBroadcast(path: String): BroadcastProducer { val origin = publishOrigin ?: throw IllegalStateException("no publish origin configured") diff --git a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt index 048ee430c2..9cd8e32517 100644 --- a/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt +++ b/kt/moq/src/jvmAndAndroidTest/kotlin/dev/moq/SmokeTest.kt @@ -132,6 +132,16 @@ class SmokeTest { assertEquals(ConnectionStatus.CONNECTED, status) } + @Test + fun `closing a broadcast ends it, and closing again is a no-op`() = runTest { + val broadcast = BroadcastProducer() + val consumer = broadcast.consume() + // AutoCloseable.close() releases the last producer handle, which ends the broadcast. + broadcast.close() + broadcast.close() + assertFailsWith { consumer.subscribeTrack("events", null) } + } + @Test fun `broadcast updates shared video properties`() { BroadcastProducer().use { broadcast -> @@ -302,7 +312,6 @@ class SmokeTest { server.createBroadcast("live").use { broadcast -> broadcast.announce(Route()) broadcast.unannounce() - broadcast.finish() } } } diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index 6add3e7adb..0902031a5a 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -68,7 +68,7 @@ async def main(): # Clean up audio.finish() - broadcast.finish() + broadcast.close() asyncio.run(main()) @@ -132,7 +132,7 @@ client = moq.Client( - **`Server(bind="[::]:443", *, tls_cert=(), tls_key=(), tls_generate=(), publish=None, subscribe=None)`**. Async context manager + async iterator of incoming `Request`s. - `.local_addr`. The bound address (useful when binding to port `0`). - `.cert_fingerprints()`. SHA-256 fingerprints of the configured TLS certificates, for `serverCertificateHashes` browser cert pinning. - - `.create_broadcast(path) → BroadcastProducer`. Create an unannounced broadcast, invisible to everyone; `announce()` makes it discoverable and reachable; `finish()` unpublishes it. + - `.create_broadcast(path) → BroadcastProducer`. Create an unannounced broadcast, invisible to everyone; `announce()` makes it discoverable and reachable; `close()` ends it. - **`Request`**. An incoming session, yielded by `async for request in server`. - `.url`, `.path`, `.query`, `.transport`. The query-free path is uniform across transports; the root or missing path is `""`. The encoded query may contain credentials. - `.set_publish(origin)`, `.set_consume(origin)`. Per-request overrides, captured at `accept()`. Raise if the request is already answered, cancelled, or currently accepting. @@ -155,7 +155,7 @@ client = moq.Client( - `.publish_video(format, init=b"", *, label=None, hint=None, track=None) → MediaProducer`. `init` may be empty for a format that resolves in band; a `VideoHint` pins catalog fields the stream can't reveal (bitrate) or publishes the catalog before the first keyframe. `track` names the track as in `publish_audio`. - `.encode_video(input, output, *, bandwidth=None) → VideoProducer`. Encode raw `VideoFrame`s inside the binding; `.write(frame)` each one. - `.encode_audio(name, input, output, *, bandwidth=None) → AudioProducer`. Encode raw PCM `AudioFrame`s; the codec is `output.codec`, e.g. `AudioCodec.opus()`, with `output.frame_duration_us` setting the Opus frame length. - - `.finish()` + - `.close()` ends the broadcast for good; a second call is a no-op. `.finish()` is its deprecated alias. - **`BroadcastDynamic`**. Async source of tracks requested by subscribers. - `await .requested_track() → TrackRequest`. Call `.accept()` on it for a `TrackProducer`, or `.abort(code)` to reject. - Async iterator yielding `TrackRequest` diff --git a/py/moq-rs/moq/origin.py b/py/moq-rs/moq/origin.py index f4a7c1886b..517ebe1210 100644 --- a/py/moq-rs/moq/origin.py +++ b/py/moq-rs/moq/origin.py @@ -248,8 +248,7 @@ def create_broadcast(self, path: str) -> BroadcastProducer: and peers alike, until :meth:`BroadcastProducer.announce`. Announce it after populating tracks. Create, :meth:`dynamic` if tracks are served on demand, populate, then announce. - ``finish()`` unpublishes immediately, while dropping the producer without - finishing also unpublishes but reads to subscribers as a failure rather - than a deliberate end. + :meth:`BroadcastProducer.close` ends it for good; dropping the last + handle does the same. """ return BroadcastProducer._from_inner(self._inner.create_broadcast(path)) diff --git a/py/moq-rs/moq/publish.py b/py/moq-rs/moq/publish.py index 10248a2bc9..322009e6a0 100644 --- a/py/moq-rs/moq/publish.py +++ b/py/moq-rs/moq/publish.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import warnings from typing import TYPE_CHECKING, Any from moq_ffi import ( @@ -843,6 +844,14 @@ def consume(self) -> BroadcastConsumer: return BroadcastConsumer(self._inner.consume()) + def close(self) -> None: + """End the broadcast for good: retract it and serve no new tracks. + + Tracks already subscribed carry on to their own end. Closing again is a no-op. + """ + self._inner.close() + def finish(self) -> None: - """Finish the broadcast, closing its tracks and unpublishing it.""" - self._inner.finish() + """Deprecated: use :meth:`close`. A broadcast end carries no cause.""" + warnings.warn("use close(); a broadcast end carries no cause", DeprecationWarning, stacklevel=2) + self._inner.close() diff --git a/py/moq-rs/tests/test_local.py b/py/moq-rs/tests/test_local.py index 004dc26d51..9f09dd57bf 100644 --- a/py/moq-rs/tests/test_local.py +++ b/py/moq-rs/tests/test_local.py @@ -127,7 +127,7 @@ def test_publish_media_lifecycle(): media = broadcast.publish_audio(moq.AudioFormat.OPUS, opus_head()) media.write_frame(b"opus frame", 1000) media.finish() - broadcast.finish() + broadcast.close() def test_publish_media_cut_and_seek(): @@ -150,7 +150,7 @@ def test_publish_media_cut_and_seek(): media.write_frame(b"resumed opus frame", 100_000) media.finish() - broadcast.finish() + broadcast.close() def test_video_properties_use_defaulted_fields(): @@ -159,7 +159,7 @@ def test_video_properties_use_defaulted_fields(): assert properties.display is None assert properties.flip is None broadcast.set_video_properties(properties) - broadcast.finish() + broadcast.close() def test_audio_rejects_bad_init_bytes(): @@ -305,12 +305,19 @@ async def test_catalog_update_on_new_track(): break -def test_finish_closes_producer(): +def test_close_twice_is_a_noop(): broadcast = moq.BroadcastProducer() _media = broadcast.publish_audio(moq.AudioFormat.OPUS, opus_head()) - broadcast.finish() + broadcast.close() + broadcast.close() with pytest.raises(Exception): + broadcast.publish_audio(moq.AudioFormat.OPUS, opus_head()) + + +def test_finish_is_deprecated(): + broadcast = moq.BroadcastProducer() + with pytest.deprecated_call(): broadcast.finish() @@ -332,7 +339,7 @@ def test_publish_lifecycle(): track = broadcast.publish_track("status") track.write_frame(b'{"cmd": "ready"}', 0) track.finish() - broadcast.finish() + broadcast.close() async def test_publish_track_info_and_subscription(): @@ -522,7 +529,7 @@ async def test_dynamic_broadcast_request(): assert frame.payload == payload assert frame.timestamp_us == 20_000 track.finish() - served.finish() + served.close() async def test_dynamic_broadcast_request_can_reject(): @@ -1077,7 +1084,7 @@ def test_encode_audio_with_opus_object(): producer.write(moq.AudioFrame(timestamp_us=0, data=bytes(960 * 4))) assert producer.name == "mic" producer.finish() - broadcast.finish() + broadcast.close() async def test_decode_video_format(): @@ -1131,7 +1138,7 @@ async def test_decode_video_format(): i420.cancel() packed.cancel() video.finish() - broadcast.finish() + broadcast.close() async def test_broadcast_is_reachable_only_while_announced(): @@ -1161,7 +1168,7 @@ async def test_broadcast_is_reachable_only_while_announced(): await asyncio.wait_for(consumer.request_broadcast("live"), timeout=5.0) announced.cancel() track.finish() - broadcast.finish() + broadcast.close() async def test_announced_pattern_captures(): @@ -1182,8 +1189,8 @@ async def test_announced_pattern_captures(): announced.cancel() dynamic.cancel() - audio.finish() - chat.finish() + audio.close() + chat.close() async def test_dynamic_serves_a_request_under_a_prefix(): @@ -1198,7 +1205,7 @@ async def test_dynamic_serves_a_request_under_a_prefix(): request.accept(served) await asyncio.wait_for(pending, timeout=5.0) dynamic.cancel() - served.finish() + served.close() async def test_dynamic_and_json_handles_are_async_context_managers(): @@ -1237,4 +1244,4 @@ async def assert_cancelled(awaitable) -> None: snapshot.finish() stream.finish() track.finish() - broadcast.finish() + broadcast.close() diff --git a/py/moq-rs/tests/test_server.py b/py/moq-rs/tests/test_server.py index 170129306b..3dd07bc8a9 100644 --- a/py/moq-rs/tests/test_server.py +++ b/py/moq-rs/tests/test_server.py @@ -72,7 +72,7 @@ async def accept_loop() -> None: except asyncio.CancelledError: pass media.finish() - broadcast.finish() + broadcast.close() async def test_client_reconnects_and_resumes_announcements(): @@ -127,7 +127,7 @@ async def accept_loop() -> None: async for announcement in client.announced(): assert announcement.prefix == "after-reconnect" break - broadcast.finish() + broadcast.close() finally: accept_task.cancel() try: @@ -287,7 +287,7 @@ async def test_serve_helper_accepts_clients(): await serve_task except asyncio.CancelledError: pass - broadcast.finish() + broadcast.close() async def test_broadcast_route_over_wire(): @@ -317,7 +317,7 @@ async def test_broadcast_route_over_wire(): await serve_task except asyncio.CancelledError: pass - broadcast.finish() + broadcast.close() async def test_route_update_observes_restart(): diff --git a/quest/m1/README.md b/quest/m1/README.md index 4df3d10a25..bf58cf9600 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -28,7 +28,8 @@ transport, benchmark tooling); worktrees isolate commits, not semantics. - [Binding audio delay](/quest/m1/binding-surface.md) - moq-ffi, libmoq, and every wrapper configure and observe audio playout delay - [FFI shape](/quest/m1/ffi-shape/README.md) - the bindings mirror Rust's layers: net at the root, then media, json, audio, and video namespaces built from the handle below - [Track demand](/quest/m1/track-demand.md) - Rust and JS watch a track's subscribers through `demand()` alone -- [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` is the one way to end a broadcast in every language, a permanent retraction that leaves in-flight tracks alone +- [Kotlin end](/quest/m1/kotlin-end.md) - Kotlin exposes `close()` as `end()`, since `AutoCloseable.close()` takes the name +- [Remove finish](/quest/m1/broadcast-remove.md) - on dev, the deprecated broadcast end APIs are gone and `closed()` carries no cause - [CLI inspection](/quest/m1/cli-inspect/README.md) - `moq ls` lists what is live and `moq fetch` reads a group over MoQ, and a guide shows how to inspect a relay - [Session close](/quest/m1/session-close.md) - a graceful session end withdraws announces and waits one second for the ack - [Close codes](/quest/m1/close-codes.md) - a client sees the peer's application close code over WebSocket and raw QUIC, like WebTransport diff --git a/quest/m1/broadcast-close/README.md b/quest/m1/broadcast-close/README.md deleted file mode 100644 index 65302ef805..0000000000 --- a/quest/m1/broadcast-close/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# Broadcast close - -## Goal - -Ending a broadcast is one operation in every language: `close()`, a permanent -retraction. A closed broadcast is no longer announced to peers or discoverable -locally, serves no new tracks, and can never be announced again. Tracks already -in flight carry on and end with their own FIN or reset. Dropping the last -handle does the same, without a warning. - -The finished-versus-aborted distinction goes away: `broadcast::Producer::finish`, -`abort(err)`, and `Consumer::is_finished` are deprecated, then removed on `dev`. -A broadcast has no FIN on the wire, only ANNOUNCE_START and ANNOUNCE_END, so -whether it ended cleanly never survives a hop. - -## Plan - -Decided with the maintainer on #4007 (the #4002 fix): - -- `close(&self)` does what `finish()` does today minus the finished flag: - retract, stop local discovery, serve no new track names, fail a later - `announce` with `Closed`. -- Dropping a broadcast only retracts it. The "dropped without finish()" warning - in `broadcast.rs`'s `Drop for Alive` goes, since drop is now a normal end. -- Garbage-collected bindings get an explicit `close()` rather than relying on - finalizers. - -Every change here behaves the same through a local consumer and a remote one; -only latency differs. Each child tests its scenario both in-process and over -an in-memory mock session, like `rs/moq-net/tests/finished_broadcast_mock.rs`. - -`broadcast::Consumer` keeps its close signal, documented as "this broadcast -object ended", not "the path went offline": announcements say whether a path -is live, and a path can be announced again by a new object. Caches and moq-hls -use it to tell a live object from a replaced one at the same path. - -Stage it as the children below: Rust first, then the bindings, then the `dev` -removal. - -## Quests - -- [Rust close](/quest/m1/broadcast-close/rust.md) - moq-net gains `close()`, deprecates `finish`/`abort`/`is_finished`, and every Rust and JS caller moves over -- [Binding close](/quest/m1/broadcast-close/bindings.md) - moq-ffi, libmoq, and every wrapper expose `close()` and deprecate `finish` -- [Kotlin end](/quest/m1/broadcast-close/kotlin-end.md) - Kotlin exposes `close()` as `end()`, since `AutoCloseable.close()` takes the name -- [Remove finish](/quest/m1/broadcast-close/remove.md) - on dev, the deprecated broadcast end APIs are gone and `closed()` carries no cause - -## Related - -- [#4002](https://github.com/moq-dev/moq/issues/4002) - broadcast end aborted in-flight tracks; fixed by #4007, which made ending a front retract without touching its tracks diff --git a/quest/m1/broadcast-close/bindings.md b/quest/m1/broadcast-close/bindings.md deleted file mode 100644 index 86437ec9e4..0000000000 --- a/quest/m1/broadcast-close/bindings.md +++ /dev/null @@ -1,29 +0,0 @@ -# [M] Binding close - -## Goal - -Every binding ends a broadcast with `close()`, mirroring Rust, and its -`finish` is deprecated. - -## Plan - -- moq-ffi: add `MoqBroadcastProducer::close()` next to `finish()` in - `rs/moq-ffi/src/producer.rs`. It closes the broadcast, then the catalog, as - `finish` does, so a catalog error cannot stop the broadcast from ending. - Deprecate `finish`. -- libmoq: export `moq_publish_close` and deprecate `moq_publish_finish`. Move - `cpp/obs/src/moq-output.cpp` and the C tests over. -- Wrappers: `py/moq-rs` `BroadcastProducer.close()` (its `finish` docstring - wrongly says it closes the tracks), swift `Broadcast.close()`, and the Go - `Publish.Close()`. Kotlin and Dart only alias the generated type, so they - pick `close` up from moq-ffi. -- Move the binding tests over, keeping one test per binding that a second - `close` errors or no-ops, whichever Rust settles on. -- Update `doc/lib/{py,swift,kt,go,dart,c}`, including `doc/lib/go/index.md`'s - `broadcast.Finish()` sample. -- Fix the moq-ffi `origin.rs` doc comment that points users at a - `broadcast.closed()` the bindings don't have. - -## Required - -- [Rust close](/quest/m1/broadcast-close/rust.md) - the binding forwards to `broadcast::Producer::close` diff --git a/quest/m1/broadcast-close/rust.md b/quest/m1/broadcast-close/rust.md deleted file mode 100644 index 79348cb597..0000000000 --- a/quest/m1/broadcast-close/rust.md +++ /dev/null @@ -1,49 +0,0 @@ -# [M] Rust close - -## Goal - -`moq_net::broadcast::Producer::close()` is the one way to end a broadcast, and -no Rust or JS code in the repository calls a deprecated end API. - -## Plan - -In `rs/moq-net/src/model/broadcast.rs`: - -- Add `close(&self)`: set `closing`, close the liveness token, and retire the - announcer, as `finish` does today without setting `finished`. Borrow, like - `finish`: any clone ends it. -- Once a broadcast has ended, by `close()` or its last producer dropping, every - new lookup on any consumer answers `Unroutable`: `Consumer::track`, and the - requests still pending for names nothing served. That is what a fresh - `request_broadcast` for the path answers, so a consumer can't tell a raw - handle from one reached through an origin, or a local source from a remote - one. Today the same lookup answers `NotFound` while clones live and `Dropped` - after. Tracks already read are untouched and end on their own FIN or reset. - This changes the error a published API returns; say so in the PR. -- Document `broadcast::Producer::consume()` as a view of this one publisher: a - new publisher at the path is never spliced into it, so a consumer that should - not care about its source goes through an origin. -- Mark `finish`, `abort`, and `Consumer::is_finished` `#[deprecated]` and - `#[doc(hidden)]` per `rs/AGENTS.md`. `finish` forwards to `close`. Leave - `abort`'s behavior alone until the `dev` removal. -- Drop the "dropped without finish()" warning in `Drop for Alive`. -- `SourceGuard` (the lite and IETF subscribers' source handle) closes on both - a graceful end and a drop, since the abort it records on drop reaches no one - past this hop. Check the IETF `Detach::Abrupt` path still ends the source. - -Callers to move (production): `model/origin.rs` (front end), -`lite/subscriber.rs` (`Route::finish`), `ietf/subscriber.rs`, moq-srt -`ts.rs`, moq-rtmp `server.rs` and `dial.rs`, moq-stats `produce.rs`, moq-gst -`sink/imp.rs`, moq-rtc `server/mod.rs`, moq-transcode `lib.rs`, moq-relay -`cluster.rs` (gossip registration), moq-boy `main.rs`, and moq-tokio's -`clock` and `chat` examples. Tests and benches across moq-net, moq-tokio, -moq-hls, moq-bench, and moq-gst move too. - -moq-srt and moq-rtmp `Publisher::abort` drop the broadcast instead of ending -it; closing it explicitly makes that path deterministic. - -In `js/net/src/broadcast.ts`, `Producer.close()` and `Consumer.close()` stop -taking `abort`: deprecate the parameter in the JSDoc and move the two origin -teardown callers in `origin.ts`. JS `close()` already blocks re-announcing. - -Update `doc/lib/rs/moq-net.md`, which says the route retracts on `finish()`. diff --git a/quest/m1/broadcast-close/remove.md b/quest/m1/broadcast-remove.md similarity index 73% rename from quest/m1/broadcast-close/remove.md rename to quest/m1/broadcast-remove.md index 4b62cc1db5..6522ebecec 100644 --- a/quest/m1/broadcast-close/remove.md +++ b/quest/m1/broadcast-remove.md @@ -18,5 +18,5 @@ This is a published API break, so it targets `dev`. ## Required -- [Binding close](/quest/m1/broadcast-close/bindings.md) - every binding already has `close()` to move to -- [Kotlin end](/quest/m1/broadcast-close/kotlin-end.md) - Kotlin keeps a forced end once `finish` is gone +- [Kotlin end](/quest/m1/kotlin-end.md) - Kotlin keeps a forced end once `finish` is gone +- `main` merged into `dev` once #4031 lands, so the deprecations exist there diff --git a/quest/m1/broadcast-close/kotlin-end.md b/quest/m1/kotlin-end.md similarity index 81% rename from quest/m1/broadcast-close/kotlin-end.md rename to quest/m1/kotlin-end.md index 4ecb51e805..44516dd62c 100644 --- a/quest/m1/broadcast-close/kotlin-end.md +++ b/quest/m1/kotlin-end.md @@ -12,7 +12,3 @@ generated `close()` would collide with `AutoCloseable.close()`. binding keeps `close()`. Kotlin's `close()` still only releases the handle. - Test that `end()` ends the broadcast while a `dynamic()` handle is still held. Document it in `doc/lib/kt`. - -## Required - -- [Binding close](/quest/m1/broadcast-close/bindings.md) - moq-ffi gains the `close()` this renames diff --git a/quest/m1/session-close.md b/quest/m1/session-close.md index 3a297810e6..39ad6edba2 100644 --- a/quest/m1/session-close.md +++ b/quest/m1/session-close.md @@ -22,7 +22,3 @@ end and returns a promise, which is a published break, so that change targets `doc/concept/moq-lite.md` says a graceful close withdraws announces and an abort does not. No new page. - -## Related - -- [Broadcast close](/quest/m1/broadcast-close/README.md) - `close()` on a broadcast, which is not this session end diff --git a/rs/libmoq/README.md b/rs/libmoq/README.md index 584f4cf328..5642aef7c4 100644 --- a/rs/libmoq/README.md +++ b/rs/libmoq/README.md @@ -69,7 +69,7 @@ int32_t moq_origin_announced_cancel(uint32_t announced); // Publishing int32_t moq_publish_announce(uint32_t broadcast, const moq_route *route); int32_t moq_publish_unannounce(uint32_t broadcast); -int32_t moq_publish_finish(uint32_t broadcast); +int32_t moq_publish_close(uint32_t broadcast); int32_t moq_publish_audio(uint32_t broadcast, const moq_audio_init *config); int32_t moq_publish_video(uint32_t broadcast, const moq_video_init *config); int32_t moq_publish_container(uint32_t broadcast, const moq_container_init *config); diff --git a/rs/libmoq/c-tests/decode-output.c b/rs/libmoq/c-tests/decode-output.c index 585f107bf2..057f33e62d 100644 --- a/rs/libmoq/c-tests/decode-output.c +++ b/rs/libmoq/c-tests/decode-output.c @@ -284,8 +284,8 @@ int main(void) { fail("error: moq_consume_close failed (%s)\n", moq_error()); if (moq_encode_video_finish((uint32_t)producer) < 0) fail("error: moq_encode_video_finish failed (%s)\n", moq_error()); - if (moq_publish_finish((uint32_t)broadcast) < 0) - fail("error: moq_publish_finish failed (%s)\n", moq_error()); + if (moq_publish_close((uint32_t)broadcast) < 0) + fail("error: moq_publish_close failed (%s)\n", moq_error()); if (moq_origin_close((uint32_t)origin) < 0) fail("error: moq_origin_close failed (%s)\n", moq_error()); diff --git a/rs/libmoq/src/api.rs b/rs/libmoq/src/api.rs index 13d263077a..7d5cfdae51 100644 --- a/rs/libmoq/src/api.rs +++ b/rs/libmoq/src/api.rs @@ -1543,7 +1543,7 @@ pub extern "C" fn moq_origin_create() -> i32 { /// /// The broadcast is invisible and unroutable, on this origin and its peers /// alike, until [moq_publish_announce]. Fill it with the `moq_publish_*` -/// functions, then announce it. [moq_publish_finish] unpublishes immediately. +/// functions, then announce it. [moq_publish_close] ends it for good. /// /// Returns a non-zero broadcast handle on success, or a negative code on failure. /// @@ -1923,20 +1923,29 @@ pub extern "C" fn moq_publish_unannounce(broadcast: u32) -> i32 { }) } -/// Finish a broadcast and release it, ending its catalog cleanly. +/// End a broadcast for good and release its handle. /// -/// Subscribers see a normal end of stream rather than an error, and the origin unpublishes -/// the path immediately. +/// The origin retracts the path immediately and serves no new tracks; tracks already +/// subscribed carry on to their own end. The handle is invalid afterwards, so closing +/// it again fails like any unknown handle. /// /// Returns a zero on success, or a negative code on failure. #[unsafe(no_mangle)] -pub extern "C" fn moq_publish_finish(broadcast: u32) -> i32 { +pub extern "C" fn moq_publish_close(broadcast: u32) -> i32 { ffi::enter(move || { let broadcast = ffi::parse_id(broadcast)?; - State::lock().publish.finish(broadcast) + State::lock().publish.close(broadcast) }) } +/// Deprecated: use [moq_publish_close]. A broadcast end carries no cause. +/// +/// Returns a zero on success, or a negative code on failure. +#[unsafe(no_mangle)] +pub extern "C" fn moq_publish_finish(broadcast: u32) -> i32 { + moq_publish_close(broadcast) +} + /// Publish one audio codec as a new media track. /// /// The track is named after the format (`0.opus`), so a subscriber finds it diff --git a/rs/libmoq/src/publish.rs b/rs/libmoq/src/publish.rs index 951093f8a7..27b3d05457 100644 --- a/rs/libmoq/src/publish.rs +++ b/rs/libmoq/src/publish.rs @@ -162,9 +162,8 @@ impl Publish { Ok((&mut broadcast.producer, &mut broadcast.catalog)) } - /// Cleanly finish the broadcast and finalize the catalog stream, so subscribers - /// see a normal end rather than [`moq_net::Error::Dropped`]. - pub fn finish(&mut self, broadcast: Id) -> Result<(), Error> { + /// End the broadcast for good and release it, finalizing the catalog stream. + pub fn close(&mut self, broadcast: Id) -> Result<(), Error> { let Broadcast { producer, mut catalog, @@ -183,9 +182,8 @@ impl Publish { } guard.commit()?; } - // Finish the broadcast first so the clean end reaches subscribers even if - // finalizing the catalog fails. - producer.finish(); + // Close the broadcast first so it ends even if finalizing the catalog fails. + producer.close(); catalog.finish()?; Ok(()) } diff --git a/rs/libmoq/src/test.rs b/rs/libmoq/src/test.rs index 9919eada4e..575a1bea46 100644 --- a/rs/libmoq/src/test.rs +++ b/rs/libmoq/src/test.rs @@ -367,7 +367,7 @@ fn publish_media_lifecycle() { let origin = id(moq_origin_create()); let broadcast = publish_broadcast(origin, b"publish-media-lifecycle"); let _guard = Guard(Some(|| { - moq_publish_finish(broadcast); + moq_publish_close(broadcast); })); let init = opus_head(); @@ -379,7 +379,7 @@ fn publish_media_lifecycle() { assert_eq!(ret, 0, "moq_publish_media_frame should succeed"); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); } #[test] @@ -389,7 +389,7 @@ fn publish_media_rejects_a_null_config() { assert!(unsafe { moq_publish_audio(broadcast, std::ptr::null()) } < 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -424,7 +424,7 @@ fn container_and_media_handles_are_not_interchangeable() { assert_eq!(moq_publish_container_finish(container), 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -511,7 +511,7 @@ fn publish_media_labels_config_without_naming_track() { assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media1), 0); assert_eq!(moq_publish_media_finish(media2), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -621,7 +621,7 @@ fn publish_media_owns_its_rendition_before_the_first_keyframe() { "finishing the media track releases its rendition name" ); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -667,7 +667,7 @@ fn publish_video_config_replaces_its_own_rendition() { "the name is free once the caller removes its rendition" ); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -690,7 +690,7 @@ fn publish_catalog_config_null_pointer() { -6, "null config should return InvalidPointer (-6)" ); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); } #[test] @@ -891,7 +891,7 @@ fn publish_catalog_roundtrip() { assert_eq!(moq_consume_catalog_cancel(catalog_task), 0); assert_eq!(catalog_cb.recv_terminal(), 0, "catalog close delivers terminal 0"); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -963,13 +963,13 @@ fn a_half_specified_coded_size_round_trips() { assert_eq!(moq_consume_catalog_cancel(forwarded_task), 0); assert_eq!(forwarded_cb.recv_catalog_terminal(), 0); assert_eq!(moq_consume_close(forwarded), 0); - assert_eq!(moq_publish_finish(forward), 0); + assert_eq!(moq_publish_close(forward), 0); assert_eq!(moq_consume_catalog_free(catalog), 0); assert_eq!(moq_consume_catalog_cancel(catalog_task), 0); assert_eq!(catalog_cb.recv_catalog_terminal(), 0); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1065,7 +1065,7 @@ fn raw_loc_video_uses_the_declared_catalog_container() { assert_eq!(catalog_cb.recv_terminal(), 0); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_track_finish(track), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1129,7 +1129,7 @@ fn cmaf_catalog_container_carries_its_init_segment() { assert_eq!(moq_consume_catalog_cancel(catalog_task), 0); assert_eq!(catalog_cb.recv_terminal(), 0); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1189,7 +1189,7 @@ fn unpublishable_catalog_containers_are_rejected() { "cmaf without an init segment should return InvalidPointer (-6)" ); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1344,7 +1344,7 @@ fn catalog_section_roundtrip() { assert_eq!(moq_consume_catalog_cancel(catalog_task), 0); assert_eq!(catalog_cb.recv_terminal(), 0, "catalog close delivers terminal 0"); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1391,7 +1391,7 @@ fn publish_track_with_info_rejects_invalid_timescale() { }; assert!(unsafe { moq_publish_track(broadcast, name.as_ptr() as *const c_char, name.len(), &info) } < 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); } #[test] @@ -1491,7 +1491,7 @@ fn raw_track_publish_consume() { assert_eq!(moq_publish_track_finish(track), 0); assert!(moq_publish_track_finish(track) < 0, "double-close should fail"); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1553,7 +1553,7 @@ fn raw_track_datagram_publish_consume() { assert!(moq_consume_datagrams_cancel(consumer) < 0, "double-close should fail"); assert_eq!(moq_publish_track_finish(track), 0); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1572,7 +1572,7 @@ fn raw_track_sparse_groups_and_known_end() { assert_eq!(moq_publish_group_finish(group), 0); assert!(moq_publish_track_group_at(track, 5) < 0); assert_eq!(moq_publish_track_finish(track), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); } #[test] @@ -1587,7 +1587,7 @@ fn raw_track_and_group_abort_consume_their_handles() { assert!(moq_publish_group_finish(group) < 0); assert_eq!(moq_publish_track_abort(track, 410), 0); assert!(moq_publish_track_finish(track) < 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); } #[test] @@ -1673,7 +1673,7 @@ fn raw_track_subscription_options_and_update() { assert_eq!(frame_cb.recv_terminal(), 0); assert_eq!(moq_publish_track_finish(track), 0); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1739,7 +1739,7 @@ fn json_snapshot_publish_consume() { "double-close should fail" ); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1799,7 +1799,7 @@ fn json_stream_publish_consume() { assert_eq!(moq_publish_json_stream_finish(producer), 0); assert!(moq_publish_json_stream_finish(producer) < 0, "double-close should fail"); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1807,13 +1807,26 @@ fn json_stream_publish_consume() { fn close_invalid_or_zero_ids() { assert!(moq_origin_close(9999) < 0); assert!(moq_session_close(9999) < 0); - assert!(moq_publish_finish(9999) < 0); + assert!(moq_publish_close(9999) < 0); assert!(moq_consume_close(9999) < 0); assert!(moq_consume_frame_free(9999) < 0); assert!(moq_origin_close(0) < 0); assert!(moq_session_close(0) < 0); - assert!(moq_publish_finish(0) < 0); + assert!(moq_publish_close(0) < 0); +} + +#[test] +fn publish_close_releases_the_handle() { + let origin = id(moq_origin_create()); + let broadcast = publish_broadcast(origin, b"close/twice"); + assert_eq!(moq_publish_close(broadcast), 0); + assert!(moq_publish_close(broadcast) < 0, "a closed handle is released"); + + // The deprecated alias still ends a broadcast. + let broadcast = publish_broadcast(origin, b"close/finish"); + assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_origin_close(origin), 0); } #[test] @@ -1865,7 +1878,7 @@ fn announced_free_lifecycle() { ann_cb.recv_terminal(); assert_eq!(moq_origin_close(origin), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); } #[test] @@ -1882,7 +1895,7 @@ fn double_close_all_resource_types() { assert_eq!(moq_publish_media_finish(media), 0); assert!(moq_publish_media_finish(media) < 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); let origin = id(moq_origin_create()); let path = b"double-close-test"; @@ -1922,7 +1935,7 @@ fn double_close_all_resource_types() { assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1964,7 +1977,7 @@ fn media_cut_bounds_audio_groups() { assert!(moq_publish_media_seek(9999, 0) < 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -1973,7 +1986,7 @@ fn unknown_format() { let origin = id(moq_origin_create()); let broadcast = publish_broadcast(origin, b"unknown-format"); let _guard = Guard(Some(|| { - moq_publish_finish(broadcast); + moq_publish_close(broadcast); })); // A format is an enum now, so the only bad value C can still supply is an out-of-range @@ -2028,7 +2041,7 @@ fn local_announce() { assert_eq!(moq_origin_announced_cancel(announced_task), 0); assert_eq!(cb.recv_terminal(), 0, "announced close delivers terminal 0"); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2075,8 +2088,8 @@ fn announced_filters_patterns_and_reports_captures() { assert_eq!(moq_origin_announced_free(announced_id), 0); assert_eq!(moq_origin_announced_cancel(announced_task), 0); assert_eq!(cb.recv_terminal(), 0); - assert_eq!(moq_publish_finish(audio), 0); - assert_eq!(moq_publish_finish(chat), 0); + assert_eq!(moq_publish_close(audio), 0); + assert_eq!(moq_publish_close(chat), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2128,7 +2141,7 @@ fn announced_deactivation() { assert_eq!(moq_origin_announced_cancel(announced_task), 0); assert_eq!(cb.recv_terminal(), 0, "announced close delivers terminal 0"); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2169,7 +2182,7 @@ fn create_broadcast_is_unroutable_until_announced() { assert_eq!(moq_origin_announced_cancel(announced_task), 0); assert_eq!(cb.recv_terminal(), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2187,7 +2200,7 @@ fn announce_accepts_an_anonymous_hop() { has_cold: false, }; assert_eq!(unsafe { moq_publish_announce(broadcast, &route) }, 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2236,7 +2249,7 @@ fn dynamic_serves_a_request_under_a_prefix() { assert_eq!(moq_origin_dynamic_cancel(dynamic), 0); assert!(moq_origin_dynamic_cancel(dynamic) < 0, "double-cancel should fail"); assert_eq!(cb.recv_terminal(), 0); - assert_eq!(moq_publish_finish(served), 0); + assert_eq!(moq_publish_close(served), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2366,7 +2379,7 @@ fn track_demand_follows_subscribers() { ); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2399,7 +2412,7 @@ fn track_demand_reports_current_state_before_close() { ); assert_eq!(moq_publish_track_finish(track), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2422,7 +2435,7 @@ fn track_demand_reports_an_abort() { assert_eq!(protocol.kind, moq_protocol_kind::MOQ_PROTOCOL_KIND_APP as u32); assert_eq!(protocol.code, 64 + 7); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2459,7 +2472,7 @@ fn media_demand_refuses_a_container() { assert_eq!(cb.recv_terminal(), 0); assert_eq!(moq_publish_container_finish(container), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2562,7 +2575,7 @@ fn dynamic_serves_track_requests() { assert_eq!(moq_publish_track_finish(track), 0); assert_eq!(demand_cb.recv_terminal(), 0); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2606,7 +2619,7 @@ fn dynamic_track_request_publishes_media() { assert_eq!(demand_cb.recv(), moq_demand::MOQ_DEMAND_UNUSED as i32); assert_eq!(moq_publish_media_finish(media), 0); assert_eq!(demand_cb.recv_terminal(), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(request_cb.recv_terminal(), 0); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_origin_close(origin), 0); @@ -2691,7 +2704,7 @@ fn track_dynamic_serves_a_fetch_miss() { assert_eq!(moq_publish_dynamic_cancel(dynamic), 0); assert_eq!(group_cb.recv_terminal(), 0); assert_eq!(moq_publish_track_finish(track), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2726,7 +2739,7 @@ fn track_dynamic_serves_a_fetch_from_frame_start() { assert_eq!(moq_publish_dynamic_cancel(dynamic), 0); assert_eq!(group_cb.recv_terminal(), 0); assert_eq!(moq_publish_track_finish(track), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2760,7 +2773,7 @@ fn track_request_dynamic_survives_accept() { assert_eq!(moq_publish_dynamic_cancel(track_dynamic), 0); assert_eq!(group_cb.recv_terminal(), 0); assert_eq!(moq_publish_track_finish(track), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(request_cb.recv_terminal(), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2858,7 +2871,7 @@ fn local_publish_consume() { assert_eq!(catalog_cb.recv_terminal(), 0, "catalog close delivers terminal 0"); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -2915,7 +2928,7 @@ fn consume_announced_local() { assert_eq!(catalog_cb.recv_terminal(), 0, "catalog close delivers terminal 0"); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3017,8 +3030,8 @@ fn consume_audio_follows_a_sibling_broadcast_reference() { assert_eq!(catalog_cb.recv_terminal(), 0, "catalog close delivers terminal 0"); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); - assert_eq!(moq_publish_finish(source), 0); + assert_eq!(moq_publish_close(broadcast), 0); + assert_eq!(moq_publish_close(source), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3145,7 +3158,7 @@ fn video_publish_consume() { assert_eq!(catalog_cb.recv_terminal(), 0, "catalog close delivers terminal 0"); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3200,7 +3213,7 @@ fn audio_raw_publish() { "a finished producer should take no more frames" ); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3255,7 +3268,7 @@ fn audio_raw_publish_frame_durations() { assert_eq!(moq_encode_audio_finish(id(encode(b"default", 0))), 0, "0 = 20 ms"); assert!(encode(b"rounded", 2_000) < 0, "2 ms is not an opus frame duration"); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3358,7 +3371,7 @@ fn video_raw_publish_consume() { assert_eq!(catalog_cb.recv_catalog_terminal(), 0); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_encode_video_finish(producer), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3496,7 +3509,7 @@ fn decode_first_frame(output: &moq_video_decoder_output) -> (u32, u32, usize) { assert_eq!(catalog_cb.recv_catalog_terminal(), 0); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_encode_video_finish(producer), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); result } @@ -3581,7 +3594,7 @@ fn video_raw_publish_from_many_threads() { .join() .unwrap(); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3677,7 +3690,7 @@ fn a_stalled_encode_does_not_block_unrelated_calls() { assert_eq!(moq_origin_close(id(created)), 0); assert_eq!(moq_encode_video_finish(stalled), 0); assert_eq!(moq_encode_video_finish(other), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3715,7 +3728,7 @@ fn video_raw_publish_rejects_frame_size_mismatch() { assert!(unsafe { moq_encode_video_frame(producer, &frame) } < 0); assert_eq!(moq_encode_video_finish(producer), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3804,7 +3817,7 @@ fn video_raw_publish_rejects_invalid_config() { assert!(moq_encode_video_bitrate(0, 1_000_000) < 0); assert!(moq_encode_video_finish(0) < 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3904,7 +3917,7 @@ fn video_raw_decode() { } assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -3964,7 +3977,7 @@ fn multiple_frames_ordering() { ); assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -4013,7 +4026,7 @@ fn catalog_update_on_new_track() { assert_eq!(moq_consume_close(consume), 0); assert_eq!(moq_publish_media_finish(media1), 0); assert_eq!(moq_publish_media_finish(media2), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -4552,7 +4565,7 @@ fn bandwidth_reservations_split_the_estimate() { let _ = first_cb.recv_terminal(); let _ = second_cb.recv_terminal(); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -4595,7 +4608,7 @@ fn bandwidth_handles_share_the_registry() { let _ = first_cb.recv_terminal(); let _ = second_cb.recv_terminal(); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -4645,7 +4658,7 @@ fn encode_video_bitrate_caps_the_reservation() { assert_eq!(moq_encode_video_finish(producer), 0); assert_eq!(moq_bandwidth_close(bandwidth), 0); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -4764,7 +4777,7 @@ fn server_accepts_a_session() { "the server handle is gone after its terminal callback" ); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(served), 0); assert_eq!(moq_origin_close(received), 0); } @@ -4934,7 +4947,7 @@ fn json_tracks_are_advertised_in_the_catalog() { assert_eq!(moq_publish_json_stream_finish(stream), 0); assert!(published_catalog(broadcast).json.tracks.is_empty()); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -4982,7 +4995,7 @@ fn binary_snapshot_is_advertised_and_delivered() { assert!(published_catalog(broadcast).binary.tracks.is_empty()); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -5028,7 +5041,7 @@ fn binary_stream_is_advertised_and_delivered() { assert!(published_catalog(broadcast).binary.tracks.is_empty()); assert_eq!(moq_consume_close(consume), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } @@ -5090,6 +5103,6 @@ fn data_track_names_cannot_collide() { ); assert_eq!(moq_publish_json_snapshot_finish(first), 0); - assert_eq!(moq_publish_finish(broadcast), 0); + assert_eq!(moq_publish_close(broadcast), 0); assert_eq!(moq_origin_close(origin), 0); } diff --git a/rs/moq-bench/src/connection.rs b/rs/moq-bench/src/connection.rs index 1502ab5c87..383f7be991 100644 --- a/rs/moq-bench/src/connection.rs +++ b/rs/moq-bench/src/connection.rs @@ -799,7 +799,7 @@ mod tests { task.await.unwrap().unwrap(); assert_eq!(stats.groups_recv.load(Ordering::Relaxed), 1); - broadcast.finish(); + broadcast.close(); } /// The relay fails a group it gave up on (`Error::Lagged` once a subscriber @@ -850,7 +850,7 @@ mod tests { write_group(&mut track); wait_for(&stats.groups_recv, 3).await; track.finish().unwrap(); - broadcast.finish(); + broadcast.close(); task.await .unwrap() diff --git a/rs/moq-boy/src/main.rs b/rs/moq-boy/src/main.rs index a167fbec44..df54bf2baa 100644 --- a/rs/moq-boy/src/main.rs +++ b/rs/moq-boy/src/main.rs @@ -305,9 +305,8 @@ async fn run(config: &Config) -> Result<()> { res = input::handle_viewers(&viewer_consumer, &cmd_tx) => res, }; - // Cleanly close the broadcast so subscribers see a normal end rather than - // Error::Dropped. - broadcast.finish(); + // Close the broadcast now, even if the emulator thread still holds a clone. + broadcast.close(); result } diff --git a/rs/moq-ffi/src/origin.rs b/rs/moq-ffi/src/origin.rs index 1f325f1692..253d5a749e 100644 --- a/rs/moq-ffi/src/origin.rs +++ b/rs/moq-ffi/src/origin.rs @@ -504,7 +504,7 @@ impl MoqAnnounceUpdate { impl MoqAnnouncedBroadcast { /// Wait until the broadcast is announced. Returns `Closed` if cancelled or the origin is closed. /// - /// Use `broadcast.closed()` to learn when the broadcast ends. + /// Its end arrives as an inactive [`MoqAnnounceUpdate`] on the origin's announcements. pub async fn available(&self) -> Result, MoqError> { self.task.run(|mut state| async move { state.available().await }).await } diff --git a/rs/moq-ffi/src/producer.rs b/rs/moq-ffi/src/producer.rs index 54386f575a..6456335841 100644 --- a/rs/moq-ffi/src/producer.rs +++ b/rs/moq-ffi/src/producer.rs @@ -170,7 +170,7 @@ impl MoqBroadcastProducer { } /// Run `f` against the open broadcast and catalog. Errors with - /// [`MoqError::Closed`] if `finish()` has already run. Used by + /// [`MoqError::Closed`] if `close()` has already run. Used by /// sibling modules (e.g. `audio`) that need joint access. pub(crate) fn with_state( &self, @@ -470,18 +470,27 @@ impl MoqBroadcastProducer { })) } - /// Finish this publisher, finalizing the catalog stream and cleanly closing the - /// broadcast so subscribers see a normal end rather than `Error::Dropped`. - pub fn finish(&self) -> Result<(), MoqError> { + /// End the broadcast for good: retract it, serve no new tracks, and finalize the catalog. + /// + /// Tracks already subscribed carry on to their own end. Every later call on this + /// producer fails with `Closed`; closing again is a no-op. + pub fn close(&self) -> Result<(), MoqError> { let _guard = crate::ffi::enter(); + // Hold the lock through shutdown so a concurrent close() returns only once it is done. let mut guard = self.state.lock().unwrap(); - let mut state = guard.take().ok_or(MoqError::Closed)?; - // Finish the broadcast first so the clean end reaches subscribers even if - // finalizing the catalog fails. - state.broadcast.finish(); + let Some(mut state) = guard.take() else { + return Ok(()); + }; + // Close the broadcast first so it ends even if finalizing the catalog fails. + state.broadcast.close(); state.catalog.finish()?; Ok(()) } + + /// Deprecated: use `close()`. A broadcast end carries no cause. + pub fn finish(&self) -> Result<(), MoqError> { + self.close() + } } // ---- Dynamic Broadcast Producer ---- diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index f07af2e68f..fca53b74b5 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -332,7 +332,7 @@ async fn announced_route_keeps_cold_cost_on_reannounce() { assert_eq!(back.cost, moq_net::origin::Cost { warm: 0, cold: 9 }); assert_eq!(MoqRoute::from(back), route); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[test] @@ -347,7 +347,7 @@ fn publish_media_lifecycle() { }) .unwrap(); media.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[tokio::test] @@ -467,7 +467,7 @@ async fn raw_audio_activity() { assert_eq!(resumed.timestamp_us, RESUMED_TIMESTAMP_US); audio.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } /// `frame_duration_us` is microseconds so Opus' 2.5 ms frame survives the trip, where @@ -509,7 +509,7 @@ async fn raw_audio_frame_durations() { "2 ms is not an opus frame duration" ); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[tokio::test] @@ -1484,7 +1484,7 @@ async fn create_broadcast_is_invisible_until_announced() { .expect("the cursor is still open"); assert_eq!(update.prefix(), "live"); assert!(update.active()); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } /// Waiting for an exact path must hand the broadcast back named by that path, the base a @@ -1511,7 +1511,7 @@ async fn announced_broadcast_keeps_the_requested_path() { .unwrap(); assert_eq!(requested.inner().info().path.as_str(), "a/pub"); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } /// A catalog rendition may name a sibling broadcast (`./source`), and the track then lives @@ -1552,8 +1552,8 @@ async fn decode_audio_follows_a_sibling_broadcast_reference() { "the catalog broadcast does not serve the track itself" ); - catalog.finish().unwrap(); - source.finish().unwrap(); + catalog.close().unwrap(); + source.close().unwrap(); } /// Announcement filters do not re-root the origin, so relative broadcast references @@ -1607,8 +1607,8 @@ async fn announced_broadcasts_resolve_siblings_under_the_prefix() { .expect("timed out subscribing on the resolved broadcast") .unwrap(); - catalog.finish().unwrap(); - source.finish().unwrap(); + catalog.close().unwrap(); + source.close().unwrap(); } #[tokio::test] @@ -1644,7 +1644,7 @@ async fn announced_filters_patterns_and_reports_captures() { assert_eq!(update.captures(), Some(vec!["alice".into()])); assert!(update.active()); - chat.finish().unwrap(); + chat.close().unwrap(); } /// A `.`-named broadcast is listed only when the config opts in or the prefix names it. @@ -1733,8 +1733,8 @@ async fn resolve_returns_a_broadcast_that_resolves_further_references() { .unwrap(); assert_eq!(back.inner().info().path.as_str(), "a/pub"); - catalog.finish().unwrap(); - source.finish().unwrap(); + catalog.close().unwrap(); + source.close().unwrap(); } #[tokio::test] @@ -1778,7 +1778,7 @@ async fn announce_and_unannounce_toggles_discovery() { .expect("timed out requesting the reannounced broadcast") .expect("a reannounced broadcast resolves"); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[tokio::test] @@ -1795,7 +1795,7 @@ async fn finish_unpublishes() { // A graceful finish detaches immediately; the path stops resolving. Removal is // asynchronous, so poll until it takes effect. - broadcast.finish().unwrap(); + broadcast.close().unwrap(); let removed = tokio::time::timeout(TIMEOUT, async { loop { if consumer.request_broadcast("live".into()).await.is_err() { @@ -1864,7 +1864,7 @@ async fn local_publish_consume_audio() { assert_eq!(frame.payload, payload); assert_eq!(frame.timestamp_us, 1_000_000); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[tokio::test] @@ -1926,7 +1926,7 @@ async fn video_publish_consume() { assert_eq!(frame.timestamp_us, 0); assert!(!frame.payload.is_empty(), "frame should have payload data"); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } /// The raw-video publish path: hand mid-gray RGBA to `encode_video` and check @@ -2039,7 +2039,7 @@ async fn video_raw_publish_consume() { assert!(!frame.payload.is_empty(), "frame should carry encoded video"); video.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } /// The decode side picks its CPU pixel layout: an unset `format` delivers I420, @@ -2147,7 +2147,7 @@ async fn video_decode_format() { i420.cancel(); rgba_out.cancel(); video.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } /// Regression: a `MoqVideoProducer` is shared, so its calls land on whichever @@ -2233,7 +2233,7 @@ async fn video_raw_publish_from_many_threads() { let closer = video.clone(); std::thread::spawn(move || closer.finish()).join().unwrap().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } /// A raw video producer rejects a buffer that isn't one picture at the @@ -2297,7 +2297,7 @@ async fn video_raw_publish_rejects_bad_frames() { Err(MoqError::Closed) )); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[tokio::test] @@ -2352,7 +2352,7 @@ async fn multiple_frames_ordering() { assert_eq!(frame.payload, expected.as_bytes(), "frame {i} has wrong payload"); } - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[tokio::test] @@ -2394,17 +2394,22 @@ async fn catalog_update_on_new_track() { assert_eq!(catalog2.audio["0.opus"].label.as_deref(), Some("English")); assert_eq!(catalog2.audio["1.opus"].label, None); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[test] -fn finish_closes_producer() { +fn close_twice_is_a_noop() { let broadcast = MoqBroadcastProducer::new().unwrap(); let init = opus_head(); - let _media = broadcast.publish_audio(audio_init(MoqAudioFormat::Opus, init)).unwrap(); - broadcast.finish().unwrap(); + let _media = broadcast + .publish_audio(audio_init(MoqAudioFormat::Opus, init.clone())) + .unwrap(); + broadcast.close().unwrap(); + broadcast.close().unwrap(); - let err = broadcast.finish().unwrap_err(); + let Err(err) = broadcast.publish_audio(audio_init(MoqAudioFormat::Opus, init)) else { + panic!("publishing after close succeeded"); + }; assert!( matches!(err, crate::error::MoqError::Closed), "expected Closed error, got {err}" @@ -2433,7 +2438,7 @@ async fn announced_broadcast() { .unwrap(); // Finish so consumers observe a deliberate end (the canonical end for a // publisher; dropping without finish reads as a failure). - _broadcast.finish().unwrap(); + _broadcast.close().unwrap(); } fn serve(origin: &MoqOriginProducer, prefix: &str) -> Arc { @@ -2486,7 +2491,7 @@ async fn dynamic_broadcast_request() { assert_eq!(frame.timestamp_us, 20_000); track.finish().unwrap(); - served.finish().unwrap(); + served.close().unwrap(); } /// A prefix serves requests beneath it; cancelling the handle @@ -2526,7 +2531,7 @@ async fn dynamic_serves_a_request_under_a_prefix() { crate::error::MoqProtocolKind::Unroutable, ); - served.finish().unwrap(); + served.close().unwrap(); } /// Tearing the origin down ends every handler with `Closed`. A parked request @@ -3268,7 +3273,7 @@ fn without_runtime() { announced.cancel(); client.cancel(); media.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); drop(client); drop(consumer); drop(announcement); @@ -3369,7 +3374,7 @@ async fn server_client_roundtrip() { // Clean up. Exercise `shutdown()` on the client side and the underlying // `cancel(code)` on the server side, so both shutdown paths run. media.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); cs.shutdown(); server_session.cancel(0); server.cancel(); @@ -3441,10 +3446,10 @@ async fn server_client_roundtrip_auto_origin() { .await .expect("timed out waiting for the loopback broadcast") .expect("an auto-origin session should discover its own announcement"); - local_broadcast.finish().unwrap(); + local_broadcast.close().unwrap(); media.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); cs.shutdown(); server_session.cancel(0); server.cancel(); @@ -3650,7 +3655,7 @@ async fn request_per_session_publish_override() { .expect("expected an announcement"); assert_eq!(announcement.prefix(), "override-only"); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); cs.cancel(0); server_session.cancel(0); server.cancel(); @@ -3778,7 +3783,7 @@ async fn client_reconnects_and_resumes_announcements() { .expect("expected an announcement"); assert_eq!(announcement.prefix(), "after-reconnect"); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); cs.cancel(0); server_session.cancel(0); server.cancel(); @@ -4042,7 +4047,7 @@ async fn video_encoder_follows_a_shrinking_grant() { } video.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } #[cfg(feature = "video")] @@ -4119,7 +4124,7 @@ async fn set_bitrate_caps_a_later_bandwidth_grant() { assert_eq!(video.applied_bitrate(), 1_000_000); video.finish().unwrap(); - broadcast.finish().unwrap(); + broadcast.close().unwrap(); } async fn one_shot_peers() -> (Arc, Arc, Arc) { diff --git a/rs/moq-ffi/uniffi.toml b/rs/moq-ffi/uniffi.toml new file mode 100644 index 0000000000..ddf0c572fe --- /dev/null +++ b/rs/moq-ffi/uniffi.toml @@ -0,0 +1,5 @@ +# Kotlin objects are AutoCloseable, and `close()` there releases the handle, +# which ends the broadcast like dropping the last Rust producer. A generated +# `close()` would collide with it. +[bindings.kotlin] +exclude = ["MoqBroadcastProducer.close"] diff --git a/rs/moq-gst/src/sink/imp.rs b/rs/moq-gst/src/sink/imp.rs index d1c7d5685c..d1ca805abd 100644 --- a/rs/moq-gst/src/sink/imp.rs +++ b/rs/moq-gst/src/sink/imp.rs @@ -641,9 +641,8 @@ impl MoqSink { if let Some(err) = failure { gst::warning!(CAT, "finalize on stop: {err:?}"); } - // Finish the broadcast (a deliberate end, so no dropped-without-finish - // warning) before reaping the session task. - state.broadcast.finish(); + // Close the broadcast before reaping the session task. + state.broadcast.close(); state.session.stop(); self.notify_updates(updates); } diff --git a/rs/moq-gst/src/source/imp.rs b/rs/moq-gst/src/source/imp.rs index c7cd09b910..cadaadecdb 100644 --- a/rs/moq-gst/src/source/imp.rs +++ b/rs/moq-gst/src/source/imp.rs @@ -1271,7 +1271,7 @@ mod session_tests { // End the served rendition and the broadcast, which answers for the reserved one. catalog.finish().unwrap(); video.finish().unwrap(); - broadcast.finish(); + broadcast.close(); // No shutdown is sent: the session has to end on the media draining alone. super::RUNTIME diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 4b5770a10e..1d3cc5df92 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -2293,11 +2293,11 @@ mod tests { // Tear down the publisher mid-group. The track ends abruptly (the cursor drains the // segments it already saw and ends; the still-open live-edge group is NOT finalized, - // since a reset can't vouch that its media is complete), while finishing the broadcast - // ends it cleanly rather than as a failure. (Clean-end finalization of the + // since a reset can't vouch that its media is complete), and the broadcast closes. + // (Clean-end finalization of the // live edge is covered by segments::tests::next_after_walks_finalized_segments.) drop((catalog, media, registration)); - broadcast.finish(); + broadcast.close(); let end = tokio::time::timeout(Duration::from_secs(5), segments.next()) .await diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index 5832ae9375..7d30ee5f32 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -139,9 +139,8 @@ impl Media { let mut handle = self.handle.lock().expect("media lock poisoned"); match handle.binding.poll_broadcast(&kio::Waiter::noop()) { Poll::Ready(Ok(broadcast)) if broadcast.is_closed() => { - // The bound sibling ended (a different first hop replaces the publisher with - // Dropped). Origin finish()es that spliced front, so is_finished() cannot - // tell a rival publisher from a clean VOD end. Rows listed for it must not + // The bound sibling ended, and a broadcast end carries no cause, so a rival + // publisher looks the same as a clean VOD end. Rows listed for it must not // be served from the replacement. window.clear(); if let Ok(next) = source.bind(Some(rel)) { diff --git a/rs/moq-hls/src/server/mod.rs b/rs/moq-hls/src/server/mod.rs index 2686362d40..4e15a17b18 100644 --- a/rs/moq-hls/src/server/mod.rs +++ b/rs/moq-hls/src/server/mod.rs @@ -260,8 +260,8 @@ mod tests { let broadcaster = Broadcaster::new(source, Config::default()) .await .expect("catalog broadcast resolves while announced"); - // Finish the publisher so the resolved broadcast (and the broadcaster) reports closed. - producer.finish(); + // Close the publisher so the resolved broadcast (and the broadcaster) reports closed. + producer.close(); settle().await; broadcaster } @@ -311,6 +311,6 @@ mod tests { let cached = server.inner.broadcasters.lock().unwrap().get("live").cloned(); assert!(cached.is_some_and(|cached| Arc::ptr_eq(&cached, &new))); - new_producer.finish(); + new_producer.close(); } } diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index b2cef774cb..ffea126018 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -361,8 +361,8 @@ fn bench_handoff(c: &mut Criterion) { total += started.elapsed(); drop(subscription); - incumbent.finish(); - standby.finish(); + incumbent.close(); + standby.close(); // Wait for the front to close so the next iteration starts a fresh one. resolved.closed().await; } diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 4ef0adc042..b2cea32c26 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -388,24 +388,6 @@ impl Drop for Counted { } } -/// How the last source for a path detaches, which decides whether the origin closes -/// the broadcast now or holds it open for a replacement. -/// -/// Only the detach that drops the refcount to zero decides, matching the model's rule -/// for several sources at one path (the front's source selection): an earlier owner that vanished -/// does not outvote the last one still on the path. That keeps two advertisements on -/// one session behaving like the same two on separate sessions, where the model sees -/// two independent sources and the last one out decides. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Detach { - /// The peer retracted the path, or we are rolling back an announce we just made. - /// Nothing is coming back, so close it now. - Graceful, - /// The stream carrying the path went away without retracting it. Abort the - /// source so viewers observe the loss as an error rather than a clean end. - Abrupt, -} - struct BroadcastState { // The route announced into our origin for this namespace, post-charge. route: crate::origin::Route, @@ -417,9 +399,8 @@ struct BroadcastState { // active number of PUBLISH_NAMESPACE messages. count: usize, - // One minted source per requested path under the namespace: finish() on a - // deliberate unannounce, dropping (a dying session) aborts them so viewers - // observe the loss as an error. + // One minted source per requested path under the namespace, each closed + // when its guard drops. sources: HashMap, } @@ -792,14 +773,11 @@ where // ends: a clean close, a decode error, or the peer resetting it. Without this // each namespace keeps its refcount and the source never detaches. // - // Abruptly, including on a clean FIN: closing the stream retracts nothing, since - // the protocol has NAMESPACE_DONE for that. Whatever is still live here outlived - // its channel without being withdrawn, so hold the front open for a reconnect. // This is what moq-lite already does, where the equivalent map is a local whose // guards drop. let res = self.run_namespace_entries(&mut stream, &prefix, &peer, &mut live).await; for path in live { - let _ = self.stop_announce(path, Detach::Abrupt); + let _ = self.stop_announce(path); } res } @@ -843,7 +821,7 @@ where // leave subscriptions on a path the peer no longer offers. tracing::debug!(%path, "dropping reflected namespace"); if live.remove(&path) { - let _ = self.stop_announce(path, Detach::Graceful); + let _ = self.stop_announce(path); } continue; }; @@ -873,7 +851,7 @@ where let path = prefix.join(&msg.suffix); tracing::debug!(%path, "namespace_done"); if live.remove(&path) { - let _ = self.stop_announce(path, Detach::Graceful); + let _ = self.stop_announce(path); } } _ => { @@ -1008,7 +986,7 @@ where Ok(_) => { if let Err(err) = self.write_ok(&mut stream, request_id).await { // Local rollback, not a peer unannounce: don't count announce bytes. - let _ = self.stop_announce(path, Detach::Graceful); + let _ = self.stop_announce(path); return Err(err); } } @@ -1032,15 +1010,7 @@ where .await; if attached { - // Ending cleanly IS the retraction here, unlike a NAMESPACE stream: this stream - // carries exactly one advertisement, and withdrawing it is what ends the stream. - // Any other ending left the advertisement standing, so the peer never withdrew - // it and the loss reads as abrupt (an error, not a clean end). - let detach = match res.is_ok() { - true => Detach::Graceful, - false => Detach::Abrupt, - }; - self.stop_announce(path, detach)?; + self.stop_announce(path)?; } res @@ -1152,7 +1122,7 @@ where let Some(advert) = self.route(held.as_ref(), &peer) else { if std::mem::take(attached) { tracing::debug!(%path, "publish_namespace now loops back; detaching"); - let _ = self.stop_announce(path.clone(), Detach::Graceful); + let _ = self.stop_announce(path.clone()); } self.write_ok(stream, msg.request_id).await?; continue; @@ -1421,24 +1391,18 @@ where } } - fn stop_announce(&mut self, path: PathOwned, detach: Detach) -> Result<(), Error> { + /// Release one advertisement of `path`, closing its sources when it was the last. + fn stop_announce(&mut self, path: PathOwned) -> Result<(), Error> { let mut state = self.state.lock(); match state.broadcasts.entry(path.clone()) { Entry::Occupied(mut entry) => { entry.get_mut().count -= 1; if entry.get().count == 0 { - tracing::debug!(route = %self.origin.absolute(&path), ?detach, "unannounced"); - // Dropping the entry retracts the route (its announcement drops). - let removed = entry.remove(); - for (_, source) in removed.sources { - match detach { - Detach::Graceful => source.finish(), - // Dropping the guard aborts the source, so the loss reads - // as an error rather than a clean end. - Detach::Abrupt => {} - } - } + tracing::debug!(route = %self.origin.absolute(&path), "unannounced"); + // Dropping the entry retracts the route (its announcement drops) and + // closes its sources (their guards drop). + entry.remove(); } } Entry::Vacant(_) => return Err(Error::NotFound), @@ -1514,8 +1478,8 @@ where let dynamic = source.dynamic(); request.accept(&source); - // Retain the source so a retraction can finish it. If the route was - // retracted since the accept, finish it here as that retraction would + // Retain the source so a retraction can close it. If the route was + // retracted since the accept, close it here as that retraction would // have, and still serve what it took on: tracks subscribed since carry on. let guard = crate::model::broadcast::SourceGuard::new(source); let retracted = { @@ -1528,9 +1492,7 @@ where None => Some(guard), } }; - if let Some(guard) = retracted { - guard.finish(); - } + drop(retracted); let this = self.clone(); broadcasts.push(async move { @@ -3752,7 +3714,7 @@ mod tests { // Let the SUBSCRIBE go out, then retract the broadcast before any response. settle().await; - producer.finish(); + producer.close(); settle().await; assert!( @@ -4544,7 +4506,7 @@ mod tests { subscriber.start_announce(path.clone(), advert).unwrap(); settle().await; - subscriber.stop_announce(path, Detach::Graceful).unwrap(); + subscriber.stop_announce(path).unwrap(); assert!( routed_now(&consumer, "room/host").is_none(), "an explicit NAMESPACE_DONE must retract the route", @@ -4665,7 +4627,7 @@ mod tests { /// Several advertisements share one refcounted source, so the detach that empties it /// is the one that counts: the broadcast survives the first stop and closes on the - /// last, whatever kind each detach is. + /// last. /// /// That is the model's own rule for several sources at one path (the front's source /// selection), @@ -4686,7 +4648,7 @@ mod tests { settle().await; // One advertisement's stream dies: the other still holds the source. - subscriber.stop_announce(path.clone(), Detach::Abrupt).unwrap(); + subscriber.stop_announce(path.clone()).unwrap(); settle().await; assert!( routed_now(&consumer, "room/host").is_some(), @@ -4694,7 +4656,7 @@ mod tests { ); // The last owner retracts: the broadcast closes with it. - subscriber.stop_announce(path, Detach::Graceful).unwrap(); + subscriber.stop_announce(path).unwrap(); settle().await; assert!( routed_now(&consumer, "room/host").is_none(), @@ -4761,7 +4723,7 @@ mod tests { // One advertisement, so one unannounce detaches it. If the update had bumped the // refcount, this would leave the route stranded. - subscriber.stop_announce(path, Detach::Graceful).unwrap(); + subscriber.stop_announce(path).unwrap(); assert!(routed_now(&consumer, "room/host").is_none()); } @@ -4814,7 +4776,7 @@ mod tests { ); // One advertisement, so one unannounce detaches it. - subscriber.stop_announce(path, Detach::Graceful).unwrap(); + subscriber.stop_announce(path).unwrap(); assert!(routed_now(&consumer, "room/host").is_none()); } @@ -4843,9 +4805,9 @@ mod tests { assert!(routed_now(&consumer, "room/host").is_some()); // Two advertisements, so it takes two unannounces to retract. - subscriber.stop_announce(path.clone(), Detach::Graceful).unwrap(); + subscriber.stop_announce(path.clone()).unwrap(); assert!(routed_now(&consumer, "room/host").is_some()); - subscriber.stop_announce(path, Detach::Graceful).unwrap(); + subscriber.stop_announce(path).unwrap(); assert!(routed_now(&consumer, "room/host").is_none()); } @@ -4872,9 +4834,9 @@ mod tests { assert!(routed_now(&consumer, "room/host").is_some()); // Two advertisements, so it takes two unannounces to retract. - subscriber.stop_announce(path.clone(), Detach::Graceful).unwrap(); + subscriber.stop_announce(path.clone()).unwrap(); assert!(routed_now(&consumer, "room/host").is_some()); - subscriber.stop_announce(path, Detach::Graceful).unwrap(); + subscriber.stop_announce(path).unwrap(); assert!(routed_now(&consumer, "room/host").is_none()); } @@ -4911,7 +4873,7 @@ mod tests { ); // That supersedes the advertisement it repeats, so the old route is retired. - subscriber.stop_announce(path, Detach::Graceful).unwrap(); + subscriber.stop_announce(path).unwrap(); assert!( routed_now(&consumer, "room/host").is_none(), "the superseded route must not stay attached" @@ -5371,7 +5333,7 @@ mod tests { // What the stream's exit path does with whatever it still holds. for path in live { - subscriber.stop_announce(path, Detach::Graceful).unwrap(); + subscriber.stop_announce(path).unwrap(); } assert!(routed_now(&consumer, "room/a").is_none(), "room/a leaked a refcount"); diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 69996539ca..fdaa29b201 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -2767,9 +2767,8 @@ impl Announced { } fn declined(&mut self, path: PathOwned) { - if let Some(Some(route)) = self.routes.insert(path, None) { - route.finish(); - } + // Dropping a replaced route closes its sources. + self.routes.insert(path, None); } /// Record an advertisement before deciding what to do with it. @@ -2780,8 +2779,7 @@ impl Announced { /// at each rejection is what stops the next early return from silently freeing a path /// the peer still holds. /// Only valid on a prefix the peer does not already hold, which the caller establishes - /// with [`Self::contains`]. Overwriting an attached route here would drop its source - /// without finishing it, which is [`Self::declined`]'s job. + /// with [`Self::contains`]. Overwriting an attached route is [`Self::declined`]'s job. fn reserve(&mut self, path: PathOwned) { debug_assert!(!self.routes.contains_key(&path), "reserved a prefix already advertised"); self.routes.insert(path, None); @@ -2792,9 +2790,8 @@ impl Announced { } fn retire(&mut self, path: &PathOwned) { - if let Some(Some(route)) = self.routes.remove(path) { - route.finish(); - } + // Dropping the route closes its sources. + self.routes.remove(path); } /// Serve queued requests on every ready route: mint a source per requested @@ -2846,8 +2843,7 @@ struct AnnouncedRoute { route: crate::origin::Route, /// Dropping it retracts the route and rejects its queued requests. dynamic: crate::origin::Dynamic, - /// One minted source per requested path, finished on a clean retraction and - /// aborted (via drop) when the session dies. + /// One minted source per requested path, each closed when its guard drops. sources: HashMap, /// Whether the GOAWAY drain already re-priced this route. drained: bool, @@ -2871,14 +2867,6 @@ impl AnnouncedRoute { } } - /// The peer deliberately retracted the route: finish the minted sources so - /// their consumers observe a clean end, and retract the announcement. - fn finish(self) { - for (_, source) in self.sources { - source.finish(); - } - } - /// Update the announced route in place (a restart). fn update(&mut self, route: crate::origin::Route) { self.route = route.clone(); diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index 7cdf0618eb..3b7f90306e 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -2,8 +2,8 @@ //! //! A [Producer] creates tracks on demand: a [Consumer] subscribes by name, and the //! producer either serves a track it already has or is handed a [`track::Request`] to -//! fill. Both handles are refcounted clones of one broadcast, which closes on -//! [`Producer::finish`] or when the last producer drops. +//! fill. Both handles are refcounted clones of one broadcast, which ends on +//! [`Producer::close`] or when the last producer drops. //! //! [Info] is the broadcast's static metadata, fixed for its lifetime. use crate::{cache, stats, track}; @@ -67,7 +67,7 @@ impl Info { /// Consume this [Info] to create a producer that carries its metadata. /// /// Keep the returned [`Producer`] alive for as long as the broadcast should stay - /// available, and end it with [`Producer::finish`]. See the note on [`Producer`]. + /// available, and end it with [`Producer::close`]. See the note on [`Producer`]. pub fn produce(self) -> Producer { Producer::new(self) } @@ -94,12 +94,11 @@ struct BroadcastState { // joined across per-session tracks. `None` for an ordinary broadcast. spliced: Option, - // Set by an explicit `Producer::finish()` or `Producer::abort()` so `Drop` can - // tell a deliberate shutdown apart from a producer dropped by accident. + // Set once the broadcast ends: `Producer::close()`, an abort, or the last + // producer-side handle dropping. Every lookup after it answers `Unroutable`. closing: bool, - // Set only by `Producer::finish()`: the broadcast ended deliberately, as - // opposed to aborting or losing its producer. + // Set only by the deprecated `Producer::finish()`, for `Consumer::is_finished`. finished: bool, // The error passed to `Producer::abort()`, reported by `Consumer::closed`. @@ -185,17 +184,10 @@ impl BroadcastState { /// /// # Lifetime /// -/// **You must keep this producer alive for as long as the broadcast should stay -/// available.** A broadcast lives as long as at least one [`Producer`] exists; -/// children do *not* keep it alive (cloning a [`Consumer`] or holding a -/// [`track::Producer`] does nothing for the broadcast's lifetime). When the last -/// producer goes away every consumer observes [`Error::Dropped`]. -/// -/// End the broadcast with [`Self::finish`] rather than dropping it. Dropping is an -/// easy footgun in garbage-collected bindings (Go, Python, ...), where the handle -/// can be collected the moment it falls out of scope even while you are still -/// publishing, tearing the stream down mid-broadcast. Dropping the last producer -/// without [`Self::finish`] logs a warning. +/// A broadcast lives until [`Self::close`] or until the last [`Producer`] (or +/// [`Dynamic`]) drops, whichever comes first; both end it the same way. Children +/// do *not* keep it alive: cloning a [`Consumer`] or holding a [`track::Producer`] +/// does nothing for the broadcast's lifetime. #[derive(Clone)] pub struct Producer { // Held behind an Arc so each track born from this broadcast can inherit a shared @@ -250,12 +242,11 @@ impl Producer { /// local consumers and peers alike. Call it once the tracks a subscriber needs /// first (a catalog) exist, so the advertisement lands with them in place: /// consumers act on it immediately. The route retracts on [`unannounce`](Self::unannounce), - /// [`finish`](Self::finish), [`abort`](Self::abort), or the last producer - /// dropping. + /// [`close`](Self::close), or the last producer dropping. /// /// Fails with [`Error::Closed`] on a standalone broadcast (one not created - /// through an origin, so there is nothing to announce into) or once the - /// origin's driver has been dropped. + /// through an origin, so there is nothing to announce into), once the broadcast + /// has closed, or once the origin's driver has been dropped. pub fn announce(&self, route: Route) -> Result<(), Error> { let mut announcer = self.alive.announcer.lock(); let announcer = announcer.as_mut().ok_or(Error::Closed)?; @@ -344,7 +335,7 @@ impl Producer { /// /// Subscribers wait on the name until it is accepted, so a reservation the producer /// ends up never filling has to be dropped or rejected. Ending the broadcast - /// ([`Self::finish`] or [`Self::abort`]) resolves whatever is left. + /// resolves whatever is left. pub fn reserve_track(&self, name: impl Into>) -> Result { let request = track::Request::new(self.info.clone(), name).with_stats(self.stats.clone()); self.state.lock().insert_track(request.weak())?; @@ -428,7 +419,11 @@ impl Producer { } } - /// Create a consumer that can subscribe to tracks in this broadcast. + /// Create a consumer of this one publisher's broadcast. + /// + /// A view of this broadcast object, not of its path: a new publisher at the same + /// path is never spliced into it, so it ends when this broadcast does. Go through + /// an origin for a consumer that should not care which publisher serves the path. pub fn consume(&self) -> Consumer { Consumer { info: self.info.clone(), @@ -438,52 +433,28 @@ impl Producer { } } - /// Cleanly finish the broadcast once you are done publishing. - /// - /// Marks the broadcast as deliberately finished so consumers observe a normal - /// end. Prefer this over dropping the producer: an accidental drop (see the note - /// on [`Producer`]) logs a warning, whereas `finish()` is silent. - /// - /// Ends the broadcast outright: consumers observe a normal end immediately and no - /// new tracks are served, whether or not other producer clones are still alive. - /// Existing tracks stay readable so consumers can drain what they already have. + /// End the broadcast for good, whether or not other clones are still alive. /// - /// A name that was reserved, or requested and never taken by a [`Dynamic`], resolves - /// with [`Error::NotFound`]: nothing can fill it now, so its subscribers fail rather - /// than waiting on a [`track::Info`] that is never coming. A request a handler took - /// is left for that handler to answer. + /// Retracts its announcement and local discovery; a later [`Self::announce`] fails + /// with [`Error::Closed`]. Tracks already handed out carry on and end with their + /// own finish or abort. Every later [`Consumer::track`], and every name reserved or + /// still queued for a handler, answers [`Error::Unroutable`]: the same answer an + /// origin gives for a path nobody publishes. A request a [`Dynamic`] already took is + /// left for that handler to answer. /// - /// Borrows rather than consumes, matching [`track::Producer::finish`]. Finishing - /// declares the end, so it must not depend on the caller also surrendering the - /// handle. + /// Dropping the last producer does the same. Closing twice is a no-op. + pub fn close(&self) { + self.alive.close(); + } + + #[doc(hidden)] + #[deprecated(note = "use close(); a broadcast end carries no cause")] pub fn finish(&self) { - { - let mut state = self.state.lock(); - state.closing = true; - state.finished = true; - // A name that was reserved or queued but never served can't arrive now, - // and `Consumer::track` already answers `NotFound` for one asked about after - // this point. Say the same to whoever asked earlier. - state.reject_unserved(Error::NotFound); - } - // Ending the broadcast is what consumers wait on, so signal it here rather - // than leaving it to the last handle drop. - let _ = self.alive.token.close(); - self.alive.retire(); + self.alive.end(true); } - /// Abort the broadcast, ending it for consumers with `err`. - /// - /// Like [`finish`](Self::finish) the end is immediate, whether or not other - /// producer clones are still alive, and existing tracks stay readable so - /// consumers can drain what they already have (an abort does not cascade into - /// the tracks), while a name nothing ever served resolves with `err` the same - /// way [`finish`](Self::finish) resolves it. Unlike a finish, consumers observe - /// `err` from [`Consumer::closed`], so the end reads as a failure rather than a - /// deliberate one. - /// - /// Consumes the producer: an abort is terminal. Errors if the broadcast was - /// already finished or aborted. + #[doc(hidden)] + #[deprecated(note = "use close(); a broadcast end carries no cause")] pub fn abort(self, err: Error) -> Result<(), Error> { { let mut state = self.state.lock(); @@ -507,8 +478,8 @@ impl Producer { } } -/// Ends the broadcast when the last [`Producer`] or [`Dynamic`] drops, closing the -/// liveness channel every [`Consumer`] watches. +/// Ends the broadcast on [`Producer::close`] or when the last [`Producer`] or +/// [`Dynamic`] drops, closing the liveness channel every [`Consumer`] watches. /// /// A refcount rather than a "am I the last one?" check inside `Drop`: that answer is /// a snapshot, and acting on it is exactly what invalidates it. @@ -516,7 +487,7 @@ struct Alive { token: kio::Producer<()>, state: kio::Shared, // The advertisement of the broadcast's exact path, owned here so it retracts - // with the broadcast: on finish, abort, or the last producer-side handle + // with the broadcast: on close, abort, or the last producer-side handle // dropping. `None` for a standalone broadcast. announcer: kio::Lock>, } @@ -537,6 +508,29 @@ impl Alive { } } + /// End the broadcast. See [`Producer::close`]. + fn close(&self) { + self.end(false); + } + + /// End the broadcast, recording the deprecated `finished` flag in the same locked + /// transition that claims the end, so a racing `abort` can't win after it's set. + fn end(&self, finished: bool) { + { + let mut state = self.state.lock(); + if std::mem::replace(&mut state.closing, true) { + return; + } + state.finished = finished; + // A name that was reserved or queued but never served can't arrive now, + // and `Consumer::track` answers `Unroutable` for one asked about after this + // point. Say the same to whoever asked earlier. + state.reject_unserved(Error::Unroutable); + } + let _ = self.token.close(); + self.retire(); + } + /// End the broadcast's advertising for good: retract the standing advertisement /// and drop the announcer, so a later `announce` fails with `Closed`. fn retire(&self) { @@ -549,15 +543,7 @@ impl Alive { impl Drop for Alive { fn drop(&mut self) { - // Warn if the last exit wasn't an explicit finish(), since consumers will - // then see Error::Dropped (classically a GC-collected handle in a language - // binding that tears the stream down mid-publish). - if !self.state.read().closing { - tracing::warn!( - "broadcast::Producer dropped without finish(). Keep the producer alive while publishing, then call finish()." - ); - } - self.retire(); + self.close(); } } @@ -574,36 +560,22 @@ impl Producer { } /// A session-owned handle to a source broadcast created via -/// [`crate::origin::Producer::create_broadcast`]: [`Self::finish`] ends it -/// deliberately, while dropping the guard aborts it as [`Error::Dropped`] (a dead -/// session), so consumers observe the loss as an error. Shared by the lite and -/// IETF subscribers so the drop-vs-finish contract lives in one place. -pub(crate) struct SourceGuard { - // `Option` so `finish` can consume the producer while `Drop` aborts it. - producer: Option, -} +/// [`crate::origin::Producer::create_broadcast`], closing it on drop even while the +/// session's serve machines still hold its [`Dynamic`]. +/// +/// A peer's retraction and a dead session end the source the same way: a broadcast +/// carries no end cause past this hop. Shared by the lite and IETF subscribers. +pub(crate) struct SourceGuard(Producer); impl SourceGuard { pub fn new(producer: Producer) -> Self { - Self { - producer: Some(producer), - } - } - - /// End the source deliberately: the origin detaches it immediately, - /// unannouncing the path if it was the last. - pub fn finish(mut self) { - if let Some(producer) = self.producer.take() { - producer.finish(); - } + Self(producer) } } impl Drop for SourceGuard { fn drop(&mut self) { - if let Some(producer) = self.producer.take() { - let _ = producer.abort(Error::Dropped); - } + self.0.close(); } } @@ -614,6 +586,7 @@ impl Drop for SourceGuard { /// [`track::Request::accept`]s it with a concrete [`track::Info`] or /// [`track::Request::reject`]s it. Dropped when no longer needed; pending requests /// are automatically aborted. +#[derive(Clone)] pub struct Dynamic { info: Arc, // Keeps the broadcast alive while a handler exists (mirrors a producer). @@ -622,31 +595,51 @@ pub struct Dynamic { // Ingress stats scope, applied to the tracks this handler serves. Empty (no-op) // for an untagged broadcast. stats: stats::Scope, + // Declared after `alive` so it drops second: when this was the broadcast's last + // handle, `Alive` has already ended it and answered every queued request + // `Unroutable`, so the handler's own `Dropped` rejection finds nothing left. + _handler: Handler, } -impl Clone for Dynamic { +/// Counts one live [`Dynamic`], rejecting the queued requests when the last one drops. +struct Handler(kio::Shared); + +impl Handler { + fn new(state: kio::Shared) -> Self { + state.lock().requests.add_handler(); + Self(state) + } +} + +impl Clone for Handler { fn clone(&self) -> Self { - // Mirror `new`: count each live handle. Without this, deriving Clone would - // let `Drop` decrement past `new`'s single increment and prematurely flip - // the handler count to zero, causing future `track` calls to return `NotFound`. - self.state.lock().requests.add_handler(); + // Count each live handle, or dropping a clone would flip the handler count to + // zero and future `track` calls would return `NotFound`. + Self::new(self.0.clone()) + } +} - Self { - info: self.info.clone(), - alive: self.alive.clone(), - state: self.state.clone(), - stats: self.stats.clone(), +impl Drop for Handler { + fn drop(&mut self) { + // Decrement and reject under one lock, so a `track` call that saw a live + // handler through the same lock can't slip a request past the rejection. + let mut state = self.0.lock(); + if state.requests.remove_handler() { + // No handlers left to fulfill pending requests; reject them so consumers + // don't block forever on tracks nobody will serve. + for request in state.requests.drain_queued() { + request.reject(Error::Dropped); + } } } } impl Dynamic { fn new(info: Arc, alive: Arc, state: kio::Shared, stats: stats::Scope) -> Self { - state.lock().requests.add_handler(); - Self { info, alive, + _handler: Handler::new(state.clone()), state, stats, } @@ -659,9 +652,8 @@ impl Dynamic { /// Poll for the next consumer-requested track, without blocking. /// - /// Returns [`Error::Closed`] once the broadcast was deliberately ended - /// ([`Producer::finish`] or aborted), so a serving loop knows to stop and - /// release its handle. + /// Returns [`Error::Closed`] once the broadcast has ended, so a serving loop + /// knows to stop and release its handle. pub fn poll_requested_track(&mut self, waiter: &kio::Waiter) -> Poll> { let mut state = ready!(self.state.poll(waiter, |state| { if state.requests.has_queued() || state.closing { @@ -699,15 +691,14 @@ impl Dynamic { } } - /// Block until the broadcast is closed, by [`Producer::finish`], - /// [`Producer::abort`], or every producer dropping, returning the cause. + /// Block until the broadcast ends, by [`Producer::close`] or every producer dropping. + /// + /// Returns [`Error::Dropped`], or the error passed to the deprecated `abort`. pub async fn closed(&self) -> Error { kio::wait(|waiter| self.poll_closed(waiter)).await } - /// Poll until the broadcast closes; ready with the cause: the error passed to - /// [`Producer::abort`], or [`Error::Dropped`] for a [`Producer::finish`] or a - /// dropped producer (check [`Consumer::is_finished`] to tell those apart). + /// Poll-based variant of [`Self::closed`]. pub fn poll_closed(&self, waiter: &kio::Waiter) -> Poll { ready!(self.alive.token.poll_closed(waiter)); Poll::Ready(self.state.read().abort.clone().unwrap_or(Error::Dropped)) @@ -719,21 +710,6 @@ impl Dynamic { } } -impl Drop for Dynamic { - fn drop(&mut self) { - // Decrement and reject under one lock, so a `track` call that saw a live - // handler through the same lock can't slip a request past the rejection. - let mut state = self.state.lock(); - if state.requests.remove_handler() { - // No handlers left to fulfill pending requests; reject them so consumers - // don't block forever on tracks nobody will serve. - for request in state.requests.drain_queued() { - request.reject(Error::Dropped); - } - } - } -} - #[cfg(test)] use futures::FutureExt; @@ -753,6 +729,10 @@ impl Dynamic { } /// Subscribe to arbitrary broadcast/tracks. +/// +/// Its close signal means this broadcast object ended, not that the path went +/// offline: announcements say whether a path is live, and a new publisher may +/// announce the same path again. pub struct Consumer { info: Arc, // Broadcast liveness (read-only): watched for close. @@ -806,6 +786,8 @@ impl Consumer { } /// Get a handle to a track on this broadcast. + /// + /// Fails with [`Error::Unroutable`] once the broadcast has ended. pub fn track(&self, name: &str) -> Result { // Rebind the track to *this* handle's view of the broadcast, so a catalog track // resolves its relative references against the path we were handed out at rather @@ -816,16 +798,17 @@ impl Consumer { } fn track_inner(&self, name: &str) -> Result { - // A closed broadcast (every producer and handler gone) serves nothing. - if self.is_closed() { - return Err(Error::Dropped); - } - let mut state = self.state.lock(); + // An ended broadcast serves nothing new, not even a track it still has: + // the lookup answers what a fresh `request_broadcast` for the path would. + // Tracks already handed out are untouched. + if state.closing { + return Err(Error::Unroutable); + } + // A route-fed broadcast mints spliced logical tracks: they outlive any // session, and a route is asked (via the pending queue) to start serving. - let closing = state.closing; if let Some(spliced) = state.spliced.as_mut() { // An aborted logical track is a verdict from the sources attached at // the time, not a property of the name: a publisher that had not yet @@ -850,11 +833,6 @@ impl Consumer { producer.consume(), )); } - // A deliberately-ended broadcast serves nothing new; nothing drains the - // pending queue once the front is torn down. - if closing { - return Err(Error::NotFound); - } let name: Arc = name.into(); let producer = super::resume::Producer::new(); let consumer = producer.consume(); @@ -882,12 +860,6 @@ impl Consumer { return Ok(pending.consume()); } - // A deliberately-ended broadcast serves nothing new; existing tracks above - // stay readable so consumers can drain the cache. - if state.closing { - return Err(Error::NotFound); - } - // Allocate the name once and share the same Arc across the request, the // requests map, and the FIFO order. The request inherits the broadcast's // cache pool through its `Arc`, same as a producer-created track. @@ -919,32 +891,28 @@ impl Consumer { } } - /// Block until the broadcast is closed, by [`Producer::finish`], - /// [`Producer::abort`], or every producer dropping, and return the cause. + /// Block until the broadcast ends, by [`Producer::close`] or every producer dropping. /// - /// Returns the error passed to [`Producer::abort`], or [`Error::Dropped`] for a - /// [`Producer::finish`] or a dropped producer (check [`Self::is_finished`] to - /// tell those apart). + /// Returns [`Error::Dropped`], or the error passed to the deprecated `abort`. pub async fn closed(&self) -> Error { self.alive.closed().await; self.state.read().abort.clone().unwrap_or(Error::Dropped) } - /// Returns true if every [`Producer`] has been dropped. + /// Returns true once the broadcast has ended. pub fn is_closed(&self) -> bool { self.alive.is_closed() } - /// Whether the broadcast is on its way out: deliberately ended (finish/abort - /// marked, even while handles remain) or already fully closed. The origin's + /// Whether the broadcast has ended, observed under the state lock. The origin's /// dispatcher treats a rejection from such a source as imminent detach rather /// than a strike. pub(crate) fn is_closing(&self) -> bool { - self.is_closed() || self.state.read().closing + self.state.read().closing } - /// Whether the broadcast ended via a deliberate [`Producer::finish`], as opposed - /// to aborting or losing its producer. `false` while the broadcast is still live. + #[doc(hidden)] + #[deprecated(note = "a broadcast end carries no cause")] pub fn is_finished(&self) -> bool { self.state.read().finished } @@ -1188,7 +1156,7 @@ mod test { assert!(!demand.is_used()); // Every producer gone: both edges report the closure. - producer.finish(); + producer.close(); assert!(matches!(demand.used().await, Err(Error::Dropped))); assert!(matches!(demand.unused().await, Err(Error::Dropped))); } @@ -1305,31 +1273,47 @@ mod test { track1c.assert_not_closed(); } - /// `closed()` reports the cause: the abort error, or `Dropped` for a finish or - /// a dropped producer, with `is_finished` telling the latter two apart. + /// `close()` ends the broadcast for every clone at once, and a second close is a no-op. #[tokio::test] - async fn closed_cause() { - // Abort: the error comes through, and it isn't a finish. + async fn close_ends_every_clone() { + let producer = Info::new().produce(); + let clone = producer.clone(); + let consumer = producer.consume(); + + producer.close(); + assert!(matches!(consumer.closed().await, Error::Dropped)); + assert!(matches!(consumer.track("video"), Err(Error::Unroutable))); + assert!(matches!(clone.consume().track("video"), Err(Error::Unroutable))); + + clone.close(); + producer.close(); + } + + /// Dropping the last producer ends the broadcast exactly like `close()`. + #[tokio::test] + async fn drop_ends_like_close() { + let producer = Info::new().produce(); + let consumer = producer.consume(); + drop(producer); + assert!(matches!(consumer.closed().await, Error::Dropped)); + assert!(matches!(consumer.track("video"), Err(Error::Unroutable))); + } + + /// The deprecated end APIs keep their old causes until they are removed. + #[tokio::test] + #[allow(deprecated)] + async fn deprecated_end_causes() { let producer = Info::new().produce(); let consumer = producer.consume(); producer.abort(Error::Timeout).unwrap(); assert!(matches!(consumer.closed().await, Error::Timeout)); assert!(!consumer.is_finished()); - // Finish: a deliberate clean end. let producer = Info::new().produce(); let consumer = producer.consume(); producer.finish(); assert!(matches!(consumer.closed().await, Error::Dropped)); assert!(consumer.is_finished()); - - // Plain drop: neither aborted nor finished. - let producer = Info::new().produce(); - let consumer = producer.consume(); - // Deliberate for the test: exercises the accidental-drop path (warns). - drop(producer); - assert!(matches!(consumer.closed().await, Error::Dropped)); - assert!(!consumer.is_finished()); } #[tokio::test] @@ -1503,24 +1487,25 @@ mod test { } /// A reserved name nobody accepts is the parking case a publisher has to be able to - /// end. Ending the broadcast is where it does: `Consumer::track` already answers - /// `NotFound` for a name asked about after this point, so whoever asked earlier gets + /// end. Ending the broadcast is where it does: `Consumer::track` answers + /// `Unroutable` for a name asked about after this point, so whoever asked earlier gets /// the same answer instead of waiting on info that can never arrive. #[tokio::test] - async fn finish_resolves_a_reserved_name() { + async fn close_resolves_a_reserved_name() { let producer = Info::new().produce(); let consumer = producer.consume(); let _request = producer.reserve_track("track1").unwrap(); let pending = subscribe_pending!(consumer, "track1"); - producer.finish(); - assert!(matches!(pending.await, Err(Error::NotFound))); + producer.close(); + assert!(matches!(pending.await, Err(Error::Unroutable))); } - /// An abort says why the broadcast ended, and an unserved name resolves with that - /// reason rather than a generic failure. + /// The deprecated abort says why the broadcast ended, and an unserved name resolves + /// with that reason. #[tokio::test] + #[allow(deprecated)] async fn abort_resolves_a_reserved_name_with_its_reason() { let producer = Info::new().produce(); let consumer = producer.consume(); @@ -1539,23 +1524,51 @@ mod test { /// A request still queued for a handler is the same parking case reached from the /// consumer side, so it ends the same way. #[tokio::test] - async fn finish_resolves_a_queued_request() { + async fn close_resolves_a_queued_request() { let producer = Info::new().produce(); let dynamic = producer.dynamic(); let consumer = dynamic.consume(); let pending = subscribe_pending!(consumer, "track1"); - producer.finish(); - assert!(matches!(pending.await, Err(Error::NotFound))); + producer.close(); + assert!(matches!(pending.await, Err(Error::Unroutable))); + drop(dynamic); + } + + /// A queued request answers the same when the broadcast ends by its last handle + /// dropping, even when that handle is the `Dynamic` that would have served it. + #[tokio::test] + async fn dropping_the_last_handle_resolves_a_queued_request() { + let dynamic = Info::new().produce().dynamic(); + let consumer = dynamic.consume(); + + let pending = subscribe_pending!(consumer, "track1"); + drop(dynamic); + assert!(matches!(pending.await, Err(Error::Unroutable))); + } + + /// With a producer still alive, losing the last handler is not the broadcast ending: + /// the queued request fails as `Dropped`. + #[tokio::test] + async fn dropping_the_last_handler_resolves_a_queued_request_dropped() { + let producer = Info::new().produce(); + let dynamic = producer.dynamic(); + let consumer = dynamic.consume(); + + let pending = subscribe_pending!(consumer, "track1"); + + drop(dynamic); + assert!(matches!(pending.await, Err(Error::Dropped))); + producer.close(); } /// A request a handler already took is the handler's to answer: it may be in flight /// to a peer, and a retraction does not disturb subscriptions already in flight. /// Whatever the handler decides still reaches the consumer. #[tokio::test] - async fn finish_leaves_a_claimed_request_to_its_handler() { + async fn close_leaves_a_claimed_request_to_its_handler() { let producer = Info::new().produce(); let mut dynamic = producer.dynamic(); let consumer = dynamic.consume(); @@ -1565,14 +1578,14 @@ mod test { let dropped = subscribe_pending!(consumer, "track2"); let abandoned = dynamic.requested_track().await.unwrap(); - producer.finish(); + producer.close(); assert!( accepted.poll_ok(&kio::Waiter::noop()).is_pending(), - "finish rejected a claimed request" + "close rejected a claimed request" ); assert!( dropped.poll_ok(&kio::Waiter::noop()).is_pending(), - "finish rejected a claimed request" + "close rejected a claimed request" ); let _track = request.accept(None); @@ -1583,11 +1596,11 @@ mod test { } /// A reverse fetch can install the track metadata before the live request is - /// accepted, but it does not create a live publisher. Finishing the broadcast + /// accepted, but it does not create a live publisher. Closing the broadcast /// must still reject that name so an arrival-order subscriber does not park on /// backfill that is deliberately absent from its queue. #[tokio::test] - async fn finish_resolves_an_unaccepted_track_with_fetched_info() { + async fn close_resolves_an_unaccepted_track_with_fetched_info() { let producer = Info::new().produce(); let consumer = producer.consume(); @@ -1601,24 +1614,25 @@ mod test { pending_fetch.await.unwrap(); let mut subscriber = track.subscribe(None).await.unwrap(); - producer.finish(); - assert!(matches!(subscriber.recv_group().await, Err(Error::NotFound))); + producer.close(); + assert!(matches!(subscriber.recv_group().await, Err(Error::Unroutable))); let stale = request.accept(None); assert!(stale.append_group().is_err()); } /// Ending the broadcast doesn't cascade into a track someone is publishing: it keeps - /// its cache and its publisher decides when it ends. + /// its cache and its publisher decides when it ends. Only a new lookup is refused. #[tokio::test] - async fn finish_spares_a_served_track() { + async fn close_spares_a_served_track() { let producer = Info::new().produce(); let consumer = producer.consume(); let track = producer.create_track("track1", None).unwrap(); let mut subscriber = consumer.track("track1").unwrap().subscribe(None).await.unwrap(); - producer.finish(); + producer.close(); + assert!(matches!(consumer.track("track1"), Err(Error::Unroutable))); track.append_group().unwrap(); subscriber.assert_group(); @@ -1627,22 +1641,22 @@ mod test { /// The publisher may still be holding the `track::Request` for a name the broadcast /// just gave up on. Accepting it afterwards must not resurrect the track, or a - /// subscriber that was told `NotFound` could be contradicted by a later one. + /// subscriber that was told `Unroutable` could be contradicted by a later one. #[tokio::test] - async fn finish_leaves_a_stale_reservation_inert() { + async fn close_leaves_a_stale_reservation_inert() { let producer = Info::new().produce(); let consumer = producer.consume(); let request = producer.reserve_track("track1").unwrap(); let pending = subscribe_pending!(consumer, "track1"); - producer.finish(); - assert!(matches!(pending.await, Err(Error::NotFound))); + producer.close(); + assert!(matches!(pending.await, Err(Error::Unroutable))); let track = request.accept(None); assert!(track.append_group().is_err()); let mut subscriber = track.subscribe(None); - assert!(matches!(subscriber.recv_group().await, Err(Error::NotFound))); + assert!(matches!(subscriber.recv_group().await, Err(Error::Unroutable))); assert!(consumer.track("track1").is_err()); } @@ -1659,7 +1673,7 @@ mod test { drop(request); assert!(matches!(pending.await, Err(Error::Dropped))); - producer.finish(); + producer.close(); } /// `track::Request::reject` carries its reason the same way, which is what lets a @@ -1674,7 +1688,7 @@ mod test { request.reject(Error::NotFound); assert!(matches!(pending.await, Err(Error::NotFound))); - producer.finish(); + producer.close(); } /// The interleave every unused-driven teardown has to survive: a wire subscriber @@ -1711,7 +1725,7 @@ mod test { assert!(track.abort_unused(Error::Cancel).is_ok()); assert!(matches!(consumer.track("video"), Err(Error::NotFound))); - producer.finish(); + producer.close(); } #[test] @@ -1724,6 +1738,6 @@ mod test { track.clone().abort(Error::Cancel).unwrap(); assert!(!track.is_used()); assert!(track.abort_unused(Error::Cancel).is_ok()); - producer.finish(); + producer.close(); } } diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 52c06cc5f6..ae1c473d21 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -1237,11 +1237,8 @@ impl Producer { /// polled. Register a [`broadcast::Producer::dynamic`] handler before /// announcing, so the first consumer finds the tracks it serves. /// - /// End the broadcast with [`broadcast::Producer::finish`]; dropping it - /// without finishing also works, but logs a warning. Either way the path - /// closes once it was the last source; an unfinished drop additionally aborts - /// the spliced tracks with an error, so consumers observe a failure rather - /// than a clean end. + /// End the broadcast with [`broadcast::Producer::close`] or by dropping it; + /// either way the path closes once it was the last source. /// /// Fails with [`Error::Unauthorized`] if `path` is outside the prefixes this /// producer may publish under (after [`scope`](Self::scope)), @@ -1271,7 +1268,7 @@ impl Producer { // The broadcast is a route table entry at its exact path from the start, // hidden from cursors and requests until it announces. The entry lives - // as long as the broadcast: its announcer drops on finish, abort, or the + // as long as the broadcast: its announcer drops on close, abort, or the // last handle. let announcing = Announcing { hop: self.hop, @@ -2286,7 +2283,7 @@ async fn run_front(task: FrontTask) { // subscriptions already in flight): dropping their producers // leaves each reader on the copy it was spliced from, ending // when and as that copy ends. - broadcast.finish(); + broadcast.close(); broadcast.release_spliced(err.clone()); for (_, mut io) in tracks.drain() { // A reader still waiting on its source's answer is in flight @@ -4192,7 +4189,7 @@ mod tests { broadcast.announce(Route::default()).unwrap(); announced.assert_next_active("room/alice"); peer.assert_next_active("room/alice"); - broadcast.finish(); + broadcast.close(); announced.assert_next_ended("room/alice"); peer.assert_next_ended("room/alice"); assert!(matches!(broadcast.announce(Route::default()), Err(Error::Closed))); @@ -6123,9 +6120,9 @@ mod tests { assert!(again.is_clone(&resolved)); // Losing one source keeps the front alive; losing both closes it. - first.finish(); + first.close(); settle(|| consumer.get_broadcast("room/alice").is_some()).await; - second.finish(); + second.close(); settle(|| consumer.get_broadcast("room/alice").is_none()).await; // The path is free again for a fresh broadcast. @@ -6161,7 +6158,7 @@ mod tests { group.finish().unwrap(); track.finish().unwrap(); drop(track); - broadcast.finish(); + broadcast.close(); let mut group = next_group(&mut subscription) .await @@ -6206,7 +6203,7 @@ mod tests { // ANNOUNCE_END overtakes the track's end: the route is retracted, and the front // has acted on it, before the track's last group and end arrive. - source.finish(); + source.close(); drop(server); settle(|| resolved.is_closed()).await; let mut group = track.append_group().unwrap(); @@ -6508,7 +6505,7 @@ mod tests { // The incumbent leaving exhausts the table: the refusal is never retried. drop(track); - first.finish(); + first.close(); assert!(matches!(subscription.recv_group().await, Err(Error::Unsupported))); } @@ -6707,7 +6704,7 @@ mod tests { // Publishing through the nested view lands where the root says. let broadcast = nested.create_broadcast("room/chat/live").unwrap(); assert!(producer.consume().get_broadcast("room/chat/live").is_some()); - broadcast.finish(); + broadcast.close(); } #[test] diff --git a/rs/moq-net/tests/broadcast_close.rs b/rs/moq-net/tests/broadcast_close.rs new file mode 100644 index 0000000000..fbe94a1e78 --- /dev/null +++ b/rs/moq-net/tests/broadcast_close.rs @@ -0,0 +1,72 @@ +//! `broadcast::Producer::close()` ends a broadcast the same way for a local consumer +//! and a remote one: the handle closes, every new track lookup answers +//! [`Error::Unroutable`](moq_net::Error::Unroutable), a fresh request for the path +//! answers the same, and the broadcast can never be announced again. + +mod support; + +use std::time::Duration; + +use moq_net::{Error, Hop, Version}; +use support::harness::{MockConnectOptions, connect_mock}; + +const TIMEOUT: Duration = Duration::from_secs(10); + +fn produce_origin(hop: u64) -> moq_net::origin::Producer { + let (producer, driver) = moq_net::origin::Producer::new(moq_net::origin::Config::new(Hop::new(hop).unwrap())); + tokio::spawn(support::harness::run(driver)); + producer +} + +/// Close a published broadcast and check what `reader`'s origin answers afterwards. +async fn close_then_lookup(publisher: moq_net::origin::Producer, reader: moq_net::origin::Producer) { + let broadcast = publisher.create_broadcast("bcast").unwrap(); + let _track = broadcast.create_track("video", None).unwrap(); + broadcast.announce(Default::default()).unwrap(); + + let consumer = reader.consume(); + tokio::time::timeout(TIMEOUT, consumer.routed("bcast")) + .await + .expect("announce timeout") + .expect("routed"); + let handle = tokio::time::timeout(TIMEOUT, consumer.request_broadcast("bcast")) + .await + .expect("resolve timeout") + .expect("broadcast resolves"); + handle.track("video").expect("a live broadcast serves its track"); + + broadcast.close(); + + tokio::time::timeout(TIMEOUT, handle.closed()) + .await + .expect("the handle never closed"); + assert!(matches!(handle.track("video"), Err(Error::Unroutable))); + assert!(matches!(handle.track("audio"), Err(Error::Unroutable))); + assert!(matches!( + consumer.request_broadcast("bcast").await, + Err(Error::Unroutable) + )); + assert!(matches!(broadcast.announce(Default::default()), Err(Error::Closed))); +} + +#[tokio::test] +async fn close_ends_a_local_consumer() { + tokio::time::pause(); + let origin = produce_origin(1); + close_then_lookup(origin.clone(), origin).await; +} + +#[tokio::test] +async fn close_ends_a_remote_consumer() { + tokio::time::pause(); + for version in ["moq-lite-05", "moq-transport-14", "moq-transport-19"] { + let publisher = produce_origin(1); + let subscriber = produce_origin(2); + let mut options = MockConnectOptions::new(version.parse::().unwrap()); + options.server_publish = Some(publisher.consume()); + options.client_subscribe = Some(subscriber.clone()); + let _pair = connect_mock(options).await; + + close_then_lookup(publisher, subscriber).await; + } +} diff --git a/rs/moq-net/tests/finished_broadcast_mock.rs b/rs/moq-net/tests/finished_broadcast_mock.rs index 4c987f9317..9f2495055a 100644 --- a/rs/moq-net/tests/finished_broadcast_mock.rs +++ b/rs/moq-net/tests/finished_broadcast_mock.rs @@ -90,7 +90,7 @@ async fn round(finish_broadcast: bool) -> (Vec>, Option) track.finish().unwrap(); drop(track); if finish_broadcast { - broadcast.finish(); + broadcast.close(); } // The session and both origins stay up until the reader is done. diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index b9ba56453f..a70de866bf 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -1555,9 +1555,9 @@ impl Cluster { } } - // Deliberate shutdown: finishing the registration retracts the route. + // Deliberate shutdown: closing the registration retracts the route. if let Some(registration) = self_registration.as_mut() { - registration.finish(); + registration.close(); } Ok(()) } diff --git a/rs/moq-rtc/src/server/mod.rs b/rs/moq-rtc/src/server/mod.rs index 18e0df6831..580b22f671 100644 --- a/rs/moq-rtc/src/server/mod.rs +++ b/rs/moq-rtc/src/server/mod.rs @@ -88,10 +88,10 @@ impl AcceptedSession { } _ = cancel => { tracing::debug!(role = self.role, "webrtc session terminated by DELETE"); - // A deliberate end: finish the broadcast so the origin + // A deliberate end: close the broadcast so the origin // unannounces it immediately. if let Some(broadcast) = self.broadcast.take() { - broadcast.finish(); + broadcast.close(); } Ok(()) } diff --git a/rs/moq-rtmp/src/dial.rs b/rs/moq-rtmp/src/dial.rs index 4e987e63e1..1a0572b5ad 100644 --- a/rs/moq-rtmp/src/dial.rs +++ b/rs/moq-rtmp/src/dial.rs @@ -485,11 +485,11 @@ async fn client_handshake(stream: &mut S) -> anyhow::Result> /// An active pull: the moq-mux FLV importer publishing into the origin. Mirrors the /// server's publisher; either [`Self::finish`] or dropping it unannounces the -/// path, the former without the dropped-without-finish warning. +/// path. struct Publisher { importer: FlvImport, - // A clone of the importer's producer, so a deliberate end can finish() the - // broadcast (prompt unannounce) even though the importer owns it. + // A clone of the importer's producer, so an end can close the broadcast + // (prompt unannounce) even though the importer owns it. broadcast: moq_net::broadcast::Producer, } @@ -522,16 +522,17 @@ impl Publisher { fn finish(&mut self) -> anyhow::Result<()> { self.importer.finish()?; - self.broadcast.finish(); + self.broadcast.close(); Ok(()) } /// Abort the published tracks with `err` so subscribers see the real cause /// (the remote dropped, a protocol error) rather than a generic `Error::Dropped`. /// - /// Consumes the publisher: the broadcast is done. + /// Consumes the publisher and closes the broadcast. fn abort(self, err: moq_net::Error) { self.importer.abort(err); + self.broadcast.close(); } } diff --git a/rs/moq-rtmp/src/server.rs b/rs/moq-rtmp/src/server.rs index 9087b93a4b..a4ed3c0094 100644 --- a/rs/moq-rtmp/src/server.rs +++ b/rs/moq-rtmp/src/server.rs @@ -1301,11 +1301,11 @@ async fn run_handshake(stream: &mut S, peer: SocketAddr) -> anyhow::R /// An active publish: the moq-mux FLV importer, which owns the origin-created /// [`BroadcastProducer`](moq_net::broadcast::Producer) it publishes into. /// Either [`Self::finish`] or dropping it closes the broadcast and unannounces -/// the path, the former without the dropped-without-finish warning. +/// the path. struct Publisher { importer: FlvImport, - // A clone of the importer's producer, so a deliberate end can finish() the - // broadcast (prompt unannounce) even though the importer owns it. + // A clone of the importer's producer, so an end can close the broadcast + // (prompt unannounce) even though the importer owns it. broadcast: moq_net::broadcast::Producer, } @@ -1343,7 +1343,7 @@ impl Publisher { /// the broadcast so the origin unannounces it immediately. fn finish(&mut self) -> anyhow::Result<()> { self.importer.finish()?; - self.broadcast.finish(); + self.broadcast.close(); Ok(()) } @@ -1351,9 +1351,10 @@ impl Publisher { /// (the client disconnected, a protocol error) rather than a generic /// `Error::Dropped` from the importer being dropped. /// - /// Consumes the publisher: the broadcast is done. + /// Consumes the publisher and closes the broadcast. fn abort(self, err: moq_net::Error) { self.importer.abort(err); + self.broadcast.close(); } } diff --git a/rs/moq-srt/src/ts.rs b/rs/moq-srt/src/ts.rs index baafbb3ef1..8e6ccf2abc 100644 --- a/rs/moq-srt/src/ts.rs +++ b/rs/moq-srt/src/ts.rs @@ -21,14 +21,14 @@ use crate::Result; /// transport packets and retains any partial trailing packet internally for the /// next call (the same pattern `moq-cli import ... stdin ts` uses against stdin). /// Either [`Self::finish`] or dropping the publisher ends the broadcast and -/// unannounces the path, the former without the dropped-without-finish warning. +/// unannounces the path. pub struct Publisher { // TS carries undecoded elementary streams (SCTE-35, teletext, DVB AC-3, ...) // verbatim, so the importer uses the `mpegts` catalog extension rather than the // media-only `()`, which would route those PIDs to `Stream::Ignored` and drop them. importer: ts::Import, - // A clone of the importer's producer, so a deliberate end can finish() the - // broadcast (prompt unannounce) even though the importer owns it. + // A clone of the importer's producer, so an end can close the broadcast + // (prompt unannounce) even though the importer owns it. broadcast: moq_net::broadcast::Producer, } @@ -65,16 +65,17 @@ impl Publisher { /// the broadcast so the origin unannounces it immediately. pub fn finish(&mut self) -> Result<()> { self.importer.finish().map_err(moq_mux::Error::from)?; - self.broadcast.finish(); + self.broadcast.close(); Ok(()) } /// Abort the published tracks with `err` so subscribers see the real cause /// (the SRT caller dropped, a demux error) rather than a generic `Error::Dropped`. /// - /// Consumes the publisher: the broadcast is done. + /// Consumes the publisher and closes the broadcast. pub fn abort(self, err: moq_net::Error) { self.importer.abort(err); + self.broadcast.close(); } } diff --git a/rs/moq-stats/src/produce.rs b/rs/moq-stats/src/produce.rs index e6d860851a..7cd482b042 100644 --- a/rs/moq-stats/src/produce.rs +++ b/rs/moq-stats/src/produce.rs @@ -852,12 +852,11 @@ impl GroupPublisher { self.sessions.adopt_parked(&self.broadcast, &mut self.requested); } - /// Deliberately end the broadcast: finish every pair, then the broadcast - /// itself, so teardown emits no dropped-without-finish warnings. + /// Deliberately end the broadcast: finish every pair, then close the broadcast. fn finish(mut self) { self.traffic.finish(); self.sessions.finish(); - self.broadcast.finish(); + self.broadcast.close(); } } diff --git a/rs/moq-tokio/examples/chat.rs b/rs/moq-tokio/examples/chat.rs index 2e78dc5881..98241f9e5d 100644 --- a/rs/moq-tokio/examples/chat.rs +++ b/rs/moq-tokio/examples/chat.rs @@ -83,10 +83,8 @@ async fn run_broadcast(origin: moq_net::origin::Producer) -> anyhow::Result<()> // Sleep before exiting and closing the broadcast. tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; - // Cleanly close the broadcast so subscribers see a normal end. Dropping the - // producer works too, but closing is explicit (and dropping it by accident - // while still publishing is a common bug this makes obvious). - broadcast.finish(); + // End the broadcast. Dropping the producer does the same; closing is explicit. + broadcast.close(); Ok(()) } diff --git a/rs/moq-tokio/examples/clock.rs b/rs/moq-tokio/examples/clock.rs index 42912d89c1..c792195209 100644 --- a/rs/moq-tokio/examples/clock.rs +++ b/rs/moq-tokio/examples/clock.rs @@ -84,9 +84,8 @@ async fn main() -> anyhow::Result<()> { _ = clock.run() => Ok(()), }; - // Cleanly close the broadcast on exit so subscribers see a normal end - // rather than Error::Dropped. - broadcast.finish(); + // End the broadcast on exit. + broadcast.close(); result } Command::Subscribe => { diff --git a/rs/moq-tokio/src/origin.rs b/rs/moq-tokio/src/origin.rs index b2911dfba5..ac350fff45 100644 --- a/rs/moq-tokio/src/origin.rs +++ b/rs/moq-tokio/src/origin.rs @@ -39,7 +39,7 @@ mod tests { assert_eq!(update.prefix.as_str(), "cam"); assert!(update.kind.is_active()); - broadcast.finish(); + broadcast.close(); let update = announced.next().await.expect("retraction"); assert!(!update.kind.is_active()); } diff --git a/rs/moq-tokio/tests/broadcast.rs b/rs/moq-tokio/tests/broadcast.rs index 203cfaae4c..23a16c1fde 100644 --- a/rs/moq-tokio/tests/broadcast.rs +++ b/rs/moq-tokio/tests/broadcast.rs @@ -789,7 +789,7 @@ async fn announce_lifecycle_test(version: &str) { // Unannounce: retracted by announce id on the wire. Dropping the announcement // retracts the route; the broadcast's own end is independent. - second.finish(); + second.close(); let update = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "room/second"); assert!(!update.kind.is_active(), "expected retraction"); @@ -804,7 +804,7 @@ async fn announce_lifecycle_test(version: &str) { // Replace the route at "first": retract the original (retiring its announce // id on the wire), then announce the same path again (assigning a fresh id). // Await the retraction first so the events cannot coalesce away. - first.finish(); + first.close(); let update = next_announce(&mut announcements).await; assert_eq!(update.prefix.as_str(), "room/first"); assert!(!update.kind.is_active(), "expected the replaced retraction"); diff --git a/rs/moq-transcode/src/lib.rs b/rs/moq-transcode/src/lib.rs index 122591eacf..99e1f1fa84 100644 --- a/rs/moq-transcode/src/lib.rs +++ b/rs/moq-transcode/src/lib.rs @@ -199,7 +199,7 @@ impl Transcoder { tasks.shutdown().await; derived.finish()?; - output.finish(); + output.close(); Ok(()) } } diff --git a/swift/Sources/Moq/Broadcast.swift b/swift/Sources/Moq/Broadcast.swift index cf52ddba1b..15a29613c6 100644 --- a/swift/Sources/Moq/Broadcast.swift +++ b/swift/Sources/Moq/Broadcast.swift @@ -344,8 +344,15 @@ public final class BroadcastProducer: Sendable { try ffi.removeCatalogSection(name: name) } - /// Finish the broadcast, finalizing the catalog stream. + /// End the broadcast for good: retract it and serve no new tracks. + /// + /// Tracks already subscribed carry on to their own end. Closing again is a no-op. + public func close() throws { + try ffi.close() + } + + @available(*, deprecated, renamed: "close", message: "A broadcast end carries no cause.") public func finish() throws { - try ffi.finish() + try ffi.close() } } diff --git a/swift/Sources/Moq/Origin.swift b/swift/Sources/Moq/Origin.swift index 6a5f4fe2d8..8c8aec69b6 100644 --- a/swift/Sources/Moq/Origin.swift +++ b/swift/Sources/Moq/Origin.swift @@ -34,10 +34,8 @@ public final class OriginProducer: Sendable { /// /// The broadcast is invisible and unroutable, for this origin's consumers /// and peers alike, until `BroadcastProducer.announce(route:)`. Announce it - /// after populating tracks. `finish()` - /// unpublishes immediately, while releasing the producer without finishing - /// also unpublishes but reads to subscribers as a failure rather than a - /// deliberate end. + /// after populating tracks. `BroadcastProducer.close()` ends it for good; + /// releasing the last handle does the same. public func createBroadcast(path: String) throws -> BroadcastProducer { BroadcastProducer(try ffi.createBroadcast(path: path)) } diff --git a/swift/Tests/MoqTests/SmokeTests.swift b/swift/Tests/MoqTests/SmokeTests.swift index 84fa239c41..38485871e1 100644 --- a/swift/Tests/MoqTests/SmokeTests.swift +++ b/swift/Tests/MoqTests/SmokeTests.swift @@ -135,7 +135,14 @@ final class SmokeTests: XCTestCase { let track = try broadcast.publishTrack(name: "events") XCTAssertEqual(try track.name, "events") try track.finish() - try broadcast.finish() + try broadcast.close() + } + + func testBroadcastCloseTwiceIsNoop() throws { + let broadcast = try BroadcastProducer() + try broadcast.close() + try broadcast.close() + XCTAssertThrowsError(try broadcast.publishTrack(name: "events")) } func testVideoHintsReachMediaPublishApi() throws { @@ -148,13 +155,13 @@ final class SmokeTests: XCTestCase { ) let media = try broadcast.publishVideo(format: .avc3, hint: hint) try media.finish() - try broadcast.finish() + try broadcast.close() } func testVideoPropertiesUseDefaultedFields() throws { let broadcast = try BroadcastProducer() try broadcast.setVideoProperties(VideoProperties(rotation: 315)) - try broadcast.finish() + try broadcast.close() } func testBroadcastConsumerFetchesCachedGroup() async throws { @@ -199,7 +206,7 @@ final class SmokeTests: XCTestCase { consumer.cancel() try producer.finish() - try broadcast.finish() + try broadcast.close() } func testJsonStreamRoundTrip() async throws { @@ -220,7 +227,7 @@ final class SmokeTests: XCTestCase { consumer.cancel() try producer.finish() - try broadcast.finish() + try broadcast.close() } func testJsonProducersReportDemand() async throws { @@ -244,7 +251,7 @@ final class SmokeTests: XCTestCase { try await snapshotDemand.unused() try await streamDemand.unused() - try broadcast.finish() + try broadcast.close() } func testRawTrackTimestamps() async throws { @@ -270,7 +277,7 @@ final class SmokeTests: XCTestCase { XCTAssertEqual(groupFrame?.timestampUs, 23_456) try track.finish() - try broadcast.finish() + try broadcast.close() } func testReadFrameSkipsEmptyThenPopulatedGroups() async throws { @@ -287,7 +294,7 @@ final class SmokeTests: XCTestCase { XCTAssertEqual(frame?.timestampUs, 2_000) try track.finish() - try broadcast.finish() + try broadcast.close() } func testSparseGroupsAndKnownEnd() throws { @@ -301,7 +308,7 @@ final class SmokeTests: XCTestCase { try track.createGroup(sequence: 4).finish() XCTAssertThrowsError(try track.createGroup(sequence: 5)) try track.finish() - try broadcast.finish() + try broadcast.close() } /// `frameDurationUs` is microseconds so Opus' 2.5 ms frame is expressible at @@ -331,7 +338,7 @@ final class SmokeTests: XCTestCase { XCTFail("2 ms is not an opus frame duration: \(error)") } - try broadcast.finish() + try broadcast.close() } /// The decode side picks its CPU layout: an unset `format` is I420, and RGBA @@ -388,7 +395,7 @@ final class SmokeTests: XCTestCase { XCTAssertTrue(stride(from: 3, to: frame.data.count, by: 4).allSatisfy { frame.data[$0] == 0xFF }) try video.finish() - try broadcast.finish() + try broadcast.close() } func testEncodeAudioWithOpusObject() throws { @@ -408,7 +415,7 @@ final class SmokeTests: XCTestCase { try producer.write(silence) XCTAssertEqual(try producer.name, "mic") try producer.finish() - try broadcast.finish() + try broadcast.close() } // Release the config before finishing: the producer retains what it needs. @@ -422,7 +429,7 @@ final class SmokeTests: XCTestCase { } try producer.write(silence) try producer.finish() - try broadcast.finish() + try broadcast.close() } } } From 2bf3e6fd650929ff91d5775454f6bf5154a6767a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 26 Sep 2026 10:28:07 -0700 Subject: [PATCH 45/45] quest: point origin-mount at auth/narrowing Co-Authored-By: Claude Opus 5.5 --- quest/m1/origin-mount.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quest/m1/origin-mount.md b/quest/m1/origin-mount.md index a2f5b667c7..efc512d6b8 100644 --- a/quest/m1/origin-mount.md +++ b/quest/m1/origin-mount.md @@ -67,5 +67,5 @@ source changed rather than scanning every mount or session. ## Related -- [Origin narrowing](/quest/m1/origin-narrowing.md) - the same origin/auth area; land them in sequence +- [Origin narrowing](/quest/m1/auth/narrowing.md) - the same origin/auth area; land them in sequence - `Cluster::admit` (#3943) - should carry mounts too