From 6198e57578a5903a3b44453d1ae4c202002509bb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 13 Aug 2026 21:47:09 -0700 Subject: [PATCH 01/12] fix(moq-mux): stop publishing audio spliced across a TS discontinuity (#2823) Co-authored-by: Claude Opus 5 Co-authored-by: Codex GPT-5.6 --- rs/moq-mux/src/container/ts/import.rs | 755 ++++++++++++++++++++++---- 1 file changed, 654 insertions(+), 101 deletions(-) diff --git a/rs/moq-mux/src/container/ts/import.rs b/rs/moq-mux/src/container/ts/import.rs index ee4c276de2..ca5fcc2d2f 100644 --- a/rs/moq-mux/src/container/ts/import.rs +++ b/rs/moq-mux/src/container/ts/import.rs @@ -66,6 +66,8 @@ pub struct Import { streams: HashMap>, /// In-progress PES reassembly, keyed by elementary PID. pending: HashMap, + /// Per elementary-stream-PID TS continuity state. + continuity: HashMap, /// True once a PMT with at least one supported stream has been parsed. initialized: bool, /// Raw 90 kHz PTS of the first audio frame in the current consecutive run. @@ -138,6 +140,7 @@ impl Import { pmt_pids: HashSet::new(), streams: HashMap::new(), pending: HashMap::new(), + continuity: HashMap::new(), initialized: false, audio_burst: None, scratch: Vec::new(), @@ -219,6 +222,43 @@ impl Import { self.si_section(pid, &pkt); continue; } + // An elementary stream's PES is as vulnerable to a break as a section is. A + // looping publisher wraps with its last PES still open and short of its declared + // length, so the next loop's leading packets would otherwise complete it and hand + // the codec one buffer straddling the cut. + if let Ok(pid) = Pid::new(pid) + && self.streams.contains_key(&pid) + { + match self.continuity.entry(pid).or_default().observe(&pkt) { + Continuation::Duplicate => continue, + // Flagged corrupt, so the packet joins the partial rather than opening a + // new PES out of bytes the demodulator already disowned. + Continuation::Corrupt => { + self.pending.remove(&pid); + if let Some(stream) = self.streams.get_mut(&pid) { + stream.desync(); + } + continue; + } + Continuation::Broken => { + // Salvage the truncated PES only where its bytes stand on their own, then + // drop whatever is left mid-unit and require the next frame to prove its + // boundary. + if self.streams.get(&pid).is_some_and(Stream::salvages_partial_pes) { + self.flush(pid)?; + } else { + self.pending.remove(&pid); + } + if let Some(stream) = self.streams.get_mut(&pid) { + stream.desync(); + } + // This packet still routes normally: a PUSI opens a fresh PES, while a + // continuation finds no pending entry and is dropped, so the stream + // resumes at the next PES start rather than mid-frame. + } + Continuation::Contiguous => {} + } + } // PIDs we don't decode and don't carry (`Stream::Ignored`: a base catalog's // undecoded streams, or an ambiguous 0x86 PID without CUEI) are dropped here, // not fed to the PES reader, which aborts on private sections (spec section 7: @@ -411,6 +451,7 @@ impl Import { self.initialized = true; } self.streams.insert(pid, stream); + self.continuity.entry(pid).or_default(); Ok(()) } @@ -441,6 +482,7 @@ impl Import { } // This PID is becoming section-framed; drop any partial PES a prior codec left pending. self.pending.remove(&pid); + self.continuity.remove(&pid); if !self.supports_mpegts { // Always route to Ignored, replacing any prior codec on this PID (a later PMT // can reassign it), so a private section never reaches the PES reader. Warn once. @@ -952,6 +994,89 @@ impl VerbatimStream { } } +/// Whether one PID's TS packets are still an unbroken chain, for whoever is accumulating +/// bytes out of them. +/// +/// Both reassemblers on this PID need the same answer: a section and a PES are equally +/// meaningless when spliced onto bytes that didn't follow them. Keeping one implementation +/// keeps a subtle rule (which packets advance the counter, which retransmissions to ignore) +/// from drifting into two. +#[derive(Default)] +struct Continuity { + /// Last continuity_counter seen on a packet with payload, to spot gaps. + last_cc: Option, + /// Last payload packet, to skip ISO 13818-1 duplicates (same cc, identical bytes). + last_pkt: Option<[u8; 188]>, +} + +/// What one packet says about the bytes already accumulated for its PID. +enum Continuation { + /// An exact retransmission, which ISO 13818-1 permits once. Ignore the packet entirely: + /// processing it would duplicate its bytes. + Duplicate, + /// Contiguous with the previous payload packet. + Contiguous, + /// A counter gap or a declared discontinuity. Whatever was accumulating for this PID is + /// lost, but this packet's own payload is intact and still worth routing. + Broken, + /// The demodulator flagged this packet corrupt. The partial is lost like [`Broken`], and + /// so is the packet: nothing in it can be trusted. + Corrupt, +} + +impl Continuity { + /// Classify one 188-byte packet, recording what the next call needs. + fn observe(&mut self, pkt: &[u8; 188]) -> Continuation { + // transport_error_indicator: the demodulator flagged this packet as corrupt, so its + // payload can't be trusted (and we don't validate CRC-32). Forgetting the counter + // keeps the next clean packet from also looking like a gap. + if pkt[1] & 0x80 != 0 { + self.last_cc = None; + self.last_pkt = None; + return Continuation::Corrupt; + } + + let afc = (pkt[3] >> 4) & 0x3; + let has_payload = afc & 0x1 != 0; + // Read the adaptation field before the no-payload case: a discontinuity can ride on + // an adaptation-only packet, and it counts just the same. + let discontinuity = if afc & 0x2 != 0 { + let af_len = pkt[4] as usize; + af_len > 0 && pkt[5] & 0x80 != 0 + } else { + false + }; + + if !has_payload { + if discontinuity { + self.last_cc = None; + self.last_pkt = None; + return Continuation::Broken; + } + return Continuation::Contiguous; + } + + // A retransmission repeats the counter, and so does a loss of exactly 15 packets. + // Only the bytes tell them apart, which is why the counter alone can't decide: taking + // every repeat for a duplicate would carry a partial straight across that loss and + // join it to unrelated bytes. + if self.last_pkt.as_ref().is_some_and(|last| last == pkt) { + return Continuation::Duplicate; + } + self.last_pkt = Some(*pkt); + + // Only payload packets advance the counter, so this is the one place it moves. + let cc = pkt[3] & 0x0f; + let cc_gap = matches!(self.last_cc, Some(last) if cc != (last + 1) & 0x0f); + self.last_cc = Some(cc); + if discontinuity || cc_gap { + Continuation::Broken + } else { + Continuation::Contiguous + } + } +} + /// Byte-level reassembler for MPEG-TS private sections on one PID. /// /// Private sections (SCTE-35 table_id 0xFC and others) are not PES. This handles @@ -965,69 +1090,40 @@ struct SectionReassembler { /// thus section_length) may not all be present yet, so completeness is /// re-checked as bytes arrive; empty means no section in progress. acc: Vec, - /// Last continuity_counter seen on a packet with payload, to spot gaps. - last_cc: Option, - /// Last payload packet, to skip ISO 13818-1 duplicates (same cc, identical bytes). - last_pkt: Option<[u8; 188]>, + /// Shared with the PES path: a broken chain drops the partial either way. + continuity: Continuity, } impl SectionReassembler { /// Consume one 188-byte TS packet, appending every completed section to `out`. fn push(&mut self, pkt: &[u8], out: &mut Vec>) { - // transport_error_indicator: the demodulator flagged this packet as corrupt, - // so its payload can't be trusted (and we don't validate CRC-32). Drop it and - // any partial; resync at the next clean PUSI. - if pkt[1] & 0x80 != 0 { - self.acc.clear(); - self.last_cc = None; - self.last_pkt = None; - return; + let pkt: &[u8; 188] = pkt.try_into().expect("section packet must be 188 bytes"); + match self.continuity.observe(pkt) { + Continuation::Duplicate => return, + // A packet flagged corrupt takes its own payload down with the partial: this is + // the one case where the packet itself must not be processed. + Continuation::Corrupt => { + self.acc.clear(); + return; + } + Continuation::Broken => self.acc.clear(), + Continuation::Contiguous => {} } let pusi = pkt[1] & 0x40 != 0; let afc = (pkt[3] >> 4) & 0x3; - let cc = pkt[3] & 0x0f; let has_payload = afc & 0x1 != 0; - // Parse the adaptation field before the no-payload early return: a - // discontinuity can ride on an adaptation-only packet and must still reset - // reassembly. let mut off = 4; - let mut discontinuity = false; if afc & 0x2 != 0 { let af_len = pkt[4] as usize; - discontinuity = af_len > 0 && pkt[5] & 0x80 != 0; off = 5 + af_len; } if !has_payload { - // An adaptation-only discontinuity still drops the partial; forgetting the - // counter keeps the next payload packet from looking like a gap. - if discontinuity { - self.acc.clear(); - self.last_cc = None; - self.last_pkt = None; - } return; } - // ISO 13818-1 permits one identical retransmission of a payload packet (same - // cc, same bytes); processing it would reset a healthy partial or re-emit a - // completed section. Skip it, recording this packet to catch the next. - if self.last_pkt.as_ref().is_some_and(|last| last[..] == pkt[..]) { - return; - } - self.last_pkt = pkt.try_into().ok(); - - // A continuity-counter gap (only payload packets advance it) or a declared - // discontinuity both mean the in-progress section is lost. - let cc_gap = matches!(self.last_cc, Some(last) if cc != (last + 1) & 0x0f); - let reset = discontinuity || cc_gap; - if reset { - self.acc.clear(); - } - self.last_cc = Some(cc); - if off >= pkt.len() { return; } @@ -1166,6 +1262,38 @@ impl Stream { } } + /// Whether a PES cut short by a break is still worth publishing. + /// + /// True where one PES carries many independently decodable units, so the ones ahead of + /// the cut are whole and correct on their own. False where it carries exactly one: half + /// an access unit is a picture with missing slices, and half a keyframe stays wrong for + /// every picture that references it. Verbatim payloads are all-or-nothing the same way. + fn salvages_partial_pes(&self) -> bool { + match self { + Stream::Aac(_) | Stream::Legacy(_) => true, + // Opus carries many packets per PES like the audio above, but its framing is + // declared rather than self-describing: a trailing packet cut short of the length + // its control header promises is a parse error, and that error would travel up out + // of `decode` and end the session. Losing the PES beats losing the broadcast. + Stream::Opus(_) => false, + Stream::H264 { .. } | Stream::H265 { .. } | Stream::Verbatim(_) => false, + Stream::Clock | Stream::Ignored => false, + } + } + + /// Sync was lost: drop whatever partial unit is held and stop vouching for the next + /// boundary. This is [`seek`](Self::seek) without the group-sequence side effect, for a + /// break the stream recovers from in place. + fn desync(&mut self) { + match self { + Stream::H264 { split, .. } => split.reset(), + Stream::H265 { split, .. } => split.reset(), + Stream::Aac(stream) => stream.desync(), + Stream::Legacy(stream) => stream.desync(), + Stream::Opus(_) | Stream::Verbatim(_) | Stream::Clock | Stream::Ignored => {} + } + } + fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { match self { Stream::H264 { split, import, .. } => { @@ -1236,6 +1364,11 @@ impl Stream { /// publish payload bytes as audio, and worse, each false positive would reset the budget /// below and keep a stream that never really parses scanning forever. /// +/// A frame joined out of a carried tail is confirmed the same way, even though the previous +/// frame vouched for where that tail begins. What it vouched for is the boundary, not the +/// bytes the next PES supplies, and a splice joins two unrelated halves whose seam a header +/// alone can't see. See [`needs_confirmation`](Self::needs_confirmation). +/// /// The budget is what keeps that from failing silently. A PID whose frames never parse /// (a PMT declaring a stream type the payload doesn't match) would otherwise scan /// forever, publishing nothing and holding its catalog reservation open, which withholds @@ -1247,6 +1380,9 @@ struct Resync { /// Whether the next frame comes from a scan rather than the previous frame's end, and /// so has to be confirmed before it can be published. unconfirmed: bool, + /// End of stream: nothing more can arrive to confirm anything, so publish what parses + /// rather than drop a frame that is whole. + draining: bool, } impl Default for Resync { @@ -1259,6 +1395,7 @@ impl Default for Resync { // would publish payload as audio and take the track's sample rate and channel // count from it for the life of the broadcast. unconfirmed: true, + draining: false, } } } @@ -1299,12 +1436,22 @@ impl Resync { self.discarded > Self::BUDGET } - /// Whether the frame at the current offset still needs a header at its end to confirm - /// it, which is true from a scan until the frame it found is published. + /// Whether the offset itself is one nothing has vouched for, which is true from a scan + /// (or the start of a stream, or a seek) until the frame it found is published. fn unconfirmed(&self) -> bool { self.unconfirmed } + /// Whether the frame at the current offset has to be confirmed by a header where it ends + /// before it can be published. Beyond an unconfirmed offset, that covers a frame + /// beginning in a carried tail: the previous frame vouched for where the tail starts, but + /// nothing vouches for the bytes joined onto it, and at a splice the two halves are + /// unrelated. Confirming only these keeps the cost off the common path, since a frame + /// that begins inside a PES is whole by the time it is parsed. + fn needs_confirmation(&self, in_tail: bool) -> bool { + !self.draining && (self.unconfirmed || in_tail) + } + /// A frame was published, so the stream is back in sync: the next frame starts where /// this one ended and needs no confirmation of its own. fn recovered(&mut self) { @@ -1330,10 +1477,11 @@ impl Resync { self.discarded = 0; } - /// End of stream. Nothing more can arrive to confirm the carried candidate, so take it - /// as-is rather than drop a frame that is whole and parses. - fn accept_unconfirmed(&mut self) { + /// End of stream. Nothing more can arrive to confirm the carried tail, so take it as-is + /// rather than drop a frame that is whole and parses. + fn drain(&mut self) { self.unconfirmed = false; + self.draining = true; } } @@ -1439,17 +1587,19 @@ impl AacStream { in_tail = false; } - // Parse the frame here, and when this offset came from a scan rather than the - // previous frame's end, require a header where the frame it declares ends - // before believing it. See `Resync`. `Err(None)` means nothing parsed badly, - // the candidate just isn't usable. + // Parse the frame here, and unless the previous frame vouched for this offset and + // for the bytes past it, require a header where the frame it declares ends before + // believing it. See `Resync`. `Err(None)` means nothing parsed badly, the + // candidate just isn't usable. + let confirm = self.resync.needs_confirmation(in_tail); let parsed: Result<_, Option> = match adts::Header::parse(&data[offset..]) { Ok(header) => { let end = offset + header.frame_len; if end > data.len() { if !self.resync.unconfirmed() { // A boundary the previous frame vouched for: the frame continues in - // the next PES, so finish it there. + // the next PES, so finish it there. A joined tail waits here too, since + // nothing can confirm a frame the buffer doesn't hold yet. break; } // Unconfirmed, and the length it declares outruns the buffer, so a split @@ -1465,7 +1615,7 @@ impl AacStream { in_tail, }); Err(None) - } else if !self.resync.unconfirmed() { + } else if !confirm { Ok((header, end)) } else if end + adts::MIN_HEADER_LEN > data.len() { // Whole, but too few bytes left to confirm it. Carry it and retry once @@ -1611,26 +1761,31 @@ impl AacStream { } fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { - // A seek is a discontinuity; the partial frame will never see its end, and whatever - // vouched for the next frame boundary no longer applies. - self.tail.clear(); - self.tail_pts = None; - self.resync.desynced(); + // A seek is a discontinuity like any other. + self.desync(); if let Some(import) = &mut self.import { import.seek(sequence)?; } Ok(()) } + /// The partial frame will never see its end, and whatever vouched for the next frame + /// boundary no longer applies. + fn desync(&mut self) { + self.tail.clear(); + self.tail_pts = None; + self.resync.desynced(); + } + fn finish(&mut self) -> anyhow::Result<()> { - // Drain a candidate held only for want of a successor to confirm it: at end of - // stream that successor is never coming. Only once this stream has published a - // frame, though. Before that nothing has vouched for any boundary, so accepting one - // here would hand a capture that joined mid-frame and ended immediately the same - // false frame that starting unconfirmed exists to reject, and build the track's - // config out of it. + // Drain a frame held only for want of a successor to confirm it: at end of stream + // that successor is never coming. Only once this stream has published a frame, + // though. Before that nothing has vouched for any boundary, so accepting one here + // would hand a capture that joined mid-frame and ended immediately the same false + // frame that starting unconfirmed exists to reject, and build the track's config out + // of it. if !self.tail.is_empty() && self.import.is_some() { - self.resync.accept_unconfirmed(); + self.resync.drain(); self.write(Pending::empty(), None)?; } // A partial frame at end of stream isn't emissible; drop it, but leave a trace for @@ -1842,17 +1997,19 @@ impl LegacyStream { in_tail = false; } - // Parse the frame here, and when this offset came from a scan rather than the - // previous frame's end, require a header where the frame it declares ends - // before believing it. See `Resync`. `Err(None)` means nothing parsed badly, - // the candidate just isn't usable. + // Parse the frame here, and unless the previous frame vouched for this offset and + // for the bytes past it, require a header where the frame it declares ends before + // believing it. See `Resync`. `Err(None)` means nothing parsed badly, the + // candidate just isn't usable. + let confirm = self.resync.needs_confirmation(in_tail); let parsed: Result<_, Option> = match (self.descriptor.parse)(&data[offset..]) { Ok(header) => { let end = offset + header.len; if end > data.len() { if !self.resync.unconfirmed() { // A boundary the previous frame vouched for: the frame continues in - // the next PES, so finish it there. + // the next PES, so finish it there. A joined tail waits here too, since + // nothing can confirm a frame the buffer doesn't hold yet. break; } // Unconfirmed, and the length it declares outruns the buffer, so a split @@ -1867,7 +2024,7 @@ impl LegacyStream { in_tail, }); Err(None) - } else if !self.resync.unconfirmed() { + } else if !confirm { Ok((header, end)) } else if end + self.descriptor.min_header_len > data.len() { // Whole, but too few bytes left to confirm it. Carry it and retry once @@ -1983,26 +2140,31 @@ impl LegacyStream { } fn seek(&mut self, sequence: u64) -> anyhow::Result<()> { - // A seek is a discontinuity; the partial frame will never see its end, and whatever - // vouched for the next frame boundary no longer applies. - self.tail.clear(); - self.tail_pts = None; - self.resync.desynced(); + // A seek is a discontinuity like any other. + self.desync(); if let Some(import) = &mut self.import { import.seek(sequence)?; } Ok(()) } + /// The partial frame will never see its end, and whatever vouched for the next frame + /// boundary no longer applies. + fn desync(&mut self) { + self.tail.clear(); + self.tail_pts = None; + self.resync.desynced(); + } + fn finish(&mut self) -> anyhow::Result<()> { - // Drain a candidate held only for want of a successor to confirm it: at end of - // stream that successor is never coming. Only once this stream has published a - // frame, though. Before that nothing has vouched for any boundary, so accepting one - // here would hand a capture that joined mid-frame and ended immediately the same - // false frame that starting unconfirmed exists to reject, and build the track's - // config out of it. + // Drain a frame held only for want of a successor to confirm it: at end of stream + // that successor is never coming. Only once this stream has published a frame, + // though. Before that nothing has vouched for any boundary, so accepting one here + // would hand a capture that joined mid-frame and ended immediately the same false + // frame that starting unconfirmed exists to reject, and build the track's config out + // of it. if !self.tail.is_empty() && self.import.is_some() { - self.resync.accept_unconfirmed(); + self.resync.drain(); self.write(Pending::empty())?; } // A partial frame at end of stream isn't emissible verbatim; drop it, but @@ -2600,6 +2762,55 @@ mod test { p } + /// Open a bounded audio PES whose declared payload is longer than this packet carries. + fn audio_pes_open(pid: u16, cc: u8, pts: u64, declared: usize, payload: &[u8]) -> Vec { + assert!(payload.len() < declared, "the test PES must remain open"); + let pts_field = [ + 0x21 | (((pts >> 30) & 0x07) << 1) as u8, + ((pts >> 22) & 0xff) as u8, + 0x01 | (((pts >> 15) & 0x7f) << 1) as u8, + ((pts >> 7) & 0xff) as u8, + 0x01 | ((pts & 0x7f) << 1) as u8, + ]; + let mut pes = vec![0x00, 0x00, 0x01, 0xc0]; + let pes_len = 3 + 5 + declared; + pes.push((pes_len >> 8) as u8); + pes.push((pes_len & 0xff) as u8); + pes.extend_from_slice(&[0x80, 0x80, 0x05]); + pes.extend_from_slice(&pts_field); + pes.extend_from_slice(payload); + + let af_len = 184 - 1 - pes.len(); + let mut p = vec![ + 0x47, + 0x40 | ((pid >> 8) as u8 & 0x1f), + (pid & 0xff) as u8, + 0x30 | (cc & 0x0f), + ]; + p.push(af_len as u8); + if af_len > 0 { + p.push(0x00); + p.extend(std::iter::repeat_n(0xff, af_len - 1)); + } + p.extend_from_slice(&pes); + assert_eq!(p.len(), 188, "open audio PES packet must fill exactly one TS packet"); + p + } + + /// Build a non-PUSI TS payload packet with adaptation-field stuffing. + fn ts_continuation(pid: u16, cc: u8, payload: &[u8]) -> Vec { + let af_len = 184 - 1 - payload.len(); + let mut p = vec![0x47, ((pid >> 8) as u8 & 0x1f), (pid & 0xff) as u8, 0x30 | (cc & 0x0f)]; + p.push(af_len as u8); + if af_len > 0 { + p.push(0x00); + p.extend(std::iter::repeat_n(0xff, af_len - 1)); + } + p.extend_from_slice(payload); + assert_eq!(p.len(), 188, "continuation packet must fill exactly one TS packet"); + p + } + // MP2/AC-3 flush like any audio PES but don't consume the jitter hint; if one // anchored the audio run, an AAC PID in the same TS would publish a jitter // inflated by the inter-PID PTS offset. @@ -2729,6 +2940,92 @@ mod test { ); } + /// Read every retained frame of the single video rendition in `catalog`. + async fn read_video_frames( + consumer: &moq_net::broadcast::Consumer, + catalog: &crate::catalog::Producer, + ) -> Vec { + let name = catalog + .snapshot() + .video + .renditions + .keys() + .next() + .expect("a video track") + .clone(); + let track = consumer.track(&name).unwrap().subscribe(None).await.unwrap(); + let mut reader = crate::container::Consumer::new(track, crate::catalog::hang::Container::Legacy); + let mut frames = Vec::new(); + while let Ok(Ok(Some(frame))) = tokio::time::timeout(std::time::Duration::from_millis(50), reader.read()).await + { + frames.push(frame); + } + frames + } + + /// Annex-B bytes for one access unit: SPS + PPS + IDR for a keyframe, else a delta slice. + fn annexb_au(keyframe: bool) -> Vec { + use crate::container::test_util::{IDR, PPS, SPS}; + let nals: &[&[u8]] = if keyframe { + &[SPS, PPS, IDR] + } else { + &[&[0x41, 0x9a, 0x00, 0x01]] + }; + let mut out = Vec::new(); + for nal in nals { + out.extend_from_slice(&[0, 0, 0, 1]); + out.extend_from_slice(nal); + } + out + } + + // A break mid-picture is not the same as a break mid-audio. One video PES is exactly one + // access unit, so there is no whole unit ahead of the cut to salvage: publishing what + // arrived would hand the decoder a picture with missing slices, and a keyframe missing + // slices stays wrong for every picture that references it. Drop it and wait for the next. + #[tokio::test(start_paused = true)] + async fn video_drops_a_partial_access_unit_across_a_continuity_break() { + const VIDEO_PID: u16 = 0x0050; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut import = super::Import::new(broadcast, catalog.reserve()); + + let pmt = synth_pmt(&[(StreamType::H264, VIDEO_PID)], false); + import.decode(&bytes::BytesMut::from(&pmt[..])).unwrap(); + + // A keyframe cut in half by the wrap: the PES declares more than this packet carries. + let whole = annexb_au(true); + import + .decode(audio_pes_open(VIDEO_PID, 0, 90_000, whole.len() + 32, &whole[..28]).as_slice()) + .unwrap(); + // The wrap completes the open PES with unrelated bytes, and continuity gives it away. + import + .decode(ts_continuation(VIDEO_PID, 12, &whole[28..]).as_slice()) + .unwrap(); + // A clean keyframe after it, which is what the track should carry. + import + .decode(audio_pes_packet(VIDEO_PID, 13, 270_000, &whole).as_slice()) + .unwrap(); + import + .decode(audio_pes_packet(VIDEO_PID, 14, 450_000, &annexb_au(false)).as_slice()) + .unwrap(); + import.finish().unwrap(); + + let frames = read_video_frames(&consumer, &catalog).await; + assert!( + frames.iter().all(|f| f.payload.len() >= whole.len() || !f.keyframe), + "a keyframe with missing slices reached the track: {:?}", + frames.iter().map(|f| (f.keyframe, f.payload.len())).collect::>() + ); + assert_eq!( + frames.first().map(|f| f.payload.to_vec()), + Some(whole.clone()), + "the first published picture is not the whole keyframe" + ); + } + /// One whole ADTS frame carrying `raw_len` bytes of `fill`. `fill` must not be 0xFF, /// which a resync would mistake for a frame sync. fn adts_frame(raw_len: usize, fill: u8) -> Vec { @@ -2737,6 +3034,44 @@ mod test { f } + // The AAC mirror of `legacy_drops_a_pes_completed_across_a_continuity_break`. + #[tokio::test(start_paused = true)] + async fn aac_drops_a_pes_completed_across_a_continuity_break() { + const AAC_PID: u16 = 0x0060; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut import = super::Import::new(broadcast, catalog.reserve()); + + let pmt = synth_pmt(&[(StreamType::AdtsAac, AAC_PID)], false); + import.decode(&bytes::BytesMut::from(&pmt[..])).unwrap(); + + let mut opening = adts_frame(40, 0xAA); + opening.extend_from_slice(&adts_frame(40, 0xBB)[..25]); + import + .decode(audio_pes_open(AAC_PID, 0, 90_000, 94, &opening).as_slice()) + .unwrap(); + import + .decode(ts_continuation(AAC_PID, 12, &adts_frame(40, 0xCC)[..32]).as_slice()) + .unwrap(); + let mut normal = adts_frame(40, 0xDD); + normal.extend_from_slice(&adts_frame(40, 0xEE)); + import + .decode(audio_pes_packet(AAC_PID, 13, 270_000, &normal).as_slice()) + .unwrap(); + import.finish().unwrap(); + + let frames = read_audio_frames(&consumer, &catalog).await; + assert_eq!( + frames.iter().map(|f| f.payload.to_vec()).collect::>(), + [0xAA, 0xDD, 0xEE] + .map(|fill| adts_frame(40, fill)[7..].to_vec()) + .to_vec(), + "the wrap was spliced onto the frame the cut left open" + ); + } + // ISO 13818-1 doesn't require AAC frames to align with PES boundaries any more than it // does the legacy codecs, but only the legacy path reassembled a split frame: AAC // rejected the PES outright, so a mux that split one killed the broadcast on well-formed @@ -2817,6 +3152,88 @@ mod test { ); } + // AAC reassembles a split frame the same way the legacy codecs do, so it splices the same + // way at a wrap and confirms the join for the same reason. See + // `legacy_confirms_a_frame_joined_out_of_a_carried_tail`. + #[tokio::test(start_paused = true)] + async fn aac_confirms_a_frame_joined_out_of_a_carried_tail() { + const AAC_PID: u16 = 0x0060; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut import = super::Import::new(broadcast, catalog.reserve()); + + let pmt = synth_pmt(&[(StreamType::AdtsAac, AAC_PID)], false); + import.decode(&bytes::BytesMut::from(&pmt[..])).unwrap(); + + // A whole frame, then one cut mid-frame by the wrap. + let mut payload = adts_frame(40, 0xAA); + payload.extend_from_slice(&adts_frame(40, 0xBB)[..25]); + import + .decode(audio_pes_packet(AAC_PID, 0, 90_000, &payload).as_slice()) + .unwrap(); + // The top of the file again: the tail splices onto its first frame. + let mut wrapped = adts_frame(40, 0xCC); + wrapped.extend_from_slice(&adts_frame(40, 0xDD)); + import + .decode(audio_pes_packet(AAC_PID, 1, 270_000, &wrapped).as_slice()) + .expect("a splice is not fatal"); + import + .decode(audio_pes_packet(AAC_PID, 2, 450_000, &adts_frame(40, 0xEE)).as_slice()) + .unwrap(); + import.finish().unwrap(); + + // The track carries raw AAC, so each published frame is its ADTS body. + let frames = read_audio_frames(&consumer, &catalog).await; + assert_eq!( + frames.iter().map(|f| f.payload.to_vec()).collect::>(), + [0xAA, 0xCC, 0xDD, 0xEE] + .map(|fill| adts_frame(40, fill)[7..].to_vec()) + .to_vec(), + "the splice cost more than the frame it interrupted" + ); + assert_eq!( + frames[1].timestamp, + Timestamp::from_micros(3_000_000).unwrap(), + "not re-anchored on the new PES" + ); + } + + // The end-of-stream drain, for AAC. See `legacy_drains_a_joined_frame_at_end_of_stream`. + #[tokio::test(start_paused = true)] + async fn aac_drains_a_joined_frame_at_end_of_stream() { + const AAC_PID: u16 = 0x0060; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut import = super::Import::new(broadcast, catalog.reserve()); + + let pmt = synth_pmt(&[(StreamType::AdtsAac, AAC_PID)], false); + import.decode(&bytes::BytesMut::from(&pmt[..])).unwrap(); + + let split = adts_frame(40, 0xBB); + let mut payload = adts_frame(40, 0xAA); + payload.extend_from_slice(&split[..25]); + import + .decode(audio_pes_packet(AAC_PID, 0, 90_000, &payload).as_slice()) + .unwrap(); + // The rest of the split frame and nothing else, so the stream ends with it joined, + // whole, and unconfirmed. + import + .decode(audio_pes_packet(AAC_PID, 1, 270_000, &split[25..]).as_slice()) + .unwrap(); + import.finish().unwrap(); + + let frames = read_audio_frames(&consumer, &catalog).await; + assert_eq!( + frames.iter().map(|f| f.payload.to_vec()).collect::>(), + [0xAA, 0xBB].map(|fill| adts_frame(40, fill)[7..].to_vec()).to_vec(), + "the last frame was held for a confirmation that could never arrive" + ); + } + /// One whole MPEG-2 Layer II frame (8 kbps, 16 kHz, mono = 72 bytes), filled with /// `fill` so frames are told apart on the wire. Small enough that two fit in the /// single TS packet [`audio_pes_packet`] builds. `fill` must not be 0xFF, which a @@ -2827,6 +3244,47 @@ mod test { f } + // The shape a looping mux actually produces, which is not a carried tail: the last PES + // before the cut is truncated, so it stays open, and the next loop's leading continuation + // packets complete it. The foreign bytes land in the SAME PES, so the codec sees one + // buffer with nothing carried, and the confirmation rule above never gets a say. The + // continuity counter is what gives the wrap away, so the truncated PES is flushed for the + // whole frames it did deliver and the stream resumes at the next PES start. + #[tokio::test(start_paused = true)] + async fn legacy_drops_a_pes_completed_across_a_continuity_break() { + const MP2_PID: u16 = 0x0061; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut import = super::Import::new(broadcast, catalog.reserve()); + + let pmt = synth_pmt(&[(StreamType::Mpeg1Audio, MP2_PID)], false); + import.decode(&bytes::BytesMut::from(&pmt[..])).unwrap(); + + let mut opening = mp2_frame(0xAA); + opening.extend_from_slice(&mp2_frame(0xBB)[..40]); + import + .decode(audio_pes_open(MP2_PID, 0, 90_000, 144, &opening).as_slice()) + .unwrap(); + import + .decode(ts_continuation(MP2_PID, 12, &mp2_frame(0xCC)[..32]).as_slice()) + .unwrap(); + let mut normal = mp2_frame(0xDD); + normal.extend_from_slice(&mp2_frame(0xEE)); + import + .decode(audio_pes_packet(MP2_PID, 13, 270_000, &normal).as_slice()) + .unwrap(); + import.finish().unwrap(); + + let frames = read_audio_frames(&consumer, &catalog).await; + assert_eq!( + frames.iter().map(|f| f.payload.clone()).collect::>(), + vec![mp2_frame(0xAA), mp2_frame(0xDD), mp2_frame(0xEE)], + "the wrap was spliced onto the frame the cut left open" + ); + } + // A damaged frame header must not take the session down with it: the demuxer scans to // the next sync word and keeps publishing, the way the TS and video layers already do. // One bit flipped in a sync word used to abort the whole broadcast (#2729). @@ -2899,32 +3357,113 @@ mod test { import .decode(audio_pes_packet(MP2_PID, 1, 270_000, &wrapped).as_slice()) .expect("a splice is not fatal"); - // The recovered frame is only published once the frame after it confirms the - // boundary, so the stream has to keep running past the wrap. + // One more PES to show the wrap costs nothing after it. import .decode(audio_pes_packet(MP2_PID, 2, 450_000, &mp2_frame(0xEE)).as_slice()) .unwrap(); import.finish().unwrap(); let frames = read_audio_frames(&consumer, &catalog).await; - // The tail still carries an intact header claiming 72 bytes, and it sits at a - // boundary the previous frame vouched for, so the splice itself is published as one - // frame of mixed bytes; the demuxer only learns sync was lost at the frame after it. - // Catching that too would mean confirming every frame, not just scanned ones. - assert_eq!(frames.len(), 3, "the stream recovers after the splice"); + // The stale tail still carries an intact header claiming 72 bytes, so what gives the + // splice away is the confirmation: the frame it declares ends inside the wrapped + // frame rather than at a header. The tail is scanned past, not published. assert_eq!( - frames[1].payload.as_ref(), - &mp2_frame(0xDD)[..], - "resynced onto the next whole frame" + frames.iter().map(|f| f.payload.clone()).collect::>(), + vec![mp2_frame(0xCC), mp2_frame(0xDD), mp2_frame(0xEE)], + "the splice was published as audio instead of scanned past" ); - // Re-anchored on PES 2 (270000 ticks = 3 s), give or take the frame the splice ate, - // rather than inheriting the stale tail's 1 s. At a real loop wrap that inherited - // error is the whole file's duration, not a frame. - assert!( - (Timestamp::from_micros(3_000_000).unwrap()..Timestamp::from_micros(3_200_000).unwrap()) - .contains(&frames[1].timestamp), - "not re-anchored on the new PES: {:?}", - frames[1].timestamp + // Re-anchored on PES 2 (270000 ticks = 3 s) rather than inheriting the stale tail's + // 1 s. At a real loop wrap that inherited error is the whole file's duration, not a + // frame. + assert_eq!( + frames[0].timestamp, + Timestamp::from_micros(3_000_000).unwrap(), + "not re-anchored on the new PES" + ); + } + + // The same splice one frame later, which is the shape a real wrap takes: the stream has + // already published a frame, so the tail sits at a boundary the previous frame vouched + // for. What it vouched for is where the tail begins, not the unrelated bytes the next PES + // joins onto it, so the join still has to be confirmed. Without that this published one + // frame of pre-wrap and post-wrap bytes spliced together, and swallowed the real frame + // underneath it (#2802). + #[tokio::test(start_paused = true)] + async fn legacy_confirms_a_frame_joined_out_of_a_carried_tail() { + const MP2_PID: u16 = 0x0061; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut import = super::Import::new(broadcast, catalog.reserve()); + + let pmt = synth_pmt(&[(StreamType::Mpeg1Audio, MP2_PID)], false); + import.decode(&bytes::BytesMut::from(&pmt[..])).unwrap(); + + // A whole frame, then one cut mid-frame by the wrap. The first vouches for where the + // tail begins, which is what made the join look trustworthy. + let mut payload = mp2_frame(0xAA); + payload.extend_from_slice(&mp2_frame(0xBB)[..40]); + import + .decode(audio_pes_packet(MP2_PID, 0, 90_000, &payload).as_slice()) + .unwrap(); + // The top of the file again: the tail splices onto its first frame. + let mut wrapped = mp2_frame(0xCC); + wrapped.extend_from_slice(&mp2_frame(0xDD)); + import + .decode(audio_pes_packet(MP2_PID, 1, 270_000, &wrapped).as_slice()) + .expect("a splice is not fatal"); + import + .decode(audio_pes_packet(MP2_PID, 2, 450_000, &mp2_frame(0xEE)).as_slice()) + .unwrap(); + import.finish().unwrap(); + + let frames = read_audio_frames(&consumer, &catalog).await; + assert_eq!( + frames.iter().map(|f| f.payload.clone()).collect::>(), + vec![mp2_frame(0xAA), mp2_frame(0xCC), mp2_frame(0xDD), mp2_frame(0xEE)], + "the splice cost more than the frame it interrupted" + ); + // The wrapped frame keeps the new PES's PTS, not the pre-wrap tail's. + assert_eq!( + frames[1].timestamp, + Timestamp::from_micros(3_000_000).unwrap(), + "not re-anchored on the new PES" + ); + } + + // Confirming a joined frame means holding it when the buffer ends too soon after it to + // hold a header. At end of stream that header is never coming, so the drain publishes it + // anyway rather than dropping a frame that is whole and parses. + #[tokio::test(start_paused = true)] + async fn legacy_drains_a_joined_frame_at_end_of_stream() { + const MP2_PID: u16 = 0x0061; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut import = super::Import::new(broadcast, catalog.reserve()); + + let pmt = synth_pmt(&[(StreamType::Mpeg1Audio, MP2_PID)], false); + import.decode(&bytes::BytesMut::from(&pmt[..])).unwrap(); + + let mut payload = mp2_frame(0xAA); + payload.extend_from_slice(&mp2_frame(0xBB)[..40]); + import + .decode(audio_pes_packet(MP2_PID, 0, 90_000, &payload).as_slice()) + .unwrap(); + // The rest of the split frame and nothing else, so the stream ends with it joined, + // whole, and unconfirmed. + import + .decode(audio_pes_packet(MP2_PID, 1, 270_000, &mp2_frame(0xBB)[40..]).as_slice()) + .unwrap(); + import.finish().unwrap(); + + let frames = read_audio_frames(&consumer, &catalog).await; + assert_eq!( + frames.iter().map(|f| f.payload.clone()).collect::>(), + vec![mp2_frame(0xAA), mp2_frame(0xBB)], + "the last frame was held for a confirmation that could never arrive" ); } @@ -3464,6 +4003,20 @@ mod test { ); } + #[test] + fn tei_pusi_section_is_dropped() { + // A PUSI flagged TEI must not start a section out of payload the demodulator has + // already disowned. Resetting the counter is not enough: the packet itself is junk. + let mut corrupt = packet(true, 0, 0, &CUE); + corrupt[1] |= 0x80; // transport_error_indicator + let clean = packet(true, 1, 0, &CUE); + assert_eq!( + run(&[corrupt, clean]), + vec![CUE.to_vec()], + "a section was emitted from a packet flagged corrupt" + ); + } + #[test] fn duplicate_mid_section_packet_is_skipped() { // A 3-packet section with the central continuation duplicated (same cc, same From da44be53bb5ea4d2d1678cd170b78799ed395395 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 13 Aug 2026 22:27:55 -0700 Subject: [PATCH 02/12] fix(rtc): publish VP8 and VP9 dimensions (#2845) Use the shared VP8 and VP9 importers so RTC publishers advertise coded dimensions from keyframes. Defer unresolved catalog reservations until media arrives, and preserve the session abort cause if importer construction fails. --- rs/moq-rtc/src/codec/bitstream_test.rs | 47 ++++++++-- rs/moq-rtc/src/codec/mod.rs | 117 +++++++++++++++++++++-- rs/moq-rtc/src/codec/vp8.rs | 114 ++++++++++++---------- rs/moq-rtc/src/codec/vp9.rs | 125 ++++++------------------- 4 files changed, 241 insertions(+), 162 deletions(-) diff --git a/rs/moq-rtc/src/codec/bitstream_test.rs b/rs/moq-rtc/src/codec/bitstream_test.rs index 858cb5ab94..92c3bd61c9 100644 --- a/rs/moq-rtc/src/codec/bitstream_test.rs +++ b/rs/moq-rtc/src/codec/bitstream_test.rs @@ -89,23 +89,47 @@ async fn opus_frame_publishes_catalog_entry() { } #[tokio::test(start_paused = true)] -async fn vp9_keyframe_flag_from_uncompressed_header() { +async fn idle_video_bridges_do_not_gate_audio_catalog() { let broadcast = moq_net::broadcast::Info::new(); let mut producer = broadcast.produce(); let catalog = moq_mux::catalog::Producer::new(&mut producer).expect("catalog"); + let mut updates = catalog.consume().expect("catalog consumer"); - let mut bridge = codec::vp9::Bridge::new(producer, catalog.clone()).expect("bridge"); + let _vp8 = codec::vp8::Bridge::new(producer.clone(), catalog.clone()).expect("vp8 bridge"); + let _vp9 = codec::vp9::Bridge::new(producer.clone(), catalog.clone()).expect("vp9 bridge"); + let mut opus = codec::opus::Bridge::new(producer, catalog, 48_000, 2).expect("opus bridge"); + Bridge::push( + &mut opus, + Frame { + timestamp_us: 0, + payload: Bytes::from_static(&[0xfc, 0xff, 0xfe]), + }, + ) + .expect("push opus"); - // VP9 uncompressed header: frame_type is bit 2. 0 = keyframe, 1 = inter. - // Byte with bit 2 cleared is a keyframe; with bit 2 set is an inter frame. - let keyframe_byte = 0b1000_0010; // frame_marker=10, profile bits, frame_type=0 - let interframe_byte = 0b1000_0110; // same shape but frame_type=1 + let snapshot = tokio::time::timeout(std::time::Duration::from_millis(1), updates.next()) + .await + .expect("idle video must not gate active audio") + .expect("catalog update") + .expect("catalog snapshot"); + assert_eq!(snapshot.audio.renditions.len(), 1); + assert!(snapshot.video.renditions.is_empty()); +} + +#[tokio::test(start_paused = true)] +async fn vp9_keyframe_publishes_dimensions_and_starts_group() { + let broadcast = moq_net::broadcast::Info::new(); + let mut producer = broadcast.produce(); + let catalog = moq_mux::catalog::Producer::new(&mut producer).expect("catalog"); + + let mut bridge = codec::vp9::Bridge::new(producer, catalog.clone()).expect("bridge"); Bridge::push( &mut bridge, Frame { timestamp_us: 0, - payload: Bytes::from(vec![keyframe_byte, 0, 0]), + // VP9 profile 0 keyframe header for 320x240. + payload: Bytes::from_static(&[0x82, 0x49, 0x83, 0x42, 0x20, 0x13, 0xf0, 0x0e, 0xf0, 0x00]), }, ) .expect("keyframe accepted"); @@ -117,12 +141,17 @@ async fn vp9_keyframe_flag_from_uncompressed_header() { &mut bridge, Frame { timestamp_us: 33_000, - payload: Bytes::from(vec![interframe_byte, 0, 0]), + // frame_marker=10, profile=0, show_existing=0, frame_type=1. + payload: Bytes::from_static(&[0x84, 0x00, 0x00]), }, ) .expect("interframe accepted"); - assert_eq!(catalog.snapshot().video.renditions.len(), 1, "vp9 rendition announced"); + let snapshot = catalog.snapshot(); + assert_eq!(snapshot.video.renditions.len(), 1, "vp9 rendition announced"); + let config = snapshot.video.renditions.values().next().unwrap(); + assert_eq!(config.coded_width, Some(320)); + assert_eq!(config.coded_height, Some(240)); } // ── Egress (RTP-out) round-trip tests ───────────────────────────────────── diff --git a/rs/moq-rtc/src/codec/mod.rs b/rs/moq-rtc/src/codec/mod.rs index 3d386de351..e5d2f94ea5 100644 --- a/rs/moq-rtc/src/codec/mod.rs +++ b/rs/moq-rtc/src/codec/mod.rs @@ -51,18 +51,115 @@ pub trait Bridge: Send { fn abort(self: Box, err: moq_net::Error); } -/// A bridge's video catalog entry, removed however the bridge ends. -/// -/// A separate value rather than a `Drop` on the bridge itself, so a bridge's terminal -/// [`Bridge::abort`] can consume its track producer. -pub(crate) struct VideoRendition { - pub catalog: moq_mux::catalog::Producer, - pub name: String, +/// A mux importer whose catalog configuration is resolved from its first frame. +pub(crate) trait DeferredImport: Send + Sized { + /// Create the importer and its unresolved catalog rendition. + fn create(track: moq_net::track::Producer, reserved: moq_mux::catalog::Reserved) -> moq_mux::Result; + + /// Decode one complete codec frame. + fn decode(&mut self, frame: Bytes, pts: moq_net::Timestamp) -> moq_mux::Result<()>; + + /// Abort the active media track. + fn abort(self, err: moq_net::Error); +} + +impl DeferredImport for moq_mux::codec::vp8::Import { + fn create(track: moq_net::track::Producer, reserved: moq_mux::catalog::Reserved) -> moq_mux::Result { + Self::new(track, reserved, Default::default()) + } + + fn decode(&mut self, frame: Bytes, pts: moq_net::Timestamp) -> moq_mux::Result<()> { + moq_mux::codec::vp8::Import::decode(self, frame, Some(pts)) + } + + fn abort(self, err: moq_net::Error) { + moq_mux::codec::vp8::Import::abort(self, err); + } +} + +impl DeferredImport for moq_mux::codec::vp9::Import { + fn create(track: moq_net::track::Producer, reserved: moq_mux::catalog::Reserved) -> moq_mux::Result { + Self::new(track, reserved, Default::default()) + } + + fn decode(&mut self, frame: Bytes, pts: moq_net::Timestamp) -> moq_mux::Result<()> { + moq_mux::codec::vp9::Import::decode(self, frame, Some(pts)) + } + + fn abort(self, err: moq_net::Error) { + moq_mux::codec::vp9::Import::abort(self, err); + } +} + +struct PendingVideo { + track: moq_net::track::Producer, + catalog: moq_mux::catalog::Producer, +} + +enum DeferredState { + Pending(Box), + Active(Box), + Failed(Box), + Poisoned, +} + +/// Defers a video importer's catalog reservation until its first frame. +pub(crate) struct DeferredVideo { + state: DeferredState, } -impl Drop for VideoRendition { - fn drop(&mut self) { - self.catalog.lock().video.renditions.remove(&self.name); +impl DeferredVideo { + /// Create the media track without gating the initial catalog snapshot. + pub fn new( + mut broadcast: moq_net::broadcast::Producer, + catalog: moq_mux::catalog::Producer, + suffix: &str, + ) -> Result { + let track = broadcast.unique_track(suffix, catalog.track_info())?; + Ok(Self { + state: DeferredState::Pending(Box::new(PendingVideo { track, catalog })), + }) + } + + /// Decode a frame, creating the importer on first use. + pub fn decode(&mut self, frame: Bytes, pts: moq_net::Timestamp) -> Result<()> { + if let DeferredState::Active(import) = &mut self.state { + return import.decode(frame, pts).map_err(Into::into); + } + + let DeferredState::Pending(pending) = std::mem::replace(&mut self.state, DeferredState::Poisoned) else { + return Err(crate::Error::Other(anyhow::anyhow!( + "video bridge initialization already failed" + ))); + }; + let reserved = pending.catalog.reserve(); + let abort = pending.track.clone(); + let import = match I::create(pending.track, reserved) { + Ok(import) => import, + Err(err) => { + self.state = DeferredState::Failed(Box::new(abort)); + return Err(err.into()); + } + }; + self.state = DeferredState::Active(Box::new(import)); + let DeferredState::Active(import) = &mut self.state else { + unreachable!(); + }; + import.decode(frame, pts).map_err(Into::into) + } + + /// Abort the media track in either lifecycle state. + pub fn abort(self, err: moq_net::Error) { + match self.state { + DeferredState::Pending(pending) => { + let _ = pending.track.abort(err); + } + DeferredState::Active(import) => import.abort(err), + DeferredState::Failed(track) => { + let _ = track.abort(err); + } + DeferredState::Poisoned => {} + } } } diff --git a/rs/moq-rtc/src/codec/vp8.rs b/rs/moq-rtc/src/codec/vp8.rs index f32c09d391..dc89c87ad7 100644 --- a/rs/moq-rtc/src/codec/vp8.rs +++ b/rs/moq-rtc/src/codec/vp8.rs @@ -1,70 +1,86 @@ //! VP8 bridge. //! -//! VP8 carries no out-of-band config record. str0m hands us complete frames -//! and we forward them to a `.vp8` track with the matching catalog entry. -//! Keyframes are detected from the first byte (P-frame bit, RFC 6386 §9.1). +//! str0m hands us complete VP8 frames, which is exactly the raw shape that +//! [`moq_mux::codec::vp8::Import`] consumes. The shared importer parses keyframes +//! so the catalog carries the encoded dimensions and stays in sync if they change. use crate::{Result, codec}; -/// Forwards str0m's VP8 frames to a `.vp8` track, detecting keyframes inline. +/// Bridges str0m VP8 frames into a MoQ VP8 track. pub struct Bridge { - /// Owns the catalog rendition, retiring it when the bridge goes away. - rendition: codec::VideoRendition, - track: moq_mux::container::Producer, - announced: bool, + import: codec::DeferredVideo, } impl Bridge { - /// Publish a `.vp8` track on `broadcast`; the catalog rendition is added on the first frame. - pub fn new(mut broadcast: moq_net::broadcast::Producer, catalog: moq_mux::catalog::Producer) -> Result { - let track = broadcast.create_track(broadcast.unique_name(".vp8"), catalog.track_info())?; - let name = track.name().to_string(); - let producer = catalog.media_producer(track, moq_mux::catalog::hang::Container::Legacy)?; - Ok(Self { - rendition: codec::VideoRendition { catalog, name }, - track: producer, - announced: false, - }) - } - - fn announce(&mut self) -> Result<()> { - if self.announced { - return Ok(()); - } - let name = self.rendition.name.clone(); - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.container = hang::catalog::Container::Legacy; - config.timeline = Some(self.rendition.catalog.timeline(&name)?.section()); - // Publish explicitly rather than through the guard's drop, which only warns: - // marking the rendition announced when the catalog never took it would leave the - // media track advertised nowhere, and `announced` latches so we'd never retry. - let mut guard = self.rendition.catalog.lock(); - guard.video.renditions.insert(name, config); - guard.commit()?; - self.announced = true; - Ok(()) + /// Publish a `.vp8` track on `broadcast`, adding the catalog rendition once config is known. + pub fn new(broadcast: moq_net::broadcast::Producer, catalog: moq_mux::catalog::Producer) -> Result { + let import = codec::DeferredVideo::new(broadcast, catalog, ".vp8")?; + Ok(Self { import }) } } impl codec::Bridge for Bridge { fn push(&mut self, frame: codec::Frame) -> Result<()> { - self.announce()?; let pts = moq_net::Timestamp::from_micros(frame.timestamp_us) .map_err(|err| crate::Error::Other(anyhow::anyhow!("invalid timestamp: {err}")))?; - // VP8: first byte bit 0 == 0 means keyframe (RFC 6386 §9.1). - let keyframe = frame.payload.first().map(|b| b & 0x01 == 0).unwrap_or(false); - self.track - .write(moq_mux::container::Frame { - timestamp: pts, - payload: frame.payload, - keyframe, - duration: None, - }) - .map_err(|err| crate::Error::Other(anyhow::anyhow!("vp8 track write failed: {err}")))?; - Ok(()) + self.import.decode(frame.payload, pts) } fn abort(self: Box, err: moq_net::Error) { - self.track.abort(err); + self.import.abort(err); + } +} + +#[cfg(test)] +mod tests { + use bytes::Bytes; + + use crate::codec::{self, Bridge as _}; + + #[test] + fn keyframe_publishes_catalog_dimensions() { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap(); + let mut bridge = super::Bridge::new(broadcast, catalog.clone()).unwrap(); + + assert!(catalog.snapshot().video.renditions.is_empty()); + + // VP8 keyframe header for 320x240. + bridge + .push(codec::Frame { + timestamp_us: 0, + payload: Bytes::from_static(&[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00]), + }) + .unwrap(); + + let snapshot = catalog.snapshot(); + let config = snapshot.video.renditions.values().next().unwrap(); + assert_eq!(config.coded_width, Some(320)); + assert_eq!(config.coded_height, Some(240)); + } + + #[tokio::test] + async fn importer_creation_failure_preserves_abort_error() { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap(); + let _collision = broadcast.create_track("0.vp8.timeline.z", None).unwrap(); + let consumer = broadcast.consume(); + let mut bridge = super::Bridge::new(broadcast, catalog).unwrap(); + let mut track = consumer.track("0.vp8").unwrap().subscribe(None).await.unwrap(); + + let result = bridge.push(codec::Frame { + timestamp_us: 0, + payload: Bytes::from_static(&[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00]), + }); + assert!(result.is_err(), "timeline collision must fail importer creation"); + + Box::new(bridge).abort(moq_net::Error::Transport("session failed".into())); + let Err(error) = track.recv_group().await else { + panic!("aborted track must fail"); + }; + assert!(matches!( + error, + moq_net::Error::Transport(message) if message == "session failed" + )); } } diff --git a/rs/moq-rtc/src/codec/vp9.rs b/rs/moq-rtc/src/codec/vp9.rs index 51b6f70446..ccad7e3884 100644 --- a/rs/moq-rtc/src/codec/vp9.rs +++ b/rs/moq-rtc/src/codec/vp9.rs @@ -1,124 +1,61 @@ //! VP9 bridge. //! -//! Keyframes are detected from the frame_type bit (RFC 8741 §3 / VP9 spec §6.2: -//! the second bit of the uncompressed header). +//! str0m hands us complete VP9 frames, which is exactly the raw shape that +//! [`moq_mux::codec::vp9::Import`] consumes. The shared importer parses keyframes +//! so the catalog carries the encoded dimensions and stays in sync if they change. use crate::{Result, codec}; -/// Forwards str0m's VP9 frames to a `.vp9` track, detecting keyframes inline. +/// Bridges str0m VP9 frames into a MoQ VP9 track. pub struct Bridge { - /// Owns the catalog rendition, retiring it when the bridge goes away. - rendition: codec::VideoRendition, - track: moq_mux::container::Producer, - announced: bool, + import: codec::DeferredVideo, } impl Bridge { - /// Publish a `.vp9` track on `broadcast`; the catalog rendition is added on the first frame. - pub fn new(mut broadcast: moq_net::broadcast::Producer, catalog: moq_mux::catalog::Producer) -> Result { - let track = broadcast.create_track(broadcast.unique_name(".vp9"), catalog.track_info())?; - let name = track.name().to_string(); - let producer = catalog.media_producer(track, moq_mux::catalog::hang::Container::Legacy)?; - Ok(Self { - rendition: codec::VideoRendition { catalog, name }, - track: producer, - announced: false, - }) - } - - fn announce(&mut self) -> Result<()> { - if self.announced { - return Ok(()); - } - let name = self.rendition.name.clone(); - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VP9::default()); - config.container = hang::catalog::Container::Legacy; - config.timeline = Some(self.rendition.catalog.timeline(&name)?.section()); - // Publish explicitly rather than through the guard's drop, which only warns: - // marking the rendition announced when the catalog never took it would leave the - // media track advertised nowhere, and `announced` latches so we'd never retry. - let mut guard = self.rendition.catalog.lock(); - guard.video.renditions.insert(name, config); - guard.commit()?; - self.announced = true; - Ok(()) + /// Publish a `.vp9` track on `broadcast`, adding the catalog rendition once config is known. + pub fn new(broadcast: moq_net::broadcast::Producer, catalog: moq_mux::catalog::Producer) -> Result { + let import = codec::DeferredVideo::new(broadcast, catalog, ".vp9")?; + Ok(Self { import }) } } impl codec::Bridge for Bridge { fn push(&mut self, frame: codec::Frame) -> Result<()> { - self.announce()?; let pts = moq_net::Timestamp::from_micros(frame.timestamp_us) .map_err(|err| crate::Error::Other(anyhow::anyhow!("invalid timestamp: {err}")))?; - let keyframe = is_keyframe(&frame.payload); - self.track - .write(moq_mux::container::Frame { - timestamp: pts, - payload: frame.payload, - keyframe, - duration: None, - }) - .map_err(|err| crate::Error::Other(anyhow::anyhow!("vp9 track write failed: {err}")))?; - Ok(()) + self.import.decode(frame.payload, pts) } fn abort(self: Box, err: moq_net::Error) { - self.track.abort(err); - } -} - -/// Detect a VP9 keyframe from the uncompressed header's first byte (VP9 spec -/// §6.2), reading bits MSB-first: `frame_marker(2)`, `profile_low(1)`, -/// `profile_high(1)`, a `reserved(1)` bit only when profile == 3, -/// `show_existing_frame(1)`, then `frame_type(1)` (0 == KEY_FRAME). A -/// show-existing frame carries no frame_type and is never a keyframe. -fn is_keyframe(payload: &[u8]) -> bool { - let Some(&b) = payload.first() else { - return false; - }; - let profile = (((b >> 4) & 1) << 1) | ((b >> 5) & 1); // (high << 1) | low - // Bits consumed from the MSB: 2 (marker) + 2 (profile), plus profile 3's reserved bit. - let mut pos = 4; - if profile == 3 { - pos += 1; + self.import.abort(err); } - let show_existing_frame = (b >> (7 - pos)) & 1; - if show_existing_frame == 1 { - return false; - } - pos += 1; - let frame_type = (b >> (7 - pos)) & 1; - frame_type == 0 } #[cfg(test)] mod tests { - use super::is_keyframe; + use bytes::Bytes; - // frame_marker = 0b10 in the top two bits for every well-formed header. - #[test] - fn profile0_keyframe_and_interframe() { - // profile 0, show_existing_frame = 0, frame_type = 0 (key) / 1 (inter). - assert!(is_keyframe(&[0b1000_0010])); - assert!(!is_keyframe(&[0b1000_0110])); - } - - #[test] - fn profile0_show_existing_frame_is_not_keyframe() { - // profile 0, show_existing_frame = 1: no frame_type follows. - assert!(!is_keyframe(&[0b1000_1000])); - } + use crate::codec::{self, Bridge as _}; #[test] - fn profile3_keyframe_and_interframe() { - // profile 3 (both profile bits set) inserts a reserved bit before - // show_existing_frame, shifting frame_type one position right. - assert!(is_keyframe(&[0b1011_0000])); // reserved=0, show=0, frame_type=0 - assert!(!is_keyframe(&[0b1011_0010])); // frame_type=1 - } + fn keyframe_publishes_catalog_dimensions() { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = moq_mux::catalog::Producer::new(&mut broadcast).unwrap(); + let mut bridge = super::Bridge::new(broadcast, catalog.clone()).unwrap(); + + assert!(catalog.snapshot().video.renditions.is_empty()); + + // VP9 profile 0 keyframe header for 320x240. + bridge + .push(codec::Frame { + timestamp_us: 0, + payload: Bytes::from_static(&[0x82, 0x49, 0x83, 0x42, 0x20, 0x13, 0xf0, 0x0e, 0xf0, 0x00]), + }) + .unwrap(); - #[test] - fn empty_payload_is_not_keyframe() { - assert!(!is_keyframe(&[])); + let snapshot = catalog.snapshot(); + let config = snapshot.video.renditions.values().next().unwrap(); + assert_eq!(config.coded_width, Some(320)); + assert_eq!(config.coded_height, Some(240)); } } From 6ce4878718c5898d316b90690a8aa724c6cb1fd6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 14 Aug 2026 13:24:58 -0700 Subject: [PATCH 03/12] fix(net): stop blocking connect on the initial announce set (#2856) Co-authored-by: Claude Opus 5 --- cpp/obs/src/moq-source.cpp | 34 ++++++------ doc/lib/py/moq-rs.md | 2 + rs/moq-cli/src/transcode.rs | 27 +++++++--- rs/moq-ffi/src/origin.rs | 6 +++ rs/moq-net/src/client.rs | 75 +++++++++++++++----------- rs/moq-net/src/lite/connecting.rs | 75 -------------------------- rs/moq-net/src/lite/mod.rs | 2 - rs/moq-net/src/lite/session.rs | 28 ++-------- rs/moq-net/src/lite/subscriber.rs | 74 +++++-------------------- rs/moq-net/src/lite/test_transport.rs | 18 ++++++- rs/moq-net/src/model/origin.rs | 9 ++-- rs/moq-net/src/session.rs | 20 +------ rs/moq-relay/tests/cluster_unknown.rs | 9 ++-- rs/moq-transcode/examples/transcode.rs | 27 +++++++--- 14 files changed, 156 insertions(+), 250 deletions(-) delete mode 100644 rs/moq-net/src/lite/connecting.rs diff --git a/cpp/obs/src/moq-source.cpp b/cpp/obs/src/moq-source.cpp index 5bf653b37a..22895affb6 100644 --- a/cpp/obs/src/moq-source.cpp +++ b/cpp/obs/src/moq-source.cpp @@ -142,9 +142,9 @@ struct subscription_ref { subscription_ref &operator=(const subscription_ref &) = delete; }; -// user_data for a single moq_origin_request. The generation must travel with the -// request rather than live on ctx: a reconnect can issue a new request while an -// older one still has a delivery in flight, and a single slot on ctx would let +// user_data for a single moq_origin_consume_announced. The generation must travel +// with the request rather than live on ctx: a reconnect can issue a new request while +// an older one still has a delivery in flight, and a single slot on ctx would let // that stale delivery read the new generation and pass the staleness check. // Allocated before the request exists and freed by its terminal on_broadcast. struct broadcast_request { @@ -698,11 +698,14 @@ static void moq_source_start_consume(struct moq_source *ctx, uint32_t expected_g req->ctx = ctx; req->gen = expected_gen; - // Resolve the broadcast by path against what is announced now plus any - // dynamic fallback, failing if neither can serve it. libmoq copies the path, + // Wait for the broadcast to be announced. This runs off the session-connected + // callback, and announcements arrive over the session after it connects, so + // resolving against only what is announced *now* (moq_origin_request) would race + // them and blank the source for a broadcast that is live. libmoq copies the path, // so it need not outlive this call, and delivers the broadcast handle // asynchronously to on_broadcast. - int32_t request = moq_origin_request(origin, broadcast_copy, strlen(broadcast_copy), on_broadcast, req); + int32_t request = + moq_origin_consume_announced(origin, broadcast_copy, strlen(broadcast_copy), on_broadcast, req); if (request < 0) { LOG_ERROR("Failed to request broadcast '%s': %d", broadcast_copy, request); bfree(broadcast_copy); @@ -730,15 +733,15 @@ static void moq_source_start_consume(struct moq_source *ctx, uint32_t expected_g } else { // Stale or shutting down: close it; its terminal releases the reference. pthread_mutex_unlock(&ctx->mutex); - moq_origin_request_close(request); + moq_origin_consume_announced_close(request); } } -// Receives the broadcast resolved by moq_origin_request: a positive handle once -// served, then exactly once more with a terminal code (0 = finished, including -// after moq_origin_request_close; < 0 = could not be served). The terminal is the -// last touch of user_data, so it both frees the request context and releases the -// request's lifetime reference via subscription_ref. +// Receives the announced broadcast: a positive handle once announced, then exactly +// once more with a terminal code (0 = finished, including after +// moq_origin_consume_announced_close; < 0 = error). The terminal is the last touch +// of user_data, so it both frees the request context and releases the request's +// lifetime reference via subscription_ref. static void on_broadcast(void *user_data, int32_t broadcast) { struct broadcast_request *req = (struct broadcast_request *)user_data; @@ -843,10 +846,11 @@ static void moq_source_disconnect_locked(struct moq_source *ctx) ctx->catalog_handle = -1; } - // An unresolved request still owes a terminal on_broadcast; closing it makes - // that fire (with 0) instead of leaving it pending until the source dies. + // An unresolved wait still owes a terminal on_broadcast; closing it makes that + // fire (with 0) instead of leaving it pending until the source dies. This is the + // path that ends a wait for a broadcast that is never announced. if (ctx->request >= 0) { - moq_origin_request_close(ctx->request); + moq_origin_consume_announced_close(ctx->request); ctx->request = -1; } diff --git a/doc/lib/py/moq-rs.md b/doc/lib/py/moq-rs.md index 7cb3076cb8..0cba8422f7 100644 --- a/doc/lib/py/moq-rs.md +++ b/doc/lib/py/moq-rs.md @@ -325,6 +325,8 @@ broadcast = await client.announced_broadcast("live/cam1") broadcast = await client.request_broadcast("live/cam1") ``` +Announcements arrive over the session after it connects, so `request_broadcast` on its own races them: right after connecting it can raise for a broadcast that is live. Await `announced_broadcast(path)` first when you know the path you want; `request_broadcast` is for a path a dynamic handler serves, or one you already know is announced. + Each broadcast carries a `Route`: `route.hops` is the chain of relay origin ids (as `list[int]`) the broadcast passed through to reach you, oldest first, and `route.cost` is the publisher's advertised preference (lower wins). The route is dynamic; `await broadcast.route_changed()` returns the current route first, then blocks for each change (e.g. an upstream failover), and returns `None` once the broadcast ends. A publisher advertises its own route with `producer.set_route(moq.Route(hops=[], cost=10))`, for example a standby transcoder that lowers its cost to 0 once it is warm. ## Examples diff --git a/rs/moq-cli/src/transcode.rs b/rs/moq-cli/src/transcode.rs index ec02afc693..7217d1956a 100644 --- a/rs/moq-cli/src/transcode.rs +++ b/rs/moq-cli/src/transcode.rs @@ -86,19 +86,32 @@ pub async fn run(moq: MoqSide, args: Args, net: Net) -> anyhow::Result<()> { .context("`transcode` requires a relay: pass --client-connect ")?; let publish = moq_net::Origin::random().produce(); let remote = moq_net::Origin::random().produce(); - let mut session = net + let session = net .client(moq.client.clone())? .with_publisher(&publish) .with_subscriber(remote.clone()) .reconnect(url); - // Wait for the first session: the origin can't route a broadcast request - // until a connected session registers its handler. - while !matches!(session.status().await?, moq_native::Status::Connected) {} + // Wait for the source to be announced rather than for the session to connect: + // `request_broadcast` answers on the spot, so asking the moment a session exists + // races the announcement that makes the path routable. + // + // Raced against the session ending, since the wait itself never fails: the origin + // outlives the session here, so a rejected token or an exhausted retry budget would + // otherwise leave us waiting for an announcement that can never arrive. + let consumer = remote.consume(); + tokio::select! { + announced = consumer.announced_broadcast(&source_path) => { + announced.context("origin closed before the source broadcast was announced")?; + } + closed = session.closed() => { + closed.context("session failed before the source broadcast was announced")?; + anyhow::bail!("session closed before the source broadcast was announced"); + } + } - // Request the source broadcast; the session subscribes upstream on demand. - let source = remote - .consume() + // Resolve it for real; the session subscribes upstream on demand. + let source = consumer .request_broadcast(&source_path) .await .context("source broadcast unavailable")?; diff --git a/rs/moq-ffi/src/origin.rs b/rs/moq-ffi/src/origin.rs index abe8c2dab7..685bf73f29 100644 --- a/rs/moq-ffi/src/origin.rs +++ b/rs/moq-ffi/src/origin.rs @@ -261,6 +261,9 @@ impl MoqOriginConsumer { } /// Wait for a specific broadcast to be announced by path. + /// + /// This is how you resolve a path right after connecting: announcements arrive over the + /// session after it opens, so `request_broadcast` on its own races them. pub fn announced_broadcast(&self, path: String) -> Result, MoqError> { let _guard = crate::ffi::RUNTIME.enter(); let origin = self.inner.with_root(path).ok_or(MoqError::Unauthorized)?; @@ -278,6 +281,9 @@ impl MoqOriginConsumer { /// errors if nothing can serve it. Unlike `announced_broadcast`, this does *not* wait /// indefinitely for a future announcement: it resolves or fails based on what is /// announced now plus any dynamic fallback. Drop the returned future to cancel. + /// + /// Calling this straight after connecting therefore races the session's announcements + /// and can report a live broadcast as unroutable. Await `announced_broadcast` first. pub async fn request_broadcast(&self, path: String) -> Result, MoqError> { let broadcast = self.inner.request_broadcast(path.as_str()).await?; Ok(Arc::new(MoqBroadcastConsumer::new(broadcast))) diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 65c88a3be1..38e28c1f99 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -281,13 +281,12 @@ impl Client { peer_setup: None, })?; - // Block until the initial announce set has landed (Lite05+ reports it - // via AnnounceOk + N), so a `request_broadcast()` for a live path resolves - // immediately instead of racing announcement gossip. - let (session, mut driver) = Session::new(session, version.into(), start.recv_bandwidth, start.driver); - driver.wait_ready(|waiter| start.connecting.poll_ready(waiter)).await; - - return Ok((session, driver)); + return Ok(Session::new( + session, + version.into(), + start.recv_bandwidth, + start.driver, + )); } Some(ALPN_LITE_04) => { self.versions @@ -305,16 +304,12 @@ impl Client { peer_setup: None, })?; - // Lite04 has no initial-set boundary, so this resolves immediately. - let (session, mut driver) = Session::new( + return Ok(Session::new( session, lite::Version::Lite04.into(), start.recv_bandwidth, start.driver, - ); - driver.wait_ready(|waiter| start.connecting.poll_ready(waiter)).await; - - return Ok((session, driver)); + )); } Some(ALPN_LITE_03) => { self.versions @@ -333,16 +328,12 @@ impl Client { peer_setup: None, })?; - // Lite03 has no initial-set boundary, so this resolves immediately. - let (session, mut driver) = Session::new( + return Ok(Session::new( session, lite::Version::Lite03.into(), start.recv_bandwidth, start.driver, - ); - driver.wait_ready(|waiter| start.connecting.poll_ready(waiter)).await; - - return Ok((session, driver)); + )); } Some(ALPN_LITE) | None => { let supported = self.versions.filter(&NEGOTIATED.into()).ok_or(Error::Version)?; @@ -381,7 +372,7 @@ impl Client { .copied() .ok_or(Error::Version)?; - let (recv_bw, protocol, connecting) = match version { + let (recv_bw, protocol) = match version { Version::Lite(v) => { let stream = stream.with_version(v); let start = lite::start(lite::Config { @@ -397,7 +388,7 @@ impl Client { peer_setup: None, })?; - (start.recv_bandwidth, start.driver, Some(start.connecting)) + (start.recv_bandwidth, start.driver) } Version::Ietf(v) => { // Decode the parameters to get the initial request ID and what the server @@ -427,18 +418,11 @@ impl Client { peer_setup_stream: None, peer_declared: Some(peer_declared), })?; - (None, protocol, None) + (None, protocol) } }; - let (session, mut driver) = Session::new(session, version, recv_bw, protocol); - if let Some(connecting) = connecting { - // Block until the initial announce set has landed (for versions that - // report one); resolves immediately otherwise. - driver.wait_ready(|waiter| connecting.poll_ready(waiter)).await; - } - - Ok((session, driver)) + Ok(Session::new(session, version, recv_bw, protocol)) } } @@ -673,7 +657,10 @@ mod tests { .into(), ); - let _connection = client.connect(fake.clone()).await.unwrap(); + // `connect` returns as soon as the handshake completes and never polls the driver, + // so the session makes no progress (and never closes) unless we drive it here. + let (_session, driver) = client.connect(fake.clone()).await.unwrap(); + let _driver = tokio::spawn(driver); // Verify the client setup was encoded using Draft14 framing (ALPN_LITE fallback path). let mut setup_bytes = Bytes::from(fake.control_writes()); @@ -698,6 +685,32 @@ mod tests { assert_ne!(code, Error::Version.to_code(), "SessionInfo failed to decode"); } + /// `connect` must not depend on the peer answering. A peer that opens the announce + /// stream and then says nothing (or promises a count it never delivers) used to hold + /// `connect` for the life of the session, since it waited for the initial announce + /// set. Resolving a path you need is `announced_broadcast`'s job, which waits for + /// that path rather than for the peer to finish talking. + #[tokio::test(start_paused = true)] + async fn connect_does_not_wait_for_the_peer_to_announce() { + // Serves bidi streams, so the announce stream opens, and never answers on them. + let gate = kio::Producer::new(true); + let transport = crate::lite::test_transport::SinkSession::gated_bi(gate.consume()) + .with_protocol(crate::version::ALPN_LITE_05); + + // A subscribe origin is what makes the client open an announce stream at all. + let origin = crate::origin::Info::new(crate::Origin::new(1).unwrap()).produce(); + let client = Client::new() + .with_versions([Version::Lite(lite::Version::Lite05)].into()) + .with_subscriber(origin); + + // Paused time auto-advances while every task is idle, so a `connect` that waits + // on the silent peer trips this rather than hanging the suite. + tokio::time::timeout(std::time::Duration::from_secs(30), client.connect(transport)) + .await + .expect("connect waited on a peer that never announced") + .expect("connect failed"); + } + #[tokio::test(start_paused = true)] async fn alpn_lite_falls_back_to_draft14_and_switches_version_post_setup() { run_alpn_lite_fallback_case(Some(ALPN_LITE)).await; diff --git a/rs/moq-net/src/lite/connecting.rs b/rs/moq-net/src/lite/connecting.rs deleted file mode 100644 index b02cc3fd0e..0000000000 --- a/rs/moq-net/src/lite/connecting.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Tracks a session's connection progress so `connect()` can block until it's done. -//! -//! Today "connecting" means every announce-prefix stream has received its initial -//! set (AnnounceInit for Lite01/02, AnnounceOk + N for Lite05). It's deliberately -//! generic so future work (e.g. extension negotiation) can register additional -//! steps that must finish before a session is considered connected. -//! -//! Backed by `kio`: each in-flight step holds a [`ConnectingProducer`], and the -//! session is connected once they've all been dropped (which closes the channel). -//! A step drops its producer when it finishes (or, on an early error, when it goes -//! out of scope), so a failed step can't hang `connect()`. Prefer `kio` over `tokio` -//! primitives for new async state so the synchronous poll API stays available. - -use std::task::Poll; - -use kio::{Consumer, Producer, Waiter}; - -/// Producer side: hold one per in-flight connection step (e.g. one per announce -/// prefix). Clone it to add a step; drop it to mark that step done. The session is -/// connected once every producer has been dropped. -/// -/// The inner producer exists purely for its `Clone` (adds a step) and `Drop` (closes -/// the channel when the last one goes); it is never read, hence the allow. -#[derive(Clone)] -pub(super) struct ConnectingProducer(#[allow(dead_code)] Producer<()>); - -/// Consumer side: returned by [`crate::lite::start`] and awaited by `connect()`. -pub(crate) struct Connecting(Consumer<()>); - -impl Connecting { - /// Create a producer/consumer pair. The consumer reports "connected" once every - /// [`ConnectingProducer`] (the original plus any clones) has been dropped. - pub(super) fn new() -> (ConnectingProducer, Self) { - let producer = Producer::new(()); - let consumer = producer.consume(); - (ConnectingProducer(producer), Self(consumer)) - } - - /// Poll for connection completion: ready once every step's producer has dropped. - pub(crate) fn poll_ready(&self, waiter: &Waiter) -> Poll<()> { - self.0.poll_closed(waiter) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ready_once_every_producer_dropped() { - let (producer, connecting) = Connecting::new(); - let noop = kio::Waiter::noop(); - let second = producer.clone(); - assert!( - connecting.poll_ready(&noop).is_pending(), - "not ready while a producer lives" - ); - drop(producer); - assert!(connecting.poll_ready(&noop).is_pending(), "still a clone outstanding"); - drop(second); - assert!( - connecting.poll_ready(&noop).is_ready(), - "ready once the last producer drops" - ); - } - - #[test] - fn ready_when_sole_producer_dropped() { - // No steps registered (a version with no initial-set boundary, or an empty - // origin): dropping the only producer resolves immediately. - let (producer, connecting) = Connecting::new(); - drop(producer); - assert!(connecting.poll_ready(&kio::Waiter::noop()).is_ready()); - } -} diff --git a/rs/moq-net/src/lite/mod.rs b/rs/moq-net/src/lite/mod.rs index 3a2979685e..bafa81b58c 100644 --- a/rs/moq-net/src/lite/mod.rs +++ b/rs/moq-net/src/lite/mod.rs @@ -5,7 +5,6 @@ //! Specification: [] mod announce; -mod connecting; mod datagram; mod fetch; mod goaway; @@ -27,7 +26,6 @@ mod track; mod version; pub use announce::*; -pub(crate) use connecting::*; #[allow(unused_imports)] pub use datagram::*; #[allow(unused_imports)] diff --git a/rs/moq-net/src/lite/session.rs b/rs/moq-net/src/lite/session.rs index 643dba0c2b..ff77e294c7 100644 --- a/rs/moq-net/src/lite/session.rs +++ b/rs/moq-net/src/lite/session.rs @@ -8,13 +8,10 @@ use crate::{ use std::task::Poll; -use super::{ - Connecting, DataType, PeerSetup, Publisher, PublisherConfig, Setup, Subscriber, SubscriberConfig, Version, -}; +use super::{DataType, PeerSetup, Publisher, PublisherConfig, Setup, Subscriber, SubscriberConfig, Version}; pub(crate) struct SessionStart { pub recv_bandwidth: Option, - pub connecting: Connecting, pub driver: MaybeSendBox<'static, Result<(), Error>>, } @@ -82,10 +79,7 @@ pub struct Config { /// Start a lite session. /// -/// Returns the receive-bandwidth consumer (if any) and a [`Connecting`] handle that -/// becomes ready once the initial announce set has been inserted into the subscribe -/// origin, letting `connect()` block past the startup race. It is ready immediately -/// when there is nothing to wait on (a version without an initial-set boundary). +/// Returns the receive-bandwidth consumer (if any) plus the driver that runs the session. pub fn start(config: Config) -> Result { let Config { session, @@ -110,18 +104,6 @@ pub fn start(config: Config) -> Result Some(recv_bw), }; - // Connection-progress tracker. Only block on the initial set for versions with an - // initial-set boundary (AnnounceInit for Lite01/02, AnnounceOk for Lite05+). For other - // versions we drop the producer here, which closes the channel and makes - // `Connecting::ready` resolve immediately. An empty subscribe origin also resolves - // immediately because the subscriber arms with a prefix count of zero. - let (connecting_producer, connecting) = Connecting::new(); - let sub_connecting = if matches!(version, Version::Lite01 | Version::Lite02) || version.has_announce_ok() { - Some(connecting_producer) - } else { - None - }; - // Declare our origin (hop) id in SETUP so the peer can serve our // subscriptions from a route that does not flow through us. Taken from the // caller's real handles before the empty-half defaulting below, since those @@ -140,8 +122,7 @@ pub fn start(config: Config) -> Result(config: Config) -> Result(config: Config) -> Result Subscriber { } } - /// `connecting` is the connection-progress producer for this session (None for - /// versions with no initial-set boundary). It is threaded through the announce path - /// rather than stored on `Subscriber`: the struct is cloned for several long-lived - /// tasks (`bw`, `run_uni`), and any clone retaining a producer would keep the channel - /// open and hang `connect()`. - pub async fn run(self, connecting: Option, mut tasks: TaskSet) -> Result<(), Error> { + pub async fn run(self, mut tasks: TaskSet) -> Result<(), Error> { let bw = self.clone(); let dg = self.clone(); // The watchdog halves (announce/bandwidth/datagrams) only end the session on // error; their clean completion parks and the other futures keep running. - let mut announce = std::pin::pin!(err_only(self.clone().run_announce(connecting))); + let mut announce = std::pin::pin!(err_only(self.clone().run_announce())); let mut uni = std::pin::pin!(self.run_uni()); let mut bandwidth = std::pin::pin!(err_only(bw.run_recv_bandwidth())); let mut datagrams = std::pin::pin!(err_only(dg.run_datagrams())); @@ -217,19 +212,14 @@ impl Subscriber { Ok(()) } - async fn run_announce(self, connecting: Option) -> Result<(), Error> { + async fn run_announce(self) -> Result<(), Error> { let prefixes: Vec = self.origin.allowed().map(|p| p.to_owned()).collect(); let mut tasks = FuturesUnordered::new(); for prefix in prefixes { - tasks.push(self.clone().run_announce_prefix(prefix, connecting.clone())); + tasks.push(self.clone().run_announce_prefix(prefix)); } - // Each prefix holds its own producer clone; drop ours so the channel closes (and - // connect() unblocks) once the last prefix finishes its initial set. With no - // prefixes, this is the only producer, so the session is connected now. - drop(connecting); - while let Some(result) = tasks.next().await { result?; } @@ -237,11 +227,7 @@ impl Subscriber { Ok(()) } - async fn run_announce_prefix( - mut self, - prefix: PathOwned, - mut connecting: Option, - ) -> Result<(), Error> { + async fn run_announce_prefix(mut self, prefix: PathOwned) -> Result<(), Error> { let mut stream = Stream::open(&self.session, self.version).await?; stream.writer.encode(&lite::ControlType::Announce).await?; @@ -254,10 +240,12 @@ impl Subscriber { }; stream.writer.encode(&msg).await?; - // Lite05+: the publisher reports its own origin id (which we stamp onto every - // received Announce's hop chain, since it no longer does so itself) plus the - // count of initial active announces that follow immediately. - let (responder_origin, initial_count) = if self.version.has_announce_ok() { + // Lite05+: the publisher reports its own origin id, which we stamp onto every + // received Announce's hop chain since it no longer does so itself. Its `active` + // count marks where the initial set ends; nothing here needs that boundary, so + // it is read and dropped. Callers that must not race an announcement use + // `origin::Consumer::announced_broadcast`, which waits for the path itself. + let responder_origin = if self.version.has_announce_ok() { let ok: lite::AnnounceOk = stream.reader.decode().await?; // A peer may legally report id 0 (no identity). When the caller assigned // it one, stand that in so the route isn't loop-blind. @@ -265,9 +253,9 @@ impl Subscriber { 0 => self.peer_origin.unwrap_or(ok.origin), _ => ok.origin, }; - (Some(origin), ok.active) + Some(origin) } else { - (None, 0) + None }; // What we charge every announcement arriving on this stream. Resolved once: @@ -285,12 +273,6 @@ impl Subscriber { let mut next_announce_id: u64 = 0; let mut announced_by_id: HashMap = HashMap::new(); - // `connecting` is a local (a param), not a `self` field, so the `self.clone()` that - // start_announce uses for long-lived broadcast tasks doesn't carry the producer - // (which would keep the channel open for the broadcast's lifetime). Dropping it marks - // this prefix connected; on an early error it drops via scope exit, so a failed prefix - // can't hang connect(). - match self.version { Version::Lite01 | Version::Lite02 => { let msg: lite::AnnounceInit = stream.reader.decode().await?; @@ -314,26 +296,6 @@ impl Subscriber { } } - // Release the producer once this prefix's initial set is in. Lite01/02 delivered it - // via AnnounceInit (consumed just above); Lite05 delivers `initial_count` - // Announce::Active counted in the loop below; Lite03/04 have no boundary (already None). - let mut initial_remaining = match self.version { - Version::Lite01 | Version::Lite02 => { - connecting.take(); - 0 - } - _ if self.version.has_announce_ok() => { - if initial_count == 0 { - connecting.take(); - } - initial_count - } - _ => { - connecting.take(); - 0 - } - }; - while let Some(announce) = stream.reader.decode_maybe::().await? { match announce { lite::AnnounceBroadcast::Active { suffix, hops, cost } => { @@ -355,14 +317,6 @@ impl Subscriber { } else { self.start_announce(path.clone(), hops, cost, link_cost, responder_origin, &mut routes)?; } - // The first `initial_count` Active messages are the initial set; once - // they're all in, drop our producer to mark this prefix connected. - if initial_remaining > 0 { - initial_remaining -= 1; - if initial_remaining == 0 { - connecting.take(); - } - } } lite::AnnounceBroadcast::Ended { suffix, .. } => { let path = prefix.join(&suffix); diff --git a/rs/moq-net/src/lite/test_transport.rs b/rs/moq-net/src/lite/test_transport.rs index b09e840c3a..5f127bdd92 100644 --- a/rs/moq-net/src/lite/test_transport.rs +++ b/rs/moq-net/src/lite/test_transport.rs @@ -291,11 +291,24 @@ pub struct SinkSession { /// Set by [`Self::gated_bi`]. `None` parks `open_bi` itself forever, which is all /// a test driving only uni streams needs. bi_gate: Option>, + /// The ALPN to report, for a test that needs a specific negotiated version rather + /// than the SETUP-negotiated fallback an absent one selects. + protocol: Option<&'static str>, } impl SinkSession { pub fn new(log: Log) -> Self { - Self { log, bi_gate: None } + Self { + log, + bi_gate: None, + protocol: None, + } + } + + /// Report `protocol` as the negotiated ALPN. + pub fn with_protocol(mut self, protocol: &'static str) -> Self { + self.protocol = Some(protocol); + self } /// Serve bidi streams, holding every write until `gate` flips to true. @@ -307,6 +320,7 @@ impl SinkSession { Self { log: Log::default(), bi_gate: Some(gate), + protocol: None, } } } @@ -355,7 +369,7 @@ impl web_transport_trait::Session for SinkSession { } fn protocol(&self) -> Option<&str> { - None + self.protocol } fn close(&self, code: u32, reason: &str) { diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index c4faa0e016..37eb847ce8 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -2762,11 +2762,10 @@ impl Consumer { /// is closed before the broadcast is announced. The returned broadcast may itself be closed /// later. Subscribers should watch [`broadcast::Consumer::closed`] to react to that. /// - /// Prefer this over [`Self::request_broadcast`] when you know the exact path you want but - /// cannot guarantee the announcement has already been received. With moq-lite-05 (and - /// the older Lite01/02) `connect()` already blocks until the initial announce set lands, - /// so [`Self::request_broadcast`] is race-free for broadcasts that were live at connect time; - /// this method is still needed to wait for a broadcast that comes online *after* connect. + /// Use this whenever you know the exact path you want and cannot guarantee its + /// announcement has already arrived, which includes every path you resolve right after + /// connecting: [`Self::request_broadcast`] answers on the spot, so asking it first + /// races the announcement and reports a live broadcast as unroutable. pub async fn announced_broadcast(&self, path: impl AsPath) -> Option { let path = path.as_path(); diff --git a/rs/moq-net/src/session.rs b/rs/moq-net/src/session.rs index 56c1d27608..eb5bad7f46 100644 --- a/rs/moq-net/src/session.rs +++ b/rs/moq-net/src/session.rs @@ -140,8 +140,7 @@ struct DriverState { // transport reports no send-rate estimate), since a completed future must not be // polled again. maintenance: Option>, - // Cached so a poll after completion (e.g. after `wait_ready` consumed the - // result) doesn't re-poll a finished future. + // Cached so a poll after completion doesn't re-poll a finished future. result: Option>, } @@ -153,23 +152,6 @@ impl Driver { pub fn poll(&mut self, waiter: &kio::Waiter) -> Poll> { self.state.poll(waiter) } - - /// Drive the session until the readiness condition resolves, so `connect` can block on the - /// initial announce set. - /// - /// A session that dies first still resolves readiness: the connecting producers - /// live inside the driver, so its completion drops them and releases the barrier. - /// The error isn't lost, it's cached for whoever drives the session next. - pub(super) async fn wait_ready(&mut self, poll_ready: impl Fn(&kio::Waiter) -> Poll<()>) { - kio::wait(|waiter| { - if poll_ready(waiter).is_ready() { - return Poll::Ready(()); - } - let _ = self.poll(waiter); - Poll::Pending - }) - .await - } } impl DriverState { diff --git a/rs/moq-relay/tests/cluster_unknown.rs b/rs/moq-relay/tests/cluster_unknown.rs index 8b0c90a33e..af6d0cac6f 100644 --- a/rs/moq-relay/tests/cluster_unknown.rs +++ b/rs/moq-relay/tests/cluster_unknown.rs @@ -136,10 +136,13 @@ async fn read_first_frame(port: u16) -> Result, String> { .map_err(|_| "subscriber connect timeout".to_string())? .map_err(|err| format!("subscriber connect failed: {err}"))?; - let broadcast = tokio::time::timeout(TIMEOUT, consumer.request_broadcast(PATH)) + // Wait for the announcement rather than asking the moment the session connects: + // `request_broadcast` answers on the spot, so it would race the announcement that + // makes the path routable. + let broadcast = tokio::time::timeout(TIMEOUT, consumer.announced_broadcast(PATH)) .await - .map_err(|_| "request_broadcast timed out".to_string())? - .map_err(|err| format!("request_broadcast failed: {err}"))?; + .map_err(|_| "announced_broadcast timed out".to_string())? + .ok_or_else(|| "origin closed before the broadcast was announced".to_string())?; let mut track = tokio::time::timeout(TIMEOUT, broadcast.track("video").expect("track handle").subscribe(None)) .await diff --git a/rs/moq-transcode/examples/transcode.rs b/rs/moq-transcode/examples/transcode.rs index 2109987f44..b462a63780 100644 --- a/rs/moq-transcode/examples/transcode.rs +++ b/rs/moq-transcode/examples/transcode.rs @@ -43,18 +43,31 @@ async fn main() -> anyhow::Result<()> { let remote = moq_net::Origin::random().produce(); let client = moq_native::ClientConfig::default().init()?; - let mut session = client + let session = client .with_publisher(&publish) .with_subscriber(remote.clone()) .reconnect(args.url.clone()); - // Wait for the first session: the origin can't route a broadcast request - // until a connected session registers its handler. - while !matches!(session.status().await?, moq_native::Status::Connected) {} + // Wait for the source to be announced rather than for the session to connect: + // `request_broadcast` answers on the spot, so asking the moment a session exists + // races the announcement that makes the path routable. + // + // Raced against the session ending, since the wait itself never fails: the origin + // outlives the session here, so a rejected token or an exhausted retry budget would + // otherwise leave us waiting for an announcement that can never arrive. + let consumer = remote.consume(); + tokio::select! { + announced = consumer.announced_broadcast(&args.source) => { + announced.context("origin closed before the source broadcast was announced")?; + } + closed = session.closed() => { + closed.context("session failed before the source broadcast was announced")?; + anyhow::bail!("session closed before the source broadcast was announced"); + } + } - // Request the source broadcast; the session subscribes upstream on demand. - let source = remote - .consume() + // Resolve it for real; the session subscribes upstream on demand. + let source = consumer .request_broadcast(&args.source) .await .context("source broadcast unavailable")?; From ac632a68441af575a2b008ae235e55f1717d376b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 14 Aug 2026 14:23:06 -0700 Subject: [PATCH 04/12] fix(relay): honor server version over WebSocket (#2841) Co-authored-by: GPT-5 --- doc/bin/relay/config.md | 5 ++ rs/moq-relay/src/relay.rs | 4 +- rs/moq-relay/src/web.rs | 11 +++- rs/moq-relay/src/websocket.rs | 107 +++++++++++++++++++++++++--------- rs/moq-relay/tests/smoke.rs | 53 ++++++++++++++++- 5 files changed, 150 insertions(+), 30 deletions(-) diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 2708e4b00f..748e255df0 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -48,6 +48,11 @@ certificate, and Unix sockets add optional peer-credential gating. # is configured below. bind = "[::]:443" +# MoQ versions accepted by QUIC, WebTransport, and WebSocket listeners. +# TCP and Unix stream listeners also accept moq-lite-05 because it carries +# their request path in SETUP. Omit to accept every supported version. +version = ["moq-transport-16"] + # Plaintext qmux over TCP (no TLS, carries no peer identity). Trusted networks # only; a non-loopback bind logs a warning. Requires the `tcp` build feature. [server.tcp] diff --git a/rs/moq-relay/src/relay.rs b/rs/moq-relay/src/relay.rs index 087e14db90..0fb7256540 100644 --- a/rs/moq-relay/src/relay.rs +++ b/rs/moq-relay/src/relay.rs @@ -82,6 +82,7 @@ impl Relay { config.server.quic.max_streams.get_or_insert(DEFAULT_MAX_STREAMS); let mtls_enabled = !config.server.tls.root.is_empty(); + let server_versions = config.server.versions(); #[allow(unused_mut)] let mut server = config.server.init()?; @@ -130,7 +131,8 @@ impl Relay { let cluster = cluster.with_stats(stats.clone()); // Create a web server too. mTLS for HTTPS is opt-in via `--web-https-root`. - let web = Web::new(auth.clone(), cluster.clone(), server.certificates(), config.web); + let web = + Web::new(auth.clone(), cluster.clone(), server.certificates(), config.web).with_versions(server_versions); // Internal (ops) listener (plain HTTP, opt-in via `--internal-listen`) for // /metrics + /health + /nodes, separate from the customer-facing web server. No-op diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index d36663ae19..e48c80517a 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -136,6 +136,7 @@ pub(crate) struct WebState { pub struct Web { state: Arc, config: WebConfig, + versions: moq_net::Versions, health: moq_native::accept::Health, } @@ -153,10 +154,17 @@ impl Web { Self { state, config, + versions: moq_net::Versions::all(), health: moq_native::accept::Health::new("web"), } } + /// Restrict which MoQ versions WebSocket sessions accept, in preference order. + pub fn with_versions(mut self, versions: moq_net::Versions) -> Self { + self.versions = versions; + self + } + /// A live handle to the accept-loop health of the HTTP/HTTPS listeners, for an /// embedder that publishes it (see [`moq_native::accept`]). /// @@ -215,7 +223,8 @@ impl Web { app }; - app.layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET])) + app.layer(Extension(self.versions.clone())) + .layer(CorsLayer::new().allow_origin(Any).allow_methods([Method::GET])) .with_state(self.state.clone()) } diff --git a/rs/moq-relay/src/websocket.rs b/rs/moq-relay/src/websocket.rs index a2e23fcf82..877da34737 100644 --- a/rs/moq-relay/src/websocket.rs +++ b/rs/moq-relay/src/websocket.rs @@ -21,6 +21,7 @@ pub(crate) async fn serve_ws( OriginalUri(uri): OriginalUri, headers: HeaderMap, mtls: Option>, + Extension(versions): Extension, State(state): State>, ) -> axum::response::Result { // If this isn't a WebSocket upgrade (e.g. a plain browser visit), serve @@ -29,7 +30,8 @@ pub(crate) async fn serve_ws( return Ok(landing_response()); }; - let ws = negotiate_subprotocol(ws)?; + let alpns = versions.alpns(); + let ws = negotiate_subprotocol(ws, &alpns)?; let host = uri .authority() @@ -63,7 +65,15 @@ pub(crate) async fn serve_ws( // Unfortunately, we need to convert from Axum to Tungstenite. // Axum uses Tungstenite internally, but it's not exposed to avoid semvar issues. let socket = WebSocketAdapter::new(socket); - let _ = handle_socket(id, socket, alpn, publish, subscribe, stats).await; + let session = SessionInputs { + id, + alpn, + versions, + publish, + subscribe, + stats, + }; + let _ = handle_socket(socket, session).await; })) } @@ -74,15 +84,17 @@ fn request_auth_params(auth: &Auth, host: &str, uri: &Uri) -> Result( - _id: u64, - socket: T, +struct SessionInputs { + id: u64, alpn: Option, + versions: moq_net::Versions, publish: Option, subscribe: Option, stats: Session, -) -> anyhow::Result<()> +} + +#[tracing::instrument("ws", err, skip_all, fields(id = session.id))] +async fn handle_socket(socket: T, session: SessionInputs) -> anyhow::Result<()> where T: futures::Stream> + futures::Sink @@ -90,6 +102,15 @@ where + Unpin + 'static, { + let SessionInputs { + id: _, + alpn, + versions, + publish, + subscribe, + stats, + } = session; + // Wrap the WebSocket in a WebTransport compatibility layer. We have to // forward the negotiated subprotocol explicitly; axum performed the // upgrade, so qmux can't sniff it from the handshake. @@ -109,7 +130,7 @@ where // Only set the side the token actually grants. moq-net defaults the // unset side to a fresh no-op origin, which is fine for a // publish-only or subscribe-only token. - let mut server = moq_net::Server::new().with_stats(stats); + let mut server = moq_net::Server::new().with_versions(versions).with_stats(stats); if let Some(subscribe) = subscribe { server = server.with_publisher(&subscribe); } @@ -123,8 +144,8 @@ where /// Pick a subprotocol for the upgrade, or fail the handshake outright. /// -/// We advertise the full qmux × moq-net subprotocol matrix, with bare qmux -/// fallbacks last. axum picks the first entry that the client also offered, so +/// We advertise the configured qmux × moq-net subprotocol matrix, with bare +/// qmux fallbacks last. axum picks the first entry that the client also offered, so /// a modern client lands on `qmux-01.moq-lite-05`; old clients still match /// `webtransport` or `qmux-00.moql` and negotiate via SETUP. /// @@ -136,8 +157,8 @@ where /// /// A client that offers no subprotocol at all is left alone: it upgrades and /// negotiates the moq version over moq-lite SETUP instead. -fn negotiate_subprotocol(ws: WebSocketUpgrade) -> Result { - let supported = supported_subprotocols(); +fn negotiate_subprotocol(ws: WebSocketUpgrade, alpns: &[&str]) -> Result { + let supported = supported_subprotocols(alpns); if !subprotocols_acceptable(ws.requested_protocols().map(HeaderValue::as_bytes), &supported) { tracing::debug!("rejecting WebSocket upgrade: no supported subprotocol offered"); @@ -180,7 +201,7 @@ const QMUX01_ONLY_ALPNS: &[&str] = &["moqt-18", "moqt-19"]; /// Subprotocols to advertise on the WebSocket upgrade. /// -/// Generates the cross product of [`QMUX_VERSIONS`] × `moq_net::ALPNS`, with +/// Generates the cross product of `alpns` × [`QMUX_VERSIONS`], with /// the bare qmux fallbacks (`qmux-01`, `qmux-00`, `webtransport`) appended /// last so versioned subprotocols always win the exact-string match axum /// performs. Without the versioned entries, axum picks bare `webtransport`, @@ -189,10 +210,10 @@ const QMUX01_ONLY_ALPNS: &[&str] = &["moqt-18", "moqt-19"]; /// /// `qmux-00.moqt-1{8,9}` is excluded: moq-transport-18 and -19 require qmux-01, so /// those pairs are illegal. -fn supported_subprotocols() -> Vec { - let mut out = Vec::with_capacity(QMUX_VERSIONS.len() * moq_net::ALPNS.len() + qmux::ALPNS.len()); - for &version in QMUX_VERSIONS { - for &alpn in moq_net::ALPNS { +fn supported_subprotocols(alpns: &[&str]) -> Vec { + let mut out = Vec::with_capacity(QMUX_VERSIONS.len() * alpns.len() + qmux::ALPNS.len()); + for &alpn in alpns { + for &version in QMUX_VERSIONS { if version == qmux::Version::QMux00 && QMUX01_ONLY_ALPNS.contains(&alpn) { continue; } @@ -384,7 +405,7 @@ mod tests { vec![Some(0xff000012), Some(0xff000013)] ); - let list = supported_subprotocols(); + let list = supported_subprotocols(moq_net::ALPNS); // Newest moq ALPN under the preferred prefix must come first so axum // picks it whenever the client offers it. @@ -424,9 +445,37 @@ mod tests { } } + #[test] + fn supported_subprotocols_only_lists_configured_alpns() { + let list = supported_subprotocols(&["moqt-16"]); + + assert!(list.contains(&"qmux-01.moqt-16".to_string())); + assert!(list.contains(&"qmux-00.moqt-16".to_string())); + assert!(list.iter().all(|entry| !entry.contains("moq-lite"))); + assert!(list.iter().all(|entry| !entry.contains("moqt-19"))); + } + + #[test] + fn supported_subprotocols_preserves_moq_preference_across_qmux_versions() { + let list = supported_subprotocols(&["moqt-16", "moqt-18"]); + let preferred = list + .iter() + .position(|entry| entry == "qmux-00.moqt-16") + .expect("missing preferred moqt-16 pair"); + let newer_qmux = list + .iter() + .position(|entry| entry == "qmux-01.moqt-18") + .expect("missing moqt-18 pair"); + + assert!( + preferred < newer_qmux, + "configured MoQ preference must outrank QMux version preference: {list:?}", + ); + } + #[test] fn subprotocols_acceptable_requires_a_match_when_any_are_offered() { - let supported = supported_subprotocols(); + let supported = supported_subprotocols(moq_net::ALPNS); let known = supported.first().expect("no supported subprotocols").clone(); // Offering nothing is the legacy route: upgrade and negotiate via SETUP. @@ -504,7 +553,7 @@ mod tests { ); // A supported identifier still upgrades. - let supported = supported_subprotocols(); + let supported = supported_subprotocols(moq_net::ALPNS); let known = supported.first().expect("no supported subprotocols"); let status = handshake_status(addr, Some(&format!("bogus-99, {known}"))).await; assert!( @@ -539,7 +588,7 @@ mod tests { let route = any(move |ws: WebSocketUpgrade| { let tx = tx.clone(); async move { - let ws = negotiate_subprotocol(ws)?; + let ws = negotiate_subprotocol(ws, moq_net::ALPNS)?; Ok::<_, StatusCode>(ws.on_upgrade(move |socket| async move { let wire = socket.protocol().and_then(|h| h.to_str().ok()).map(str::to_owned); let socket = WebSocketAdapter::new(socket); @@ -627,7 +676,7 @@ mod tests { let (addr, mut rx) = spawn_test_server().await; let url = format!("ws://{addr}/"); - for entry in supported_subprotocols() { + for entry in supported_subprotocols(moq_net::ALPNS) { // Bare fallbacks can't be offered in isolation via the qmux client API; // they're covered by `axum_ws_negotiates_newest_moq_alpn`. let Some((version, app)) = split_pair(&entry) else { @@ -751,13 +800,17 @@ mod tests { let (server_to_client, client_incoming) = mpsc::unbounded_channel(); let frozen = Arc::new(AtomicBool::new(false)); + let session = SessionInputs { + id: 0, + alpn: Some(alpn.clone()), + versions: moq_net::Versions::all(), + publish: None, + subscribe: None, + stats: Session::default(), + }; let server = tokio::spawn(handle_socket( - 0, Pipe::new(server_incoming, server_to_client, frozen.clone()), - Some(alpn.clone()), - None, - None, - Session::default(), + session, )); // A real qmux peer, so the transport handshake completes and its 10s diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index bde294b080..7e7a137371 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -10,7 +10,7 @@ use std::{net::TcpListener, time::Duration}; use moq_native::moq_net::{self, Origin}; -use moq_relay::{AuthConfig, Cluster, ClusterConfig, Connection, PublicConfig, Web, WebConfig}; +use moq_relay::{AuthConfig, Cluster, ClusterConfig, Config, Connection, PublicConfig, Relay, Web, WebConfig}; const TIMEOUT: Duration = Duration::from_secs(10); @@ -119,6 +119,31 @@ async fn spawn_relay() -> (u16, tokio::task::JoinHandle<()>) { (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.server.bind = Some("127.0.0.1:0".to_string()); + config.server.tls.generate = vec!["localhost".into()]; + config.server.version = versions; + config.web.ws = true; + config.web.http.listen = Some(format!("127.0.0.1:{port}").parse().expect("parse listen")); + + #[allow(deprecated)] + let public = PublicConfig::Simple(vec![String::new()]); + config.auth.public = Some(public); + + let relay = Relay::load(config).await.expect("load relay"); + let web = relay.web; + 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; + (port, handle) +} + fn client() -> moq_native::Client { client_version(None) } @@ -215,6 +240,32 @@ async fn relay_websocket_round_trip_uses_newest_version() { web_handle.abort(); } +/// `--server-version` applies to the WebSocket fallback as well as QUIC. +#[tokio::test] +async fn relay_websocket_honors_server_version() { + let allowed: moq_net::Version = "moq-transport-16".parse().expect("parse allowed version"); + let excluded = newest_lite_version(); + let (port, web_handle) = spawn_versioned_relay(vec![allowed]).await; + let url: url::Url = format!("ws://127.0.0.1:{port}/smoke").parse().expect("parse url"); + + let excluded_result = tokio::time::timeout(TIMEOUT, client_version(Some(excluded)).connect(url.clone())) + .await + .expect("excluded client connect timeout"); + assert!( + excluded_result.is_err(), + "WebSocket accepted excluded version {excluded} despite --server-version {allowed}" + ); + + let session = tokio::time::timeout(TIMEOUT, client_version(Some(allowed)).connect(url)) + .await + .expect("allowed client connect timeout") + .expect("allowed client connect failed"); + assert_eq!(session.version(), allowed); + + drop(session); + web_handle.abort(); +} + #[tokio::test] async fn relay_web_serves_merged_routes() { tokio::time::pause(); From 22658064f8f7550f36a11fa9eace92c7004ee2dd Mon Sep 17 00:00:00 2001 From: Shayne Reese <160145735+arctic-uno-0144@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:32:22 -0400 Subject: [PATCH 05/12] fix(moq-net): serve an IETF subscribe from the live edge (#2862) Co-authored-by: sreese Co-authored-by: Claude Opus 5 --- rs/moq-net/src/ietf/publisher.rs | 52 ++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 635fb13c8b..ffc9b5331c 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -678,6 +678,13 @@ impl Publisher { /// Serve a track using FuturesUnordered for unlimited concurrent groups. async fn run_track(&self, mut track: track::Subscriber, request_id: RequestId) -> Result<(), Error> { + // A fresh cursor starts at the oldest cached group, so leaving it there replays the + // whole retained history at once, one concurrent stream per group. LargestObject is + // the only filter we accept and it means the live edge. + if let Some(latest) = track.latest() { + track.start_at(latest); + } + let mut tasks = FuturesUnordered::new(); loop { @@ -1561,6 +1568,51 @@ mod group_priority_test { } } +#[cfg(test)] +mod subscribe_cursor_test { + use super::*; + use crate::lite::test_transport::{Log, SinkSession}; + + /// A subscription's cursor starts at the oldest cached group, so serving it verbatim + /// replays every retained group at once, each on its own stream. Relays reject the burst + /// and players skip straight back to the live edge, so the catch-up is pure waste. + #[tokio::test] + async fn a_subscribe_is_served_from_the_live_edge() { + let log = Log::default(); + let session = SinkSession::new(log.clone()); + + let origin = crate::origin::Info::new(crate::Origin::new(1).unwrap()).produce(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let publisher = Publisher::new( + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + Version::Draft16, + ); + + let mut track = track::Producer::new(std::sync::Arc::new(crate::broadcast::Info::default()), "video", None); + for sequence in 0..4 { + let mut group = track.create_group(group::Info { sequence }).unwrap(); + group + .write_frame(crate::Timestamp::from_millis(0).unwrap(), b"frame".as_slice()) + .unwrap(); + group.finish().unwrap(); + } + + let subscriber = track.subscribe(None); + track.finish().unwrap(); + + publisher.run_track(subscriber, RequestId(1)).await.unwrap(); + + // `run_group` sets the priority once per stream it opens, so this counts groups served. + assert_eq!(log.priorities().len(), 1, "only group 3 should have been served"); + } +} + #[cfg(test)] mod tests { use super::*; From f3ff49319d05b3f95ad0929538cf20f58f315578 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 14 Aug 2026 15:25:52 -0700 Subject: [PATCH 06/12] fix(path): resolve catalog references like URLs (#2855) Resolve catalog broadcast references against the catalog parent while preserving empty self-references and valid root references. Ignore above-root references before media selection, export, and SDP codec discovery, and normalize transcoder-derived references consistently. Co-Authored-By: GPT-5 --- bun.lock | 6 +- doc/concept/layer/hang.md | 6 +- drafts/draft-lcurley-moq-hang.md | 4 +- js/hang/package.json | 2 +- js/hang/src/catalog/audio.ts | 2 +- js/hang/src/catalog/path.ts | 2 +- js/hang/src/catalog/root.test.ts | 23 ++- js/hang/src/catalog/video.ts | 2 +- js/net/package.json | 2 +- js/net/src/consume.ts | 2 +- js/net/src/path.test.ts | 41 +++-- js/net/src/path.ts | 60 +++++-- js/watch/package.json | 2 +- js/watch/src/broadcast.ts | 41 ++++- js/watch/src/video/source.test.ts | 50 +++++- rs/hang/src/catalog/audio/mod.rs | 2 +- rs/hang/src/catalog/root.rs | 27 +++- rs/hang/src/catalog/video/mod.rs | 2 +- rs/moq-cli/src/main.rs | 2 +- rs/moq-cli/src/transcode.rs | 30 ++-- rs/moq-hls/src/export/mod.rs | 23 +++ rs/moq-hls/src/export/renditions.rs | 22 +++ rs/moq-mux/src/codec/h264/export.rs | 7 +- rs/moq-mux/src/codec/h265/export.rs | 7 +- rs/moq-mux/src/container/flv/export.rs | 12 +- rs/moq-mux/src/container/fmp4/export.rs | 9 +- rs/moq-mux/src/container/mkv/export.rs | 12 +- rs/moq-mux/src/container/source.rs | 36 +++-- rs/moq-mux/src/container/ts/export.rs | 10 +- rs/moq-mux/src/error.rs | 4 + rs/moq-mux/src/source.rs | 202 ++++++++++++++++++++---- rs/moq-net/src/path.rs | 130 +++++++++++---- rs/moq-rtc/src/egress.rs | 61 ++++++- rs/moq-rtc/src/server/whep.rs | 2 +- rs/moq-transcode/README.md | 4 +- rs/moq-transcode/examples/transcode.rs | 28 ++-- rs/moq-transcode/src/catalog.rs | 2 +- rs/moq-transcode/src/config.rs | 46 +++++- rs/moq-transcode/src/lib.rs | 6 +- 39 files changed, 736 insertions(+), 195 deletions(-) diff --git a/bun.lock b/bun.lock index 0224ec3d91..2e2fea4460 100644 --- a/bun.lock +++ b/bun.lock @@ -98,7 +98,7 @@ }, "js/hang": { "name": "@moq/hang", - "version": "0.3.5", + "version": "0.4.0", "dependencies": { "@kixelated/libavjs-webcodecs-polyfill": "^0.5.5", "@libav.js/variant-opus-af": "^6.9.8", @@ -182,7 +182,7 @@ }, "js/net": { "name": "@moq/net", - "version": "0.2.8", + "version": "0.3.0", "dependencies": { "@moq/qmux": "^0.3.2", "@moq/signals": "workspace:*", @@ -265,7 +265,7 @@ }, "js/watch": { "name": "@moq/watch", - "version": "0.4.6", + "version": "0.5.0", "dependencies": { "@moq/hang": "workspace:^", "@moq/msf": "workspace:^", diff --git a/doc/concept/layer/hang.md b/doc/concept/layer/hang.md index 8c00feecb4..446d1ce5ef 100644 --- a/doc/concept/layer/hang.md +++ b/doc/concept/layer/hang.md @@ -77,12 +77,12 @@ This is the minimum amount of information required to initialize a video decoder ### Cross-broadcast renditions -A rendition may set an optional `broadcast` field: a path relative to the broadcast that served the catalog (e.g. `"../source"`), pointing at another broadcast that publishes the actual track. -A consumer resolves the reference against the catalog broadcast's own path (`..` pops a segment, other segments append) and subscribes to the track on the resolved broadcast over the same connection. +A rendition may set an optional `broadcast` field: a path relative to the broadcast that served the catalog (e.g. `"./source"`), pointing at another broadcast that publishes the actual track. +A consumer resolves a non-empty reference like a relative URL: it replaces the catalog broadcast's last path segment, then applies `.` and `..` segments. An empty reference names the catalog broadcast itself. When the field is absent, the track lives in the same broadcast as the catalog. This lets a transcoder publish a sidecar catalog that adds new renditions while pointing unchanged ones at the original broadcast, instead of re-publishing those bytes through the transcoder. -For example, a transcoder consuming `room/source` can publish `room/transcode` whose catalog contains a downscaled `480p` rendition plus the original `1080p` rendition marked `"broadcast": "../source"`. +For example, a transcoder consuming `room/source` can publish `room/transcode` whose catalog contains a downscaled `480p` rendition plus the original `1080p` rendition marked `"broadcast": "./source"`. A viewer of `room/transcode` then pulls `480p` from the transcoder and `1080p` directly from the source, and the relay dedupes the source subscription with the transcoder's own. `@moq/watch` resolves the reference automatically. In Rust, the `moq-mux` exporters do the same: they take a `Source::new(origin, path)`, and both the catalog broadcast and any referenced broadcast resolve through the origin over the same connection. diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index 8e64bf5ba9..b52603a8d9 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -270,7 +270,9 @@ By default a rendition's track lives in the same broadcast that served the catal The `broadcast` field overrides that, naming a different broadcast that publishes the track. The value is a relative path, resolved against the path of the broadcast that served the catalog. -It uses the `.` and `..` semantics of a relative URL reference ({{!RFC3986, Section 5.2.4}}), for example `../source`. +It uses relative reference resolution ({{!RFC3986, Section 5.2}}): a non-empty reference replaces the catalog broadcast's last path segment before applying `.` and `..` segments. +For example, `./source` in a catalog served by `room/transcode` resolves to `room/source`, while `.` resolves to `room`. +An empty reference resolves to the catalog broadcast itself. A publisher MUST NOT use an absolute path, and a consumer MUST ignore a rendition whose `broadcast` escapes above the root. This lets a publisher author a catalog that points at tracks it does not republish. diff --git a/js/hang/package.json b/js/hang/package.json index 9d2b0ed442..05050fa58a 100644 --- a/js/hang/package.json +++ b/js/hang/package.json @@ -1,7 +1,7 @@ { "name": "@moq/hang", "type": "module", - "version": "0.3.5", + "version": "0.4.0", "description": "WebCodecs-based media format for MoQ", "license": "(MIT OR Apache-2.0)", "repository": "github:moq-dev/moq", diff --git a/js/hang/src/catalog/audio.ts b/js/hang/src/catalog/audio.ts index ca6e08db5e..28e513363f 100644 --- a/js/hang/src/catalog/audio.ts +++ b/js/hang/src/catalog/audio.ts @@ -16,7 +16,7 @@ const TrackSchema = z.object({ */ export const AudioConfigSchema = z.object({ // Optional reference to another broadcast that publishes this track, expressed - // relative to the broadcast that served this catalog (e.g. "../source"). + // relative to the broadcast that served this catalog (e.g. "./source"). // If unset, the track lives in the same broadcast as the catalog. broadcast: z.optional(RelativeBroadcastSchema), diff --git a/js/hang/src/catalog/path.ts b/js/hang/src/catalog/path.ts index 002f6bb1cb..65f14898b7 100644 --- a/js/hang/src/catalog/path.ts +++ b/js/hang/src/catalog/path.ts @@ -3,7 +3,7 @@ import * as z from "zod/mini"; /** * Zod schema for a relative broadcast reference stored in a catalog (a rendition's - * `broadcast` field, e.g. "../source"). Normalizes the input the same way the Rust + * `broadcast` field, e.g. "./source"). Normalizes the input the same way the Rust * `PathRelative` type does so JS and Rust agree byte-for-byte after deserialization. * Resolve it against the catalog broadcast's own path with `Path.resolve`. */ diff --git a/js/hang/src/catalog/root.test.ts b/js/hang/src/catalog/root.test.ts index bc3a2eb20e..4e555cdcb8 100644 --- a/js/hang/src/catalog/root.test.ts +++ b/js/hang/src/catalog/root.test.ts @@ -27,7 +27,7 @@ test("rendition broadcast reference is parsed and normalized", () => { video: { renditions: { video: { - broadcast: ".././source/", + broadcast: "././source/", codec: "avc1.64001f", container: { kind: "legacy" }, }, @@ -36,8 +36,25 @@ test("rendition broadcast reference is parsed and normalized", () => { }; const parsed = RootSchema.parse(catalog); if (!parsed.video || !("renditions" in parsed.video)) throw new Error("missing video section"); - // Normalized like Rust PathRelative: `.` and empty segments dropped, `..` preserved. - expect(parsed.video.renditions.video?.broadcast).toBe("../source"); + // Normalized like Rust PathRelative: redundant `.` and empty segments are dropped. + expect(parsed.video.renditions.video?.broadcast).toBe("source"); +}); + +test("rendition parent broadcast reference stays distinct from empty", () => { + const catalog = { + video: { + renditions: { + video: { + broadcast: ".", + codec: "avc1.64001f", + container: { kind: "legacy" }, + }, + }, + }, + }; + const parsed = RootSchema.parse(catalog); + if (!parsed.video || !("renditions" in parsed.video)) throw new Error("missing video section"); + expect(parsed.video.renditions.video?.broadcast).toBe("."); }); test("rendition without broadcast reference stays undefined", () => { diff --git a/js/hang/src/catalog/video.ts b/js/hang/src/catalog/video.ts index b7ecf66591..7bc38543ce 100644 --- a/js/hang/src/catalog/video.ts +++ b/js/hang/src/catalog/video.ts @@ -13,7 +13,7 @@ const TrackSchema = z.object({ /** Schema for a single video rendition's decoder config. Mirrors WebCodecs VideoDecoderConfig. */ export const VideoConfigSchema = z.object({ // Optional reference to another broadcast that publishes this track, expressed - // relative to the broadcast that served this catalog (e.g. "../source"). + // relative to the broadcast that served this catalog (e.g. "./source"). // If unset, the track lives in the same broadcast as the catalog. broadcast: z.optional(RelativeBroadcastSchema), diff --git a/js/net/package.json b/js/net/package.json index fdafcb9a3f..b86ee0ff76 100644 --- a/js/net/package.json +++ b/js/net/package.json @@ -1,7 +1,7 @@ { "name": "@moq/net", "type": "module", - "version": "0.2.8", + "version": "0.3.0", "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": "github:moq-dev/moq", diff --git a/js/net/src/consume.ts b/js/net/src/consume.ts index 197309ee82..9c070aa878 100644 --- a/js/net/src/consume.ts +++ b/js/net/src/consume.ts @@ -6,7 +6,7 @@ import type * as Path from "./path.ts"; * subscribers. * * `Connection.consume(path)` must not mint a fresh subscription per call: repeat requests - * for the same path (e.g. several renditions referencing one `broadcast: "../source"`) should + * for the same path (e.g. several renditions referencing one `broadcast: "./source"`) should * share a single upstream subscription. This mirrors the Rust `origin::Consumer` weak-cache: * a still-live path resolves to a shared {@link broadcast.Consumer.clone}, a closed one is * re-consumed on the next request. Each handle is reference-counted, so the shared broadcast diff --git a/js/net/src/path.test.ts b/js/net/src/path.test.ts index 5f73f0fb3d..834db511a0 100644 --- a/js/net/src/path.test.ts +++ b/js/net/src/path.test.ts @@ -190,9 +190,12 @@ test("from sanitizes multiple arguments with slashes", () => { expect(Path.from("foo//", "//bar", "baz")).toBe("foo/bar/baz" as Path.Valid); }); -test("resolve appends named segments", () => { - expect(Path.resolve(Path.from("a/b"), "c")).toBe(Path.from("a/b/c")); - expect(Path.resolve(Path.from("a/b"), "c/d")).toBe(Path.from("a/b/c/d")); +test("resolve replaces the base name", () => { + expect(Path.resolve(Path.from("a/b"), "c")).toBe(Path.from("a/c")); + expect(Path.resolve(Path.from("a/b"), "c/d")).toBe(Path.from("a/c/d")); + expect(Path.resolve(Path.from("foo.hang/catalog.pro"), "./transcode.pro")).toBe( + Path.from("foo.hang/transcode.pro"), + ); }); test("resolve with empty rel returns base", () => { @@ -200,12 +203,12 @@ test("resolve with empty rel returns base", () => { }); test("resolve single dotdot pops one segment", () => { - expect(Path.resolve(Path.from("a/b/c"), "../d")).toBe(Path.from("a/b/d")); - expect(Path.resolve(Path.from("a/b/c"), "..")).toBe(Path.from("a/b")); + expect(Path.resolve(Path.from("a/b/c"), "../d")).toBe(Path.from("a/d")); + expect(Path.resolve(Path.from("a/b/c"), "..")).toBe(Path.from("a")); }); test("resolve multiple dotdot pops multiple segments", () => { - expect(Path.resolve(Path.from("a/b/c"), "../../x")).toBe(Path.from("a/x")); + expect(Path.resolve(Path.from("a/b/c"), "../../x")).toBe(Path.from("x")); expect(Path.resolve(Path.from("a/b/c"), "../../../x")).toBe(Path.from("x")); }); @@ -219,20 +222,28 @@ test("resolve with empty base", () => { expect(Path.resolve(Path.empty(), "..")).toBe(Path.from("")); }); -test("resolve treats dot as a no-op", () => { - expect(Path.resolve(Path.from("a/b"), ".")).toBe(Path.from("a/b")); - expect(Path.resolve(Path.from("a/b"), "./c")).toBe(Path.from("a/b/c")); - expect(Path.resolve(Path.from("a/b"), "./../c")).toBe(Path.from("a/c")); - expect(Path.resolve(Path.from("a/b"), "foo/./bar")).toBe(Path.from("a/b/foo/bar")); +test("resolve dot names the base parent", () => { + expect(Path.resolve(Path.from("a/b"), ".")).toBe(Path.from("a")); + expect(Path.resolve(Path.from("a/b"), "./c")).toBe(Path.from("a/c")); + expect(Path.resolve(Path.from("a/b"), "./../c")).toBe(Path.from("c")); + expect(Path.resolve(Path.from("a/b"), "foo/./bar")).toBe(Path.from("a/foo/bar")); +}); + +test("resolve self-reference via sibling name equals base", () => { + expect(Path.resolve(Path.from("a/b"), "./b")).toBe(Path.from("a/b")); }); -test("resolve self-reference via dotdot equals base", () => { - expect(Path.resolve(Path.from("a/b"), "../b")).toBe(Path.from("a/b")); +test("tryResolve distinguishes the root from an escape", () => { + expect(Path.tryResolve(Path.from("top"), ".")).toBe(Path.empty()); + expect(Path.tryResolve(Path.from("top"), "..")).toBeUndefined(); + expect(Path.tryResolve(Path.from("a/b"), "..")).toBe(Path.empty()); + expect(Path.tryResolve(Path.from("a/b"), "../..")).toBeUndefined(); }); -test("normalizeRelative drops empty and dot segments", () => { +test("normalizeRelative preserves an all-dot reference", () => { expect(Path.normalizeRelative("")).toBe(""); - expect(Path.normalizeRelative(".")).toBe(""); + expect(Path.normalizeRelative(".")).toBe("."); + expect(Path.normalizeRelative("././")).toBe("."); expect(Path.normalizeRelative("./foo")).toBe("foo"); expect(Path.normalizeRelative("foo//bar")).toBe("foo/bar"); expect(Path.normalizeRelative("foo/./bar")).toBe("foo/bar"); diff --git a/js/net/src/path.ts b/js/net/src/path.ts index 90e1800267..e98b34021e 100644 --- a/js/net/src/path.ts +++ b/js/net/src/path.ts @@ -176,41 +176,44 @@ export function empty(): Valid { /** * Normalize a relative path reference: trim leading/trailing slashes, drop empty - * segments, and drop `.` segments (no-ops, matching POSIX). `..` is preserved and - * only interpreted by {@link resolve}. + * segments, and drop redundant `.` segments. A reference made only of `.` segments + * normalizes to `.` because it names the base's parent, while empty names the base. + * `..` is preserved and only interpreted by {@link resolve}. * * Mirrors the Rust `PathRelative::new` normalization, so JS and Rust agree * byte-for-byte on the stored form. Two callers comparing normalized strings can - * detect that `""`, `"."`, `"/./"` etc. all mean "no reference". + * detect equivalent references while preserving the distinction between `""` and `"."`. */ export function normalizeRelative(rel: string): string { - return rel - .split("/") - .filter((s) => s !== "" && s !== ".") - .join("/"); + const raw = rel.split("/"); + const normalized = raw.filter((s) => s !== "" && s !== ".").join("/"); + + return normalized === "" && raw.includes(".") ? "." : normalized; } /** * Resolve a relative path reference against a base path. * - * `..` segments pop the last segment of the base; other segments are appended. - * `.` and empty segments are no-ops. Excess `..` once the base is empty is also a - * no-op (subsequent named segments still append). An empty / normalized-empty `rel` - * returns the base path unchanged. + * A non-empty reference replaces the last segment of the base, matching relative URL + * resolution. `..` segments then pop another segment; other segments are appended. + * `.` and empty segments are no-ops. Excess `..` once the base is empty is also a no-op + * (subsequent named segments still append). An empty `rel` returns the base unchanged. * * Mirrors the Rust `Path::resolve`, used by hang catalogs to express * cross-broadcast track references (a rendition's `broadcast` field). * * @example * ```typescript - * Path.resolve(Path.from("a/b/c"), "../source"); // "a/b/source" - * Path.resolve(Path.from("a/b"), "x/y"); // "a/b/x/y" - * Path.resolve(Path.from("a"), "../../x"); // "x" - * Path.resolve(Path.from("a/b"), "./c"); // "a/b/c" + * Path.resolve(Path.from("a/b/c"), "./source"); // "a/b/source" + * Path.resolve(Path.from("a/b"), "."); // "a" + * Path.resolve(Path.from("a/b/c"), "../source"); // "a/source" * ``` */ export function resolve(base: Valid, rel: string): Valid { + if (rel === "") return base; + const segments = base === "" ? [] : base.split("/"); + segments.pop(); for (const seg of rel.split("/")) { if (seg === "" || seg === ".") { @@ -225,3 +228,30 @@ export function resolve(base: Valid, rel: string): Valid { return segments.join("/") as Valid; } + +/** + * Resolve a relative path, returning `undefined` if it escapes above the root. + * + * Unlike {@link resolve}, this distinguishes a valid reference to the empty root + * path from excess `..` segments. Use it for untrusted catalog references that + * must not be clamped to the root. + */ +export function tryResolve(base: Valid, rel: string): Valid | undefined { + if (rel === "") return base; + + const segments = base === "" ? [] : base.split("/"); + segments.pop(); + + for (const seg of rel.split("/")) { + if (seg === "" || seg === ".") { + continue; + } + if (seg === "..") { + if (segments.pop() === undefined) return undefined; + } else { + segments.push(seg); + } + } + + return segments.join("/") as Valid; +} diff --git a/js/watch/package.json b/js/watch/package.json index a9215ced37..6738251998 100644 --- a/js/watch/package.json +++ b/js/watch/package.json @@ -1,7 +1,7 @@ { "name": "@moq/watch", "type": "module", - "version": "0.4.6", + "version": "0.5.0", "jsr": false, "description": "Watch Media over QUIC broadcasts", "license": "(MIT OR Apache-2.0)", diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index 658d41050b..284d5cac1b 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -24,6 +24,33 @@ function skipDiscovery(conn: Moq.Connection.Established): boolean { return true; } +type ReferencedRendition = { + broadcast?: string; +}; + +function filterRenditions( + base: Moq.Path.Valid, + renditions: Record, +): Record { + return Object.fromEntries( + Object.entries(renditions).filter(([, config]) => + config.broadcast === undefined ? true : Path.tryResolve(base, config.broadcast) !== undefined, + ), + ); +} + +function filterCatalog(base: Moq.Path.Valid, catalog: Catalog.Root): Catalog.Root { + return { + ...catalog, + video: catalog.video + ? { ...catalog.video, renditions: filterRenditions(base, catalog.video.renditions) } + : undefined, + audio: catalog.audio + ? { ...catalog.audio, renditions: filterRenditions(base, catalog.audio.renditions) } + : undefined, + }; +} + // Watch supports the on-the-wire catalog formats from @moq/hang, plus "hangz" (the // DEFLATE-compressed `catalog.json.z` track) and a "manual" mode where the user supplies the // catalog directly without fetching. "hangz" is opt-in only: it shares the `.hang` broadcast suffix @@ -199,7 +226,7 @@ export class Broadcast { if (format === "manual") { // Mirror the caller-supplied catalog into the effective output. const catalog = effect.get(this.in.catalog); - effect.set(this.#out.catalog, catalog, undefined); + effect.set(this.#out.catalog, catalog ? filterCatalog(name, catalog) : undefined, undefined); this.#out.status.set(catalog ? "live" : "loading"); return; } @@ -237,7 +264,7 @@ export class Broadcast { console.debug("received catalog", format, this.in.name.peek(), update); - this.#out.catalog.set(update); + this.#out.catalog.set(filterCatalog(name, update)); this.#out.status.set("live"); } } catch (err) { @@ -264,12 +291,12 @@ export class Broadcast { if (!rel) return effect.get(this.out.active); const base = effect.get(this.in.name); - const resolved = Path.resolve(base, rel); + const resolved = Path.tryResolve(base, rel); - // A reference that walks back to the catalog's own broadcast (or resolves to - // the empty root, via excess `..`) is served by the catalog broadcast itself, - // avoiding a duplicate subscription on the same path. - if (resolved === base || resolved === Path.empty()) return effect.get(this.out.active); + // Ignore a rendition whose reference escapes above the root. A valid empty result + // names the root broadcast, while a reference back to the catalog uses its active handle. + if (resolved === undefined) return undefined; + if (resolved === base) return effect.get(this.out.active); if (!effect.get(this.in.enabled)) return undefined; diff --git a/js/watch/src/video/source.test.ts b/js/watch/src/video/source.test.ts index 5b8fb9443e..52576ab127 100644 --- a/js/watch/src/video/source.test.ts +++ b/js/watch/src/video/source.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "bun:test"; -import type * as Catalog from "@moq/hang/catalog"; +import * as Catalog from "@moq/hang/catalog"; +import { Path } from "@moq/net"; import { Signal } from "@moq/signals"; -import type { Broadcast } from "../broadcast"; +import { Broadcast } from "../broadcast"; import { Source } from "./source"; const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -14,7 +15,7 @@ function config(codec: string): Catalog.VideoConfig { return { codec, container: { kind: "legacy" } }; } -function broadcast(renditions: Record): Broadcast { +function mockBroadcast(renditions: Record): Broadcast { return { in: { connection: new Signal(undefined), @@ -39,7 +40,7 @@ describe("Source error signal", () => { it("is unsupported when the catalog has video renditions but none are supported", async () => { await withoutWarnings(async () => { const source = new Source({ - broadcast: broadcast({ hd: config("hev1.1.6.L120.90") }), + broadcast: mockBroadcast({ hd: config("hev1.1.6.L120.90") }), supported: async () => false, }); @@ -54,7 +55,7 @@ describe("Source error signal", () => { it("treats a support probe throw as unsupported without aborting the remaining renditions", async () => { await withoutWarnings(async () => { const source = new Source({ - broadcast: broadcast({ + broadcast: mockBroadcast({ bad: config("not-a-codec"), good: config("avc1.640028"), }), @@ -81,7 +82,7 @@ describe("Source error signal", () => { ); const source = new Source({ - broadcast: broadcast({ hd: config("avc1.640028") }), + broadcast: mockBroadcast({ hd: config("avc1.640028") }), supported, }); @@ -101,7 +102,7 @@ describe("Source error signal", () => { it("is undefined when the catalog has no video renditions", async () => { const source = new Source({ - broadcast: broadcast({}), + broadcast: mockBroadcast({}), supported: async () => false, }); @@ -111,4 +112,39 @@ describe("Source error signal", () => { source.close(); }); + + it("ignores escaping renditions before selecting a valid fallback", async () => { + const invalidVideo = { ...config("avc1.640028"), broadcast: "../../source" }; + const validVideo = { ...config("avc1.640028"), broadcast: "./source" }; + const audioConfig = Catalog.AudioConfigSchema.parse({ + codec: "opus", + container: { kind: "legacy" }, + sampleRate: 48_000, + numberOfChannels: 2, + }); + const broadcast = new Broadcast({ + enabled: true, + name: Path.from("room/catalog.hang"), + catalogFormat: "manual", + catalog: { + video: { renditions: { invalid: invalidVideo, fallback: validVideo } }, + audio: { + renditions: { + invalid: { ...audioConfig, broadcast: "../../source" }, + fallback: { ...audioConfig, broadcast: "./source" }, + }, + }, + }, + }); + const source = new Source({ broadcast, supported: async () => true }); + + await settle(); + expect(Object.keys(broadcast.out.catalog.peek()?.video?.renditions ?? {})).toEqual(["fallback"]); + expect(Object.keys(broadcast.out.catalog.peek()?.audio?.renditions ?? {})).toEqual(["fallback"]); + expect(Object.keys(source.out.available.peek())).toEqual(["fallback"]); + expect(source.out.track.peek()).toBe("fallback"); + + source.close(); + broadcast.close(); + }); }); diff --git a/rs/hang/src/catalog/audio/mod.rs b/rs/hang/src/catalog/audio/mod.rs index 0582bbafa9..397f2203f4 100644 --- a/rs/hang/src/catalog/audio/mod.rs +++ b/rs/hang/src/catalog/audio/mod.rs @@ -69,7 +69,7 @@ impl Audio { #[non_exhaustive] pub struct AudioConfig { /// Optional reference to another broadcast that publishes this track, expressed - /// relative to the broadcast that served this catalog (e.g. `../source`). If unset, + /// relative to the broadcast that served this catalog (e.g. `./source`). If unset, /// the track lives in the same broadcast as the catalog. #[serde(default)] pub broadcast: Option, diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 7a0708635a..efd2ca0126 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -258,7 +258,7 @@ mod test { "video": { "renditions": { "video": { - "broadcast": "../source", + "broadcast": "./source", "codec": "avc1.64001f", "codedWidth": 1280, "codedHeight": 720, @@ -272,7 +272,7 @@ mod test { let rendition = parsed.video.renditions.get("video").expect("missing rendition"); assert_eq!( rendition.broadcast.as_ref().map(|p| p.as_str()), - Some("../source"), + Some("source"), "broadcast field did not deserialize" ); @@ -338,6 +338,29 @@ mod test { ); } + #[test] + fn rendition_with_parent_broadcast_stays_distinct_from_empty() { + let encoded = r#"{ + "video": { + "renditions": { + "video": { + "broadcast": ".", + "codec": "avc1.64001f", + "container": {"kind": "legacy"} + } + } + } + }"#; + + let parsed = Catalog::from_str(encoded).expect("failed to decode"); + let rendition = parsed.video.renditions.get("video").expect("missing rendition"); + assert_eq!( + rendition.broadcast.as_ref().map(|p| p.as_str()), + Some("."), + "parent reference should not normalize to empty" + ); + } + #[test] fn unknown_container_keeps_siblings() { // A rendition using a future container must not take down the rest of the catalog. diff --git a/rs/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index 5a8b11ce13..3596555733 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -142,7 +142,7 @@ pub struct Display { #[non_exhaustive] pub struct VideoConfig { /// Optional reference to another broadcast that publishes this track, expressed - /// relative to the broadcast that served this catalog (e.g. `../source`). If unset, + /// relative to the broadcast that served this catalog (e.g. `./source`). If unset, /// the track lives in the same broadcast as the catalog. /// /// This allows a transcoder to author a downstream catalog that points unchanged diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index 3948987740..b6f0324523 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -499,7 +499,7 @@ async fn run_stdout(consumer: moq_net::origin::Consumer, name: String, args: Sub // Confirm the broadcast is reachable and wait for it to be announced; `Subscribe` then // resolves it (and any sibling broadcast a rendition's `broadcast` field references, - // e.g. "../source") through the origin. + // e.g. "./source") through the origin. consumer .announced_broadcast(&name) .await diff --git a/rs/moq-cli/src/transcode.rs b/rs/moq-cli/src/transcode.rs index 7217d1956a..dfe3cba3cd 100644 --- a/rs/moq-cli/src/transcode.rs +++ b/rs/moq-cli/src/transcode.rs @@ -67,15 +67,19 @@ fn parse_resize_acceleration(arg: &str) -> Result anyhow::Result<()> { - let source_path = moq - .broadcast - .clone() - .filter(|name| !name.is_empty()) - .context("`transcode` requires the source broadcast: pass --broadcast ")?; - let output_path = args - .output - .clone() - .unwrap_or_else(|| format!("{source_path}/transcode.hang")); + let source_path = moq_net::PathOwned::from( + moq.broadcast + .clone() + .context("`transcode` requires the source broadcast: pass --broadcast ")?, + ); + if source_path.is_empty() { + anyhow::bail!("`transcode` requires the source broadcast: pass --broadcast "); + } + let output_path = moq_net::PathOwned::from( + args.output + .clone() + .unwrap_or_else(|| format!("{source_path}/transcode.hang")), + ); // Publish the derivative through one origin and consume the source through // another, over a single auto-reconnecting session. @@ -133,13 +137,9 @@ pub async fn run(moq: MoqSide, args: Args, net: Net) -> anyhow::Result<()> { name => moq_video::decode::Kind::Named(name.to_string()), }; config.resize.acceleration = args.resize_acceleration; - // Reference the source renditions relatively when the output nests under - // the source (`a/b` -> `a/b/transcode.hang` is `..`, one `..` per level); + // Reference the source renditions relatively when the output nests under it; // otherwise the derivative catalog advertises only the rungs. - config.source = output_path.strip_prefix(&format!("{source_path}/")).map(|rest| { - let depth = rest.split('/').count(); - moq_net::PathRelativeOwned::from(vec![".."; depth].join("/")) - }); + config.source = moq_transcode::source_reference(&source_path, &output_path); let output = publish .create_broadcast(&output_path, moq_net::broadcast::Route::new().with_announce(true)) diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 2b37ea88bc..8749d97c89 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -246,6 +246,29 @@ mod tests { } } + #[tokio::test] + async fn escaping_broadcast_reference_is_not_advertised() { + let origin = moq_net::Origin::random().produce(); + let _broadcast = origin + .create_broadcast("a/pub", moq_net::broadcast::Route::new().with_announce(true)) + .expect("publish allowed"); + settle().await; + let source = moq_mux::Source::new(origin.consume(), "a/pub"); + let upstream = Upstream { + broadcast: source.broadcast().await.unwrap(), + source, + }; + let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); + config.broadcast = Some(moq_net::PathRelative::new("../../source").to_owned()); + config.timeline = Some(hang::catalog::Timeline::new("video.timeline")); + let mut catalog = moq_mux::catalog::hang::Catalog::default(); + catalog.video.renditions.insert("video".to_string(), config); + + let renditions = renditions::Producer::new(); + renditions.sync(&upstream, &Config::default(), &catalog); + assert!(renditions.get(Kind::Video, "video").is_none()); + } + // The whole fetch-on-demand path in process: a broadcast publishes media through the // catalog (which records the timeline), the Broadcaster renders playlists from the // timeline alone, and a segment request fetches and transmuxes exactly its groups. diff --git a/rs/moq-hls/src/export/renditions.rs b/rs/moq-hls/src/export/renditions.rs index 5ff7246a54..9cf561cf03 100644 --- a/rs/moq-hls/src/export/renditions.rs +++ b/rs/moq-hls/src/export/renditions.rs @@ -132,6 +132,28 @@ impl Producer { let Ok(mut current) = self.state.write() else { return; }; + let mut catalog = catalog.clone(); + catalog.video.renditions.retain(|name, config| { + let valid = upstream.source.resolve_reference(config.broadcast.as_ref()).is_some(); + if !valid { + tracing::warn!( + rendition = name, + "ignoring video rendition whose broadcast escapes above the root" + ); + } + valid + }); + catalog.audio.renditions.retain(|name, config| { + let valid = upstream.source.resolve_reference(config.broadcast.as_ref()).is_some(); + if !valid { + tracing::warn!( + rendition = name, + "ignoring audio rendition whose broadcast escapes above the root" + ); + } + valid + }); + let catalog = &catalog; // Renditions the catalog dropped or reconfigured. Close each as it goes so any cursor // over it drains and ends, instead of parking on a timeline that never finishes -- the diff --git a/rs/moq-mux/src/codec/h264/export.rs b/rs/moq-mux/src/codec/h264/export.rs index 3e4b8e4bce..3bc935cd37 100644 --- a/rs/moq-mux/src/codec/h264/export.rs +++ b/rs/moq-mux/src/codec/h264/export.rs @@ -121,6 +121,9 @@ impl Export { } fn update_catalog(&mut self, catalog: &Catalog) -> crate::Result<()> { + let mut catalog = catalog.clone(); + self.source.retain_valid_media(&mut catalog); + let picked = catalog .video .renditions @@ -149,7 +152,9 @@ impl Export { return Ok(()); } - let source = ExportSource::for_video_raw(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_video_raw(&self.source, name, config, self.latency)? else { + unreachable!("invalid broadcast references were removed above"); + }; let convert = match config.description.as_ref().filter(|d| !d.is_empty()) { None => None, Some(avcc) => { diff --git a/rs/moq-mux/src/codec/h265/export.rs b/rs/moq-mux/src/codec/h265/export.rs index 87792c6c0f..dd45919442 100644 --- a/rs/moq-mux/src/codec/h265/export.rs +++ b/rs/moq-mux/src/codec/h265/export.rs @@ -113,6 +113,9 @@ impl Export { } fn update_catalog(&mut self, catalog: &Catalog) -> crate::Result<()> { + let mut catalog = catalog.clone(); + self.source.retain_valid_media(&mut catalog); + let picked = catalog .video .renditions @@ -141,7 +144,9 @@ impl Export { return Ok(()); } - let source = ExportSource::for_video_raw(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_video_raw(&self.source, name, config, self.latency)? else { + unreachable!("invalid broadcast references were removed above"); + }; let convert = match config.description.as_ref().filter(|d| !d.is_empty()) { None => None, Some(hvcc) => { diff --git a/rs/moq-mux/src/container/flv/export.rs b/rs/moq-mux/src/container/flv/export.rs index eb0925d714..b8a1e977bb 100644 --- a/rs/moq-mux/src/container/flv/export.rs +++ b/rs/moq-mux/src/container/flv/export.rs @@ -301,7 +301,9 @@ impl Export { !self.video.is_empty() || !self.audio.is_empty() } - fn update_catalog(&mut self, catalog: Catalog) -> anyhow::Result<()> { + fn update_catalog(&mut self, mut catalog: Catalog) -> anyhow::Result<()> { + self.source.retain_valid_media(&mut catalog); + // A single-track FLV stream binds only the first rendition of each kind; // multitrack binds them all. Bind newly-seen renditions in name order (the // catalog is a BTreeMap) so each keeps a stable track id. @@ -349,7 +351,9 @@ impl Export { (VideoCodec::AV1(av1), None) => Some(Bytes::copy_from_slice(&av1c_bytes(av1))), _ => None, }; - let source = ExportSource::for_video(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_video(&self.source, name, config, self.latency)? else { + continue; + }; let track_id = u8::try_from(self.video.len()).context("too many FLV video tracks")?; self.video.push(FlvTrack { name: name.clone(), @@ -377,7 +381,9 @@ impl Export { } let flavor = audio_flavor(config)?; ensure_legacy(&config.container, "audio", name)?; - let source = ExportSource::for_audio(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_audio(&self.source, name, config, self.latency)? else { + continue; + }; let track_id = u8::try_from(self.audio.len()).context("too many FLV audio tracks")?; self.audio.push(FlvTrack { name: name.clone(), diff --git a/rs/moq-mux/src/container/fmp4/export.rs b/rs/moq-mux/src/container/fmp4/export.rs index 93cd2b5973..c1a70478c5 100644 --- a/rs/moq-mux/src/container/fmp4/export.rs +++ b/rs/moq-mux/src/container/fmp4/export.rs @@ -328,6 +328,7 @@ impl Export { .audio .renditions .retain(|name, config| crate::catalog::hang::supported(name, &config.container)); + self.source.retain_valid_media(&mut catalog); let catalog = &catalog; let mut active: HashMap = HashMap::new(); @@ -346,7 +347,9 @@ impl Export { if self.tracks.contains_key(name) { continue; } - let source = ExportSource::for_video(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_video(&self.source, name, config, self.latency)? else { + continue; + }; let timescale = catalog_timescale_video(config)?; let framerate = super::usable_video_framerate(config).unwrap_or(30.0); self.tracks.insert( @@ -372,7 +375,9 @@ impl Export { if self.tracks.contains_key(name) { continue; } - let source = ExportSource::for_audio(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_audio(&self.source, name, config, self.latency)? else { + continue; + }; let timescale = catalog_timescale_audio(config)?; self.tracks.insert( name.clone(), diff --git a/rs/moq-mux/src/container/mkv/export.rs b/rs/moq-mux/src/container/mkv/export.rs index f1b16e6591..6f196bc5a1 100644 --- a/rs/moq-mux/src/container/mkv/export.rs +++ b/rs/moq-mux/src/container/mkv/export.rs @@ -296,7 +296,9 @@ impl Export { Poll::Pending } - fn update_catalog(&mut self, catalog: Catalog) -> Result<()> { + fn update_catalog(&mut self, mut catalog: Catalog) -> Result<()> { + self.source.retain_valid_media(&mut catalog); + let mut active: HashMap = HashMap::new(); for name in catalog.video.renditions.keys() { active.insert(name.clone(), ()); @@ -329,7 +331,9 @@ impl Export { continue; } ensure_legacy(&config.container, "video", name)?; - let source = ExportSource::for_video(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_video(&self.source, name, config, self.latency)? else { + continue; + }; self.tracks.insert( name.clone(), MkvTrack { @@ -348,7 +352,9 @@ impl Export { continue; } ensure_legacy(&config.container, "audio", name)?; - let source = ExportSource::for_audio(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_audio(&self.source, name, config, self.latency)? else { + continue; + }; self.tracks.insert( name.clone(), MkvTrack { diff --git a/rs/moq-mux/src/container/source.rs b/rs/moq-mux/src/container/source.rs index 5c117e2259..8a8e16f08e 100644 --- a/rs/moq-mux/src/container/source.rs +++ b/rs/moq-mux/src/container/source.rs @@ -80,18 +80,21 @@ impl ExportSource { name: &str, config: &VideoConfig, latency: Duration, - ) -> Result { + ) -> Result, crate::Error> { let media: HangContainer = (&config.container).try_into()?; let transform = build_video_transform(config); let description = config.description.as_ref().filter(|b| !b.is_empty()).cloned(); + let Some(request) = source.request(config.broadcast.as_ref()) else { + return Ok(None); + }; - Ok(Self { - state: SourceState::Requesting(source.request(config.broadcast.as_ref()), name.to_string()), + Ok(Some(Self { + state: SourceState::Requesting(request, name.to_string()), media: Some(media), latency, transform, description, - }) + })) } /// Subscribe to a video rendition without attaching any codec-shape @@ -103,17 +106,20 @@ impl ExportSource { name: &str, config: &VideoConfig, latency: Duration, - ) -> Result { + ) -> Result, crate::Error> { let media: HangContainer = (&config.container).try_into()?; let description = config.description.as_ref().filter(|b| !b.is_empty()).cloned(); + let Some(request) = source.request(config.broadcast.as_ref()) else { + return Ok(None); + }; - Ok(Self { - state: SourceState::Requesting(source.request(config.broadcast.as_ref()), name.to_string()), + Ok(Some(Self { + state: SourceState::Requesting(request, name.to_string()), media: Some(media), latency, transform: None, description, - }) + })) } /// Subscribe to an audio rendition. Audio has no codec-shape transform; @@ -123,25 +129,29 @@ impl ExportSource { name: &str, config: &AudioConfig, latency: Duration, - ) -> Result { + ) -> Result, crate::Error> { let media: HangContainer = (&config.container).try_into()?; let description = config.description.as_ref().filter(|b| !b.is_empty()).cloned(); + let Some(request) = source.request(config.broadcast.as_ref()) else { + return Ok(None); + }; - Ok(Self { - state: SourceState::Requesting(source.request(config.broadcast.as_ref()), name.to_string()), + Ok(Some(Self { + state: SourceState::Requesting(request, name.to_string()), media: Some(media), latency, transform: None, description, - }) + })) } /// Subscribe to a verbatim `mpegts` stream rendition (SCTE-35, private PES, ...). /// No codec-shape transform and no description: the frames are Legacy-framed /// verbatim bytes the muxer writes back out as PES or private sections. pub fn for_stream(source: &crate::Source, name: &str, latency: Duration) -> Result { + let request = source.request(None).expect("the catalog broadcast is always valid"); Ok(Self { - state: SourceState::Requesting(source.request(None), name.to_string()), + state: SourceState::Requesting(request, name.to_string()), media: Some(HangContainer::Legacy), latency, transform: None, diff --git a/rs/moq-mux/src/container/ts/export.rs b/rs/moq-mux/src/container/ts/export.rs index 70b1ef03e3..7f747d9777 100644 --- a/rs/moq-mux/src/container/ts/export.rs +++ b/rs/moq-mux/src/container/ts/export.rs @@ -340,6 +340,8 @@ impl Export { } fn update_catalog(&mut self, mut catalog: Catalog) -> anyhow::Result<()> { + self.source.retain_valid(&mut catalog); + // The MPEG-TS section lives in the extension. The trait only exposes // `mpegts_mut`, and this snapshot is owned, so clone it out (`()` yields the // empty default: no verbatim streams, no preserved PIDs/descriptors). @@ -424,7 +426,9 @@ impl Export { self.tracks.insert(name.clone(), track); } None => { - let source = ExportSource::for_video(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_video(&self.source, name, config, self.latency)? else { + continue; + }; self.insert_track(name, source, pid, kind, descriptors, reserve); } } @@ -441,7 +445,9 @@ impl Export { self.tracks.insert(name.clone(), track); } None => { - let source = ExportSource::for_audio(&self.source, name, config, self.latency)?; + let Some(source) = ExportSource::for_audio(&self.source, name, config, self.latency)? else { + continue; + }; self.insert_track(name, source, pid, kind, descriptors, DEFAULT_DTS_RESERVE); } } diff --git a/rs/moq-mux/src/error.rs b/rs/moq-mux/src/error.rs index 9ca1979840..fe639bec34 100644 --- a/rs/moq-mux/src/error.rs +++ b/rs/moq-mux/src/error.rs @@ -132,6 +132,10 @@ pub enum Error { /// frames cannot be parsed. Such a rendition must be ignored, not guessed at. #[error("unsupported container: {0}")] UnsupportedContainer(String), + + /// A rendition's relative broadcast reference escaped above the origin root. + #[error("broadcast reference escapes above the root: {0}")] + InvalidBroadcastReference(String), } impl Error { diff --git a/rs/moq-mux/src/source.rs b/rs/moq-mux/src/source.rs index fa4b9adc4c..405a42b51f 100644 --- a/rs/moq-mux/src/source.rs +++ b/rs/moq-mux/src/source.rs @@ -2,7 +2,7 @@ //! //! A hang catalog rendition may reference a track published in *another* //! broadcast via its `broadcast` field (a path relative to the catalog's -//! broadcast, e.g. `../source`). Resolving that reference needs the catalog +//! broadcast, e.g. `./source`). Resolving that reference needs the catalog //! broadcast's own path and an [`moq_net::origin::Consumer`] to fetch the //! referenced broadcast from. [`Source`] bundles the two, and resolves both the //! catalog broadcast and any referenced broadcast through the same origin so @@ -47,23 +47,62 @@ impl Source { /// Begin resolving the broadcast that serves rendition track `name`, honoring an /// optional cross-broadcast reference. /// - /// A missing/empty `rel`, or one that resolves back to the catalog's own path (or - /// walks past the origin root), targets the catalog broadcast; anything else targets - /// the resolved sibling broadcast. Either way the broadcast is fetched from the origin, + /// A missing/empty `rel` targets the catalog broadcast. Anything else, including + /// the empty root broadcast, targets the resolved path. A reference that escapes above + /// the origin root returns `None`. Either valid target is fetched from the origin, /// which deduplicates repeat requests for the same live path (announced or dynamically /// served) so the catalog and every rendition share one upstream subscription. - pub(crate) fn request(&self, rel: Option<&moq_net::PathRelative<'_>>) -> kio::Pending { - let target = match rel.filter(|rel| !rel.is_empty()) { - // Excess `..` clamps to the (empty) origin root, which is not a broadcast; treat - // it as a self-reference and use the catalog broadcast instead. - Some(rel) => match self.path.resolve(rel) { - resolved if resolved.is_empty() => self.path.clone(), - resolved => resolved, - }, - None => self.path.clone(), - }; + pub(crate) fn request( + &self, + rel: Option<&moq_net::PathRelative<'_>>, + ) -> Option> { + let target = self.resolve_reference(rel)?; + Some(self.origin.request_broadcast(&target)) + } + + /// Resolve a rendition's optional broadcast reference to an origin path. + /// + /// A missing or empty reference returns the catalog broadcast path. A valid reference + /// may return the empty root path. `None` means the reference escaped above the root and + /// the rendition must be ignored. + pub fn resolve_reference(&self, rel: Option<&moq_net::PathRelative<'_>>) -> Option { + match rel.filter(|rel| !rel.is_empty()) { + Some(rel) => self.path.try_resolve(rel), + None => Some(self.path.clone()), + } + } + + /// Remove renditions whose broadcast reference escapes above the origin root. + pub(crate) fn retain_valid( + &self, + catalog: &mut crate::catalog::hang::Catalog, + ) { + self.retain_valid_references("video", &mut catalog.video.renditions); + self.retain_valid_references("audio", &mut catalog.audio.renditions); + } - self.origin.request_broadcast(&target) + /// Remove media renditions whose broadcast reference escapes above the origin root. + pub(crate) fn retain_valid_media(&self, catalog: &mut hang::Catalog) { + self.retain_valid_references("video", &mut catalog.video.renditions); + self.retain_valid_references("audio", &mut catalog.audio.renditions); + } + + fn retain_valid_references( + &self, + kind: &'static str, + renditions: &mut std::collections::BTreeMap, + ) { + renditions.retain(|name, config| { + let valid = self.resolve_reference(config.broadcast()).is_some(); + if !valid { + tracing::warn!( + rendition = name, + kind, + "ignoring rendition whose broadcast escapes above the root" + ); + } + valid + }); } /// Resolve an optional cross-broadcast reference to its broadcast. @@ -76,7 +115,8 @@ impl Source { &self, rel: Option<&moq_net::PathRelative<'_>>, ) -> crate::Result { - Ok(self.request(rel).await?) + let request = self.request(rel).ok_or_else(|| invalid_broadcast_reference(rel))?; + Ok(request.await?) } /// Resolve an optional cross-broadcast reference and subscribe to track `name`, @@ -94,11 +134,32 @@ impl Source { rel: Option<&moq_net::PathRelative<'_>>, name: &str, ) -> crate::Result { - let broadcast = self.request(rel).await?; + let request = self.request(rel).ok_or_else(|| invalid_broadcast_reference(rel))?; + let broadcast = request.await?; Ok(broadcast.track(name)?.subscribe(None).await?) } } +trait BroadcastConfig { + fn broadcast(&self) -> Option<&moq_net::PathRelativeOwned>; +} + +impl BroadcastConfig for hang::catalog::VideoConfig { + fn broadcast(&self) -> Option<&moq_net::PathRelativeOwned> { + self.broadcast.as_ref() + } +} + +impl BroadcastConfig for hang::catalog::AudioConfig { + fn broadcast(&self) -> Option<&moq_net::PathRelativeOwned> { + self.broadcast.as_ref() + } +} + +fn invalid_broadcast_reference(rel: Option<&moq_net::PathRelative<'_>>) -> crate::Error { + crate::Error::InvalidBroadcastReference(rel.map_or_else(String::new, |rel| rel.as_str().to_string())) +} + /// Test helper: serve `broadcast` on a throwaway origin's dynamic handler and return a /// [`Source`] rooted at it, so exporter tests that build a local broadcast can still resolve /// it by path. The origin is leaked so the broadcast stays reachable for the source's @@ -121,6 +182,7 @@ pub(crate) fn announced(broadcast: &moq_net::broadcast::Consumer) -> Source { #[cfg(test)] mod tests { use super::*; + use hang::catalog::{H264, VideoConfig}; use moq_net::{Origin, PathRelative}; /// Let the origin's spawned attach task run: a created broadcast becomes @@ -142,10 +204,15 @@ mod tests { let source = Source::new(origin.consume(), "a/pub"); // No reference and an empty reference both resolve to the catalog broadcast. - source.request(None).await.expect("catalog broadcast should resolve"); + source + .request(None) + .expect("catalog reference should be valid") + .await + .expect("catalog broadcast should resolve"); let empty = PathRelative::empty(); source .request(Some(&empty)) + .expect("empty reference should be valid") .await .expect("empty reference should resolve to the catalog broadcast"); } @@ -178,19 +245,53 @@ mod tests { let source = Source::new(origin.consume(), "a/pub"); - // Walks back to the catalog's own path. - let rel = PathRelative::new("../pub"); + // Names the catalog within its own parent. + let rel = PathRelative::new("./pub"); source .subscribe_track(Some(&rel), "video") .await .expect("self-reference should resolve to the catalog broadcast"); + } - // Excess `..` walks past the (empty) origin root, treated as a self-reference. - let rel = PathRelative::new("../../.."); - source - .subscribe_track(Some(&rel), "video") - .await - .expect("excess `..` should resolve to the catalog broadcast"); + #[tokio::test] + async fn escaping_reference_is_rejected_instead_of_using_the_catalog() { + let origin = Origin::random().produce(); + let mut producer = origin + .create_broadcast("a/pub", moq_net::broadcast::Route::new().with_announce(true)) + .unwrap(); + let _video = producer.create_track("video", None).unwrap(); + settle().await; + + let source = Source::new(origin.consume(), "a/pub"); + let rel = PathRelative::new("../../source"); + assert!(source.resolve_reference(Some(&rel)).is_none()); + assert!(matches!( + source.subscribe_track(Some(&rel), "video").await, + Err(crate::Error::InvalidBroadcastReference(reference)) if reference == "../../source" + )); + } + + #[test] + fn escaping_rendition_is_removed_while_valid_sibling_remains() { + let origin = Origin::random().produce(); + let source = Source::new(origin.consume(), "a/pub"); + let mut escaped = VideoConfig::new(H264 { + profile: 0x42, + constraints: 0, + level: 0x1e, + inline: false, + }); + escaped.broadcast = Some(PathRelative::new("../../source").to_owned()); + let mut sibling = escaped.clone(); + sibling.broadcast = Some(PathRelative::new("./source").to_owned()); + + let mut catalog = hang::Catalog::default(); + catalog.video.renditions.insert("escaped".to_string(), escaped); + catalog.video.renditions.insert("sibling".to_string(), sibling); + source.retain_valid_media(&mut catalog); + + assert!(!catalog.video.renditions.contains_key("escaped")); + assert!(catalog.video.renditions.contains_key("sibling")); } #[tokio::test] @@ -210,10 +311,57 @@ mod tests { let source = Source::new(origin.consume(), "a/pub"); // The reference resolves to `a/source`, whose "video" track answers the subscribe. - let rel = PathRelative::new("../source"); + let rel = PathRelative::new("./source"); source .subscribe_track(Some(&rel), "video") .await .expect("referenced track should resolve"); } + + #[tokio::test] + async fn dot_resolves_output_parent() { + let origin = Origin::random().produce(); + + let _catalog = origin + .create_broadcast( + "a/source/transcode", + moq_net::broadcast::Route::new().with_announce(true), + ) + .unwrap(); + + let mut referenced = origin + .create_broadcast("a/source", moq_net::broadcast::Route::new().with_announce(true)) + .unwrap(); + let _video = referenced.create_track("video", None).unwrap(); + settle().await; + + let source = Source::new(origin.consume(), "a/source/transcode"); + let rel = PathRelative::new("."); + source + .subscribe_track(Some(&rel), "video") + .await + .expect("dot should resolve to the catalog broadcast's parent"); + } + + #[tokio::test] + async fn dot_resolves_one_segment_catalog_to_root() { + let origin = Origin::random().produce(); + + let _catalog = origin + .create_broadcast("top", moq_net::broadcast::Route::new().with_announce(true)) + .unwrap(); + + let mut root = origin + .create_broadcast("", moq_net::broadcast::Route::new().with_announce(true)) + .unwrap(); + let _video = root.create_track("video", None).unwrap(); + settle().await; + + let source = Source::new(origin.consume(), "top"); + let rel = PathRelative::new("."); + source + .subscribe_track(Some(&rel), "video") + .await + .expect("dot should resolve to the empty root broadcast"); + } } diff --git a/rs/moq-net/src/path.rs b/rs/moq-net/src/path.rs index 3e95ec5710..a4e32be5e5 100644 --- a/rs/moq-net/src/path.rs +++ b/rs/moq-net/src/path.rs @@ -324,20 +324,22 @@ impl<'a> Path<'a> { /// Resolve a [`PathRelative`] against this path. /// - /// `..` segments in `rel` pop the last segment of the base; other segments are appended. + /// A non-empty reference replaces the last segment of the base, matching relative URL + /// resolution. `..` segments then pop another segment; other segments are appended. /// Excess `..` is a no-op once the base is empty (subsequent named segments still append). /// An empty `rel` returns this path as an owned copy. /// - /// [`PathRelative::new`] strips `.` and empty segments, so they are not handled here. + /// [`PathRelative::new`] strips empty and redundant `.` segments, but preserves a lone `.` + /// so it can reference the base's parent. /// /// # Examples /// ``` /// use moq_net::{Path, PathRelative}; /// /// let base = Path::new("a/b/c"); - /// assert_eq!(base.resolve(&PathRelative::new("../d")).as_str(), "a/b/d"); - /// assert_eq!(base.resolve(&PathRelative::new("d")).as_str(), "a/b/c/d"); - /// assert_eq!(base.resolve(&PathRelative::new("../../../../x")).as_str(), "x"); + /// assert_eq!(base.resolve(&PathRelative::new("./d")).as_str(), "a/b/d"); + /// assert_eq!(base.resolve(&PathRelative::new(".")).as_str(), "a/b"); + /// assert_eq!(base.resolve(&PathRelative::new("../d")).as_str(), "a/d"); /// ``` pub fn resolve(&self, rel: &PathRelative<'_>) -> PathOwned { if rel.is_empty() { @@ -345,9 +347,12 @@ impl<'a> Path<'a> { } let mut segments: Vec<&str> = self.parts().collect(); + segments.pop(); for seg in rel.as_str().split('/') { - if seg == ".." { + if seg == "." { + continue; + } else if seg == ".." { segments.pop(); } else { segments.push(seg); @@ -364,6 +369,40 @@ impl<'a> Path<'a> { }) } } + + /// Resolve a [`PathRelative`], returning `None` if it escapes above the root. + /// + /// Unlike [`Path::resolve`], this distinguishes a valid reference to the empty root + /// path from excess `..` segments. Use it when an untrusted relative reference must + /// not be clamped to the root. + pub fn try_resolve(&self, rel: &PathRelative<'_>) -> Option { + if rel.is_empty() { + return Some(self.to_owned()); + } + + let mut segments: Vec<&str> = self.parts().collect(); + segments.pop(); + + for seg in rel.as_str().split('/') { + if seg == "." { + continue; + } else if seg == ".." { + segments.pop()?; + } else { + segments.push(seg); + } + } + + let path = segments.join("/"); + if path.is_empty() { + Some(Path::empty()) + } else { + Some(Path(Repr::Shared { + buf: path.into(), + start: 0, + })) + } + } } // Comparisons, ordering, and hashing all go through `as_str()` so a borrowed and a @@ -475,27 +514,29 @@ pub type PathRelativeOwned = PathRelative<'static>; /// A relative broadcast path, used to reference one broadcast from another broadcast's content. /// /// Unlike [`Path`] (which is a complete reference within the broadcast namespace), -/// `PathRelative` may contain `..` segments to walk up the namespace and is meaningful only -/// when resolved against a base [`Path`] via [`Path::resolve`]. The hang catalog uses it to -/// point a rendition at a track published in a sibling broadcast (e.g. `../source`). +/// `PathRelative` may contain `.` and `..` segments to walk the namespace and is meaningful +/// only when resolved against a base [`Path`] via [`Path::resolve`]. The hang catalog uses it +/// to point a rendition at a track published in a sibling broadcast (e.g. `./source`). /// /// `PathRelative` has no `Encode`/`Decode` impl, so it never appears in announce/subscribe /// frames. It does serialize via serde for off-wire use (e.g. as a field inside a catalog /// JSON payload, which itself travels as a track). /// /// Normalization on creation: leading/trailing slashes are trimmed, consecutive internal -/// slashes collapse to one, and `.` segments are stripped (treated as no-ops, matching -/// POSIX). `..` is preserved and is interpreted at resolve time. +/// slashes collapse to one, and redundant `.` segments are stripped. A reference made only +/// of `.` segments normalizes to `.` rather than empty because `.` resolves to the base's +/// parent while empty resolves to the base itself. `..` is preserved for resolve time. /// /// # Examples /// ``` /// use moq_net::{Path, PathRelative}; /// -/// let rel = PathRelative::new("../source"); +/// let rel = PathRelative::new("./source"); /// assert_eq!(Path::new("a/b").resolve(&rel).as_str(), "a/source"); /// -/// // `.` segments are stripped on creation. +/// // Redundant `.` segments are stripped on creation. /// assert_eq!(PathRelative::new("./a/./b").as_str(), "a/b"); +/// assert_eq!(PathRelative::new(".").as_str(), "."); /// ``` #[derive(Debug, PartialEq, Eq, Hash, Clone, serde::Serialize)] pub struct PathRelative<'a>(Cow<'a, str>); @@ -504,7 +545,7 @@ impl<'a> PathRelative<'a> { /// Create a new `PathRelative` from a string slice. /// /// Leading and trailing slashes are trimmed, consecutive internal slashes collapse to one, - /// and `.` segments are stripped. See the type-level doc for the full normalization rules. + /// and redundant `.` segments are stripped. See the type-level doc for the full rules. pub fn new(s: &'a str) -> Self { let trimmed = s.trim_start_matches('/').trim_end_matches('/'); @@ -582,11 +623,17 @@ fn needs_normalize_relative(trimmed: &str) -> bool { } fn normalize_relative_segments(trimmed: &str) -> String { - trimmed + let segments = trimmed .split('/') .filter(|seg| !seg.is_empty() && *seg != ".") .collect::>() - .join("/") + .join("/"); + + if segments.is_empty() && trimmed.split('/').any(|seg| seg == ".") { + ".".to_string() + } else { + segments + } } impl Default for PathRelative<'_> { @@ -1360,21 +1407,28 @@ mod tests { } #[test] - fn test_path_relative_strips_dot_segments() { - assert_eq!(PathRelative::new(".").as_str(), ""); + fn test_path_relative_normalizes_dot_segments() { + assert_eq!(PathRelative::new(".").as_str(), "."); + assert_eq!(PathRelative::new("././").as_str(), "."); assert_eq!(PathRelative::new("./foo").as_str(), "foo"); assert_eq!(PathRelative::new("foo/./bar").as_str(), "foo/bar"); assert_eq!(PathRelative::new("./../foo").as_str(), "../foo"); // From takes the same normalization. assert_eq!(PathRelative::from("./foo".to_string()).as_str(), "foo"); - assert_eq!(PathRelative::from(".".to_string()).as_str(), ""); + assert_eq!(PathRelative::from(".".to_string()).as_str(), "."); } #[test] - fn test_resolve_no_dotdot() { + fn test_resolve_replaces_base_name() { let base = Path::new("a/b"); - assert_eq!(base.resolve(&PathRelative::new("c")).as_str(), "a/b/c"); - assert_eq!(base.resolve(&PathRelative::new("c/d")).as_str(), "a/b/c/d"); + assert_eq!(base.resolve(&PathRelative::new("c")).as_str(), "a/c"); + assert_eq!(base.resolve(&PathRelative::new("c/d")).as_str(), "a/c/d"); + assert_eq!( + Path::new("foo.hang/catalog.pro") + .resolve(&PathRelative::new("./transcode.pro")) + .as_str(), + "foo.hang/transcode.pro" + ); } #[test] @@ -1386,14 +1440,14 @@ mod tests { #[test] fn test_resolve_single_dotdot() { let base = Path::new("a/b/c"); - assert_eq!(base.resolve(&PathRelative::new("../d")).as_str(), "a/b/d"); - assert_eq!(base.resolve(&PathRelative::new("..")).as_str(), "a/b"); + assert_eq!(base.resolve(&PathRelative::new("../d")).as_str(), "a/d"); + assert_eq!(base.resolve(&PathRelative::new("..")).as_str(), "a"); } #[test] fn test_resolve_multiple_dotdot() { let base = Path::new("a/b/c"); - assert_eq!(base.resolve(&PathRelative::new("../../x")).as_str(), "a/x"); + assert_eq!(base.resolve(&PathRelative::new("../../x")).as_str(), "x"); assert_eq!(base.resolve(&PathRelative::new("../../../x")).as_str(), "x"); } @@ -1413,19 +1467,29 @@ mod tests { } #[test] - fn test_resolve_dot_is_noop() { + fn test_resolve_dot_names_parent() { let base = Path::new("a/b"); - // `.` is normalized away by PathRelative::new, so resolve ignores it. - assert_eq!(base.resolve(&PathRelative::new(".")).as_str(), "a/b"); - assert_eq!(base.resolve(&PathRelative::new("./c")).as_str(), "a/b/c"); - assert_eq!(base.resolve(&PathRelative::new("./../c")).as_str(), "a/c"); + assert_eq!(base.resolve(&PathRelative::new(".")).as_str(), "a"); + assert_eq!(base.resolve(&PathRelative::new("./c")).as_str(), "a/c"); + assert_eq!(base.resolve(&PathRelative::new("./../c")).as_str(), "c"); } #[test] - fn test_resolve_self_reference_via_dotdot() { - // Walking `..` back to the same path yields the base unchanged, which lets the + fn test_resolve_self_reference_via_sibling_name() { + // Naming the base within its parent yields the base unchanged, which lets the // caller compare resolved == base to detect a self-reference. let base = Path::new("a/b"); - assert_eq!(base.resolve(&PathRelative::new("../b")).as_str(), "a/b"); + assert_eq!(base.resolve(&PathRelative::new("./b")).as_str(), "a/b"); + } + + #[test] + fn test_try_resolve_distinguishes_root_from_escape() { + let base = Path::new("top"); + assert_eq!(base.try_resolve(&PathRelative::new(".")).unwrap().as_str(), ""); + assert!(base.try_resolve(&PathRelative::new("..")).is_none()); + + let nested = Path::new("a/b"); + assert_eq!(nested.try_resolve(&PathRelative::new("..")).unwrap().as_str(), ""); + assert!(nested.try_resolve(&PathRelative::new("../..")).is_none()); } } diff --git a/rs/moq-rtc/src/egress.rs b/rs/moq-rtc/src/egress.rs index e1a485bf2c..c6527fb598 100644 --- a/rs/moq-rtc/src/egress.rs +++ b/rs/moq-rtc/src/egress.rs @@ -171,11 +171,14 @@ impl EgressSource { .audio .renditions .values() - .any(|r| matches!(r.codec, AudioCodec::Opus)) + .any(|r| matches!(r.codec, AudioCodec::Opus) && valid_reference(&self.source, r.broadcast.as_ref())) { out.push(Codec::Opus); } for rendition in self.catalog.video.renditions.values() { + if !valid_reference(&self.source, rendition.broadcast.as_ref()) { + continue; + } let codec = match rendition.codec.kind() { VideoCodecKind::H264 => Some(Codec::H264), VideoCodecKind::H265 => Some(Codec::H265), @@ -194,6 +197,10 @@ impl EgressSource { } } +fn valid_reference(source: &moq_mux::Source, broadcast: Option<&moq_net::PathRelative<'_>>) -> bool { + source.resolve_reference(broadcast).is_some() +} + /// Find the first catalog rendition for the given codec and build a /// [`codec::Track`] subscribed to it, honoring an optional cross-broadcast /// reference (the rendition's catalog `broadcast` field). Returns `None` if no @@ -201,11 +208,10 @@ impl EgressSource { async fn pick_track(source: &moq_mux::Source, catalog: &Catalog, codec: Codec) -> Result> { match codec { Codec::Opus => { - let Some((name, config)) = catalog - .audio - .renditions - .iter() - .find(|(_, c)| matches!(c.codec, AudioCodec::Opus)) + let Some((name, config)) = + catalog.audio.renditions.iter().find(|(_, c)| { + matches!(c.codec, AudioCodec::Opus) && valid_reference(source, c.broadcast.as_ref()) + }) else { return Ok(None); }; @@ -221,7 +227,12 @@ async fn pick_track(source: &moq_mux::Source, catalog: &Catalog, codec: Codec) - Codec::Av1 => VideoCodecKind::AV1, _ => unreachable!(), }; - let Some((name, config)) = catalog.video.renditions.iter().find(|(_, c)| c.codec.kind() == target) else { + let Some((name, config)) = catalog + .video + .renditions + .iter() + .find(|(_, c)| c.codec.kind() == target && valid_reference(source, c.broadcast.as_ref())) + else { return Ok(None); }; let track = source.subscribe_track(config.broadcast.as_ref(), name).await?; @@ -293,6 +304,42 @@ pub fn dispatch(rtc: &mut str0m::Rtc, request: WriteRequest, wallclock: Instant) #[cfg(test)] mod tests { use super::*; + use hang::catalog::{AudioConfig, H264, VideoCodec, VideoConfig}; + use moq_net::{Origin, PathRelative}; + + #[test] + fn catalog_codecs_ignores_codecs_available_only_via_escaping_references() { + let origin = Origin::random().produce(); + let source = moq_mux::Source::new(origin.consume(), "a/pub"); + let mut catalog = Catalog::default(); + + let mut escaped_audio = AudioConfig::new(AudioCodec::Opus, 48_000, 2); + escaped_audio.broadcast = Some(PathRelative::new("../../source").to_owned()); + catalog.audio.renditions.insert("opus".to_string(), escaped_audio); + + let mut escaped_video = VideoConfig::new(H264 { + profile: 0x42, + constraints: 0, + level: 0x1e, + inline: false, + }); + escaped_video.broadcast = Some(PathRelative::new("../../source").to_owned()); + catalog.video.renditions.insert("h264".to_string(), escaped_video); + + let mut valid_video = VideoConfig::new(VideoCodec::VP8); + valid_video.broadcast = Some(PathRelative::new("./source").to_owned()); + catalog.video.renditions.insert("vp8".to_string(), valid_video); + + let (writes_tx, writes_rx) = mpsc::channel(1); + let egress = EgressSource { + source, + catalog, + writes_tx, + writes_rx: Some(writes_rx), + }; + + assert_eq!(egress.catalog_codecs(), vec![Codec::Vp8]); + } #[test] fn egress_clock_ignores_cross_track_dequeue_jitter() { diff --git a/rs/moq-rtc/src/server/whep.rs b/rs/moq-rtc/src/server/whep.rs index 2b36d0c922..dc0f44894c 100644 --- a/rs/moq-rtc/src/server/whep.rs +++ b/rs/moq-rtc/src/server/whep.rs @@ -103,7 +103,7 @@ pub async fn accept( let offer = sdp::parse_offer(offer)?; // Resolve the broadcast on the subscribe origin by path. The `Source` fetches it (and any - // sibling broadcast a rendition's catalog `broadcast` field references, e.g. `../source`) + // sibling broadcast a rendition's catalog `broadcast` field references, e.g. `./source`) // via `request_broadcast`, which resolves an announced broadcast immediately and falls back // to a dynamic handler; with neither it errors and the WHEP client retries (typical). let broadcast = broadcast.as_path().to_string(); diff --git a/rs/moq-transcode/README.md b/rs/moq-transcode/README.md index c4f3995c0a..a55ddb044b 100644 --- a/rs/moq-transcode/README.md +++ b/rs/moq-transcode/README.md @@ -37,8 +37,8 @@ broadcast. ```rust let mut config = moq_transcode::Config::default(); // The derivative is announced at `/transcode.hang`, so the source -// renditions are referenced one level up. -config.source = Some(moq_net::PathRelativeOwned::from("..".to_string())); +// renditions are referenced through its parent. +config.source = Some(moq_net::PathRelativeOwned::from(".".to_string())); let output = origin.create_broadcast( format!("{path}/transcode.hang"), diff --git a/rs/moq-transcode/examples/transcode.rs b/rs/moq-transcode/examples/transcode.rs index b462a63780..51386517d8 100644 --- a/rs/moq-transcode/examples/transcode.rs +++ b/rs/moq-transcode/examples/transcode.rs @@ -6,9 +6,9 @@ // cargo run -p moq-transcode --example transcode -- \ // --url http://localhost:4443/anon --source my-broadcast // -// The derivative appears at `/transcode.hang`: its catalog references -// the source renditions via a relative `broadcast: ".."` pointer and adds the -// ladder rungs, which are only encoded while someone watches (or fetches) them. +// When the derivative is nested beneath the source, its catalog references the +// source renditions relatively and adds the ladder rungs. An unrelated output +// omits the passthrough renditions. Rungs are only encoded while watched or fetched. use anyhow::Context; use clap::Parser; @@ -32,10 +32,12 @@ struct Args { async fn main() -> anyhow::Result<()> { moq_native::Log::new(tracing::Level::INFO).init()?; let args = Args::parse(); - let output_path = args - .output - .clone() - .unwrap_or_else(|| format!("{}/transcode.hang", args.source)); + let source_path = moq_net::PathOwned::from(args.source); + let output_path = moq_net::PathOwned::from( + args.output + .clone() + .unwrap_or_else(|| format!("{source_path}/transcode.hang")), + ); // Publish the derivative through one origin and consume the source through // another, over a single auto-reconnecting session. @@ -57,7 +59,7 @@ async fn main() -> anyhow::Result<()> { // otherwise leave us waiting for an announcement that can never arrive. let consumer = remote.consume(); tokio::select! { - announced = consumer.announced_broadcast(&args.source) => { + announced = consumer.announced_broadcast(&source_path) => { announced.context("origin closed before the source broadcast was announced")?; } closed = session.closed() => { @@ -68,19 +70,19 @@ async fn main() -> anyhow::Result<()> { // Resolve it for real; the session subscribes upstream on demand. let source = consumer - .request_broadcast(&args.source) + .request_broadcast(&source_path) .await .context("source broadcast unavailable")?; let mut config = moq_transcode::Config::default(); - // The derivative lives one level below the source, so the source is `..`. - // The default ladder and encoder (hardware first: NVENC on Linux) apply. - config.source = Some(moq_net::PathRelativeOwned::from("..".to_string())); + // Reference the source when the normalized output is nested beneath it. The + // default ladder and encoder (hardware first: NVENC on Linux) apply. + config.source = moq_transcode::source_reference(&source_path, &output_path); let output = publish .create_broadcast(&output_path, moq_net::broadcast::Route::new().with_announce(true)) .context("failed to create the derivative broadcast")?; - tracing::info!(source = %args.source, output = %output_path, "transcoding"); + tracing::info!(source = %source_path, output = %output_path, "transcoding"); tokio::select! { res = moq_transcode::run(source, output, config) => Ok(res?), diff --git a/rs/moq-transcode/src/catalog.rs b/rs/moq-transcode/src/catalog.rs index 102847ca35..a3cfba2413 100644 --- a/rs/moq-transcode/src/catalog.rs +++ b/rs/moq-transcode/src/catalog.rs @@ -319,7 +319,7 @@ mod tests { video.insert("low", source(640, 360, None)).unwrap(); video.insert("high", source(1920, 1080, None)).unwrap(); let mut remote = source(3840, 2160, None); - remote.broadcast = Some(PathRelativeOwned::from("../other".to_string())); + remote.broadcast = Some(PathRelativeOwned::from("./other".to_string())); video.insert("remote", remote).unwrap(); let (name, config) = choose_source(&video).unwrap(); diff --git a/rs/moq-transcode/src/config.rs b/rs/moq-transcode/src/config.rs index b8314e96c9..f7eae38c1d 100644 --- a/rs/moq-transcode/src/config.rs +++ b/rs/moq-transcode/src/config.rs @@ -1,6 +1,27 @@ //! Transcoder configuration: the rung ladder and catalog wiring. -use moq_net::PathRelativeOwned; +use moq_net::{AsPath, PathRelativeOwned}; + +/// Compute the source broadcast reference for an output nested beneath it. +/// +/// Both paths are normalized before comparison. Returns `None` when `output` is +/// not a descendant of `source`, so callers can omit passthrough renditions. +pub fn source_reference(source: impl AsPath, output: impl AsPath) -> Option { + let source = source.as_path(); + let output = output.as_path(); + let rest = output.strip_prefix(&source)?; + if rest.is_empty() { + return None; + } + + let parents = rest.parts().count().saturating_sub(1); + let rel = if parents == 0 { + ".".to_string() + } else { + vec![".."; parents].join("/") + }; + Some(PathRelativeOwned::from(rel)) +} /// One candidate output rendition: a target resolution (by height) and bitrate. /// @@ -39,7 +60,7 @@ pub struct Config { pub rungs: Vec, /// Where the source broadcast lives relative to the output broadcast, e.g. - /// `".."` when the output is published at `/transcode.hang`. When + /// `"."` when the output is published at `/transcode.hang`. When /// set, the derivative catalog references the source renditions (all video /// and audio) through this path so players fetch them from the source /// directly; the transcoder never proxies or subscribes them. `None` omits @@ -79,3 +100,24 @@ impl Default for Config { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_reference_normalizes_and_counts_output_depth() { + assert_eq!(source_reference("a/b", "a/b/transcode.hang").unwrap().as_str(), "."); + assert_eq!(source_reference("/a//b/", "a/b/dir/").unwrap().as_str(), "."); + assert_eq!( + source_reference("a/b", "a/b/dir/transcode.hang").unwrap().as_str(), + ".." + ); + assert_eq!( + source_reference("a/b", "a/b/one/two/transcode.hang").unwrap().as_str(), + "../.." + ); + assert!(source_reference("a/b", "other/transcode.hang").is_none()); + assert!(source_reference("a/b", "a/b").is_none()); + } +} diff --git a/rs/moq-transcode/src/lib.rs b/rs/moq-transcode/src/lib.rs index b7d6a294f0..c5ec2a5df0 100644 --- a/rs/moq-transcode/src/lib.rs +++ b/rs/moq-transcode/src/lib.rs @@ -27,7 +27,7 @@ mod error; mod feed; mod rung; -pub use config::{Config, Rung}; +pub use config::{Config, Rung, source_reference}; pub use error::Error; /// Transcode `source` into `output` until the source broadcast ends. @@ -505,7 +505,7 @@ mod tests { rungs: vec![Rung::new(120, 100_000)], encoder: moq_video::encode::Kind::Software, decoder: moq_video::decode::Kind::Software, - source: Some(moq_net::PathRelativeOwned::from("..".to_string())), + source: Some(moq_net::PathRelativeOwned::from(".".to_string())), ..Default::default() }; @@ -542,7 +542,7 @@ mod tests { assert!(rung.codec.to_string().starts_with("avc3.")); let passthrough = derived.video.renditions.get("video").expect("passthrough missing"); - assert_eq!(passthrough.broadcast.as_ref().map(|b| b.as_ref()), Some("..")); + assert_eq!(passthrough.broadcast.as_ref().map(|b| b.as_ref()), Some(".")); // Subscribing to the rung starts the live loop, which mirrors source // group sequences 1:1. From 817f8a7d97b8cac6fd1fc7950f6a45918202cf48 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 14 Aug 2026 15:50:03 -0700 Subject: [PATCH 07/12] fix(mux): derive missing video geometry (#2840) Co-authored-by: GPT-5 --- rs/moq-ffi/src/test.rs | 4 +- rs/moq-hls/src/export/mod.rs | 56 ++++++---- rs/moq-mux/src/codec/av1/import.rs | 32 ++++-- rs/moq-mux/src/container/fmp4/export.rs | 28 ++++- rs/moq-mux/src/container/fmp4/export_test.rs | 90 ++++++++++++++- rs/moq-mux/src/container/fmp4/mod.rs | 39 ++++++- rs/moq-mux/src/container/fmp4/muxer.rs | 107 ++++++++++++++++-- rs/moq-mux/src/container/group.rs | 4 +- rs/moq-mux/src/container/mkv/export.rs | 26 ++++- rs/moq-mux/src/container/mkv/export_test.rs | 39 +++++++ rs/moq-mux/src/container/source.rs | 111 ++++++++++++++++--- 11 files changed, 460 insertions(+), 76 deletions(-) diff --git a/rs/moq-ffi/src/test.rs b/rs/moq-ffi/src/test.rs index 72e2eeddaa..9a6510f560 100644 --- a/rs/moq-ffi/src/test.rs +++ b/rs/moq-ffi/src/test.rs @@ -514,7 +514,9 @@ async fn fetch_media_group_rejects_invalid_container_before_fetching() { #[tokio::test] async fn fetch_media_group_decodes_multiple_cmaf_samples() { - let config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); + let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); + config.coded_width = Some(320); + config.coded_height = Some(240); let muxer = moq_mux::container::fmp4::Muxer::video(&config).unwrap(); let init = muxer.init().unwrap().expect("VP8 init should be available"); let catalog_container = hang::catalog::Container::Cmaf { init: init.clone() }; diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 8749d97c89..307b8f0c89 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -239,6 +239,30 @@ mod tests { } } + fn vp8_frame(micros: u64, keyframe: bool) -> moq_mux::container::Frame { + let payload = if keyframe { + // Key frame tag, start code, and 320x240 geometry. + &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00][..] + } else { + &[0x31, 0x00, 0x00][..] + }; + moq_mux::container::Frame { + timestamp: moq_net::Timestamp::from_micros(micros).unwrap(), + payload: bytes::Bytes::copy_from_slice(payload), + keyframe, + duration: None, + } + } + + fn video_config(catalog: &moq_mux::catalog::Producer) -> hang::catalog::VideoConfig { + let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); + config.coded_width = Some(320); + config.coded_height = Some(240); + config.framerate = Some(30.0); + config.timeline = Some(catalog.timeline("video0").unwrap().section()); + config + } + // Let the origin's spawned attach task run so a created broadcast is routable. async fn settle() { for _ in 0..10 { @@ -283,9 +307,9 @@ mod tests { let reserved = catalog.reserve(); let mut registration = reserved.video("video0"); - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.framerate = Some(30.0); - config.timeline = Some(catalog.timeline("video0").unwrap().section()); + let mut config = video_config(&catalog); + config.coded_width = None; + config.coded_height = None; registration.set(config); drop(reserved); @@ -294,11 +318,11 @@ mod tests { let mut media = catalog .media_producer(track, moq_mux::catalog::hang::Container::Legacy) .unwrap(); - media.write(frame(0, true)).unwrap(); - media.write(frame(1_000_000, false)).unwrap(); - media.write(frame(2_000_000, true)).unwrap(); - media.write(frame(3_000_000, false)).unwrap(); - media.write(frame(4_000_000, true)).unwrap(); + media.write(vp8_frame(0, true)).unwrap(); + media.write(vp8_frame(1_000_000, false)).unwrap(); + media.write(vp8_frame(2_000_000, true)).unwrap(); + media.write(vp8_frame(3_000_000, false)).unwrap(); + media.write(vp8_frame(4_000_000, true)).unwrap(); let source = moq_mux::Source::new(origin.consume(), "live"); let broadcaster = Broadcaster::new(source, Config::default()).await.unwrap(); @@ -359,9 +383,7 @@ mod tests { let reserved = catalog.reserve(); let mut registration = reserved.video("video0"); - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.framerate = Some(30.0); - config.timeline = Some(catalog.timeline("video0").unwrap().section()); + let config = video_config(&catalog); registration.set(config); drop(reserved); @@ -430,9 +452,7 @@ mod tests { let reserved = catalog.reserve(); let mut registration = reserved.video("video0"); - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.framerate = Some(30.0); - config.timeline = Some(catalog.timeline("video0").unwrap().section()); + let config = video_config(&catalog); registration.set(config.clone()); drop(reserved); @@ -573,9 +593,7 @@ mod tests { let reserved = catalog.reserve(); let mut registration = reserved.video("video0"); - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.framerate = Some(30.0); - config.timeline = Some(catalog.timeline("video0").unwrap().section()); + let config = video_config(&catalog); registration.set(config); drop(reserved); @@ -632,9 +650,7 @@ mod tests { let reserved = catalog.reserve(); let mut registration = reserved.video("video0"); - let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.framerate = Some(30.0); - config.timeline = Some(catalog.timeline("video0").unwrap().section()); + let config = video_config(&catalog); registration.set(config); drop(reserved); diff --git a/rs/moq-mux/src/codec/av1/import.rs b/rs/moq-mux/src/codec/av1/import.rs index be071bf608..5419139403 100644 --- a/rs/moq-mux/src/codec/av1/import.rs +++ b/rs/moq-mux/src/codec/av1/import.rs @@ -184,17 +184,13 @@ impl Import { } self.last_seq = Some(seq_obu.clone()); - let mut reader = &seq_obu[..]; - let header = ObuHeader::parse(&mut reader)?; - let payload_offset = seq_obu.len() - reader.len(); - - match SequenceHeaderObu::parse(header, &mut &seq_obu[payload_offset..]) { - Ok(seq_header) => self.init(&seq_header), - Err(_) if !self.catalog.configured() => { + match parse_sequence_header(seq_obu)? { + Some(seq_header) => self.init(&seq_header), + None if !self.catalog.configured() => { tracing::debug!("sequence header parse failed, using minimal config"); self.init_minimal(); } - Err(_) => {} + None => {} } Ok(()) } @@ -275,6 +271,26 @@ fn is_sequence_header(obu: &[u8]) -> bool { .unwrap_or(false) } +fn parse_sequence_header(obu: &[u8]) -> Result> { + let mut reader = obu; + let header = ObuHeader::parse(&mut reader)?; + Ok(SequenceHeaderObu::parse(header, &mut reader).ok()) +} + +/// Read encoded dimensions from the first parseable sequence header in a frame. +pub(crate) fn dimensions(payload: &[u8]) -> Result> { + let Some(sequence) = find_sequence_header(payload) else { + return Ok(None); + }; + let Some(sequence) = parse_sequence_header(&sequence)? else { + return Ok(None); + }; + Ok(Some(( + sequence.max_frame_width as u32, + sequence.max_frame_height as u32, + ))) +} + /// Find the first sequence-header OBU in a payload, if any. fn find_sequence_header(payload: &[u8]) -> Option { let mut buf = Bytes::copy_from_slice(payload); diff --git a/rs/moq-mux/src/container/fmp4/export.rs b/rs/moq-mux/src/container/fmp4/export.rs index c1a70478c5..130b2c75e5 100644 --- a/rs/moq-mux/src/container/fmp4/export.rs +++ b/rs/moq-mux/src/container/fmp4/export.rs @@ -189,14 +189,23 @@ impl Export { // is ready, so the source keeps polling for SPS/PPS-bearing frames // instead of parking. let waiting_for_init = !self.init_emitted; - for track in self.tracks.values_mut() { + for (name, track) in &mut self.tracks { if track.pending.is_some() || track.finished { continue; } loop { match track.source.poll_read(waiter) { Poll::Ready(Ok(Some(frame))) => { - if waiting_for_init && !track.source.header_ready() { + let geometry_ready = !track.is_video + || self + .catalog_snapshot + .as_ref() + .and_then(|catalog| catalog.video.renditions.get(name)) + .is_some_and(|config| { + matches!(config.container, Container::Cmaf { .. }) + || track.source.video_geometry_ready(config) + }); + if waiting_for_init && (!track.source.header_ready() || !geometry_ready) { continue; } track.pending = Some(frame); @@ -409,7 +418,17 @@ impl Export { /// True once every source has resolved its codec config so we can build /// the merged init segment. fn init_ready(&self) -> bool { - self.catalog_snapshot.is_some() && self.tracks.values().all(|t| t.source.header_ready()) + let Some(catalog) = self.catalog_snapshot.as_ref() else { + return false; + }; + self.tracks.values().all(|t| t.source.header_ready()) + && catalog.video.renditions.iter().all(|(name, config)| { + matches!(config.container, Container::Cmaf { .. }) + || self + .tracks + .get(name) + .is_some_and(|track| track.source.video_geometry_ready(config)) + }) } /// Build the merged ftyp + multi-track moov init segment from the cached @@ -434,10 +453,11 @@ impl Export { Container::Legacy | Container::Loc => { // H.264/H.265 need a synthesized config record here; VP8 has none. let description = track.source.description(); + let config = track.source.video_config(config).unwrap_or_else(|| config.clone()); let trak = crate::container::fmp4::synthesize_video_trak( track.track_id, track.timescale, - config, + &config, description.map(|d| d.as_ref()), )?; trexs.push(mp4_atom::Trex { diff --git a/rs/moq-mux/src/container/fmp4/export_test.rs b/rs/moq-mux/src/container/fmp4/export_test.rs index 14676e4182..3fc5bad243 100644 --- a/rs/moq-mux/src/container/fmp4/export_test.rs +++ b/rs/moq-mux/src/container/fmp4/export_test.rs @@ -120,22 +120,27 @@ async fn legacy_aac_source_to_cmaf_export_synthesizes_esds() { moov.encode(&mut buf).expect("encode synthesized moov"); } -/// VP8 source (catalog `Container::Legacy`, codec `vp8`, no `description`) → -/// fMP4 export must synthesize a `vp08` sample entry. VP8 carries no out-of-band -/// config, so this exercises the description-less synthesis path. +/// VP8 source (catalog `Container::Legacy`, codec `vp8`, no dimensions or +/// `description`) → fMP4 export derives geometry from the keyframe and +/// synthesizes a `vp08` sample entry. VP8 carries no out-of-band config, so +/// this exercises the dimensionless startup and description-less synthesis paths. #[tokio::test(start_paused = true)] async fn vp8_source_to_cmaf_export_synthesizes_vp08() { use hang::catalog::{Container, VideoCodec, VideoConfig}; let mut live = Live::new(".vp8", |catalog, name| { let mut config = VideoConfig::new(VideoCodec::VP8); - config.coded_width = Some(320); - config.coded_height = Some(240); config.container = Container::Legacy; catalog.lock().video.renditions.insert(name, config); }); + // Geometry-less startup frames must not park the source before the keyframe. + live.track.write(raw_frame(0, &[0x31, 0x00, 0x00], true)).unwrap(); live.track - .write(raw_frame(0, &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a], true)) + .write(raw_frame( + 33_000, + &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00], + true, + )) .unwrap(); live.track.finish().unwrap(); @@ -168,6 +173,77 @@ async fn vp8_source_to_cmaf_export_synthesizes_vp08() { moov.encode(&mut buf).expect("encode synthesized moov"); } +/// If codec data cannot reveal geometry yet, the exporter waits for a later +/// catalog snapshot instead of freezing zero dimensions into the init segment. +#[tokio::test(start_paused = true)] +async fn dimensionless_video_waits_for_catalog_geometry() { + use hang::catalog::{Container, VideoCodec, VideoConfig}; + + let mut live = Live::new(".vp8", |catalog, name| { + let mut config = VideoConfig::new(VideoCodec::VP8); + config.container = Container::Legacy; + catalog.lock().video.renditions.insert(name, config); + }); + let name = live.track.name().to_string(); + // A VP8 interframe carries no geometry. Mark it as the group boundary only + // so the synthetic producer accepts it before the catalog update arrives. + live.track.write(raw_frame(0, &[0x31, 0x00, 0x00], true)).unwrap(); + + let mut exporter = crate::container::fmp4::Export::new(live.source(), live.catalog_stream().await); + let pending = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()).await; + assert!( + pending.is_err(), + "exporter emitted an init without geometry: {pending:?}" + ); + + { + let mut catalog = live.catalog.lock(); + let config = catalog.video.renditions.get_mut(&name).unwrap(); + config.coded_width = Some(320); + config.coded_height = Some(240); + } + + let init = fragment_now(&mut exporter).await.data; + let mut cursor = Cursor::new(init.as_ref()); + let mut moov = None; + while let Some(atom) = mp4_atom::Any::decode_maybe(&mut cursor).expect("decode init") { + if let mp4_atom::Any::Moov(value) = atom { + moov = Some(value); + } + } + let trak = &moov.expect("init segment missing moov").trak[0]; + let mp4_atom::Codec::Vp08(vp08) = &trak.mdia.minf.stbl.stsd.codecs[0] else { + panic!("expected vp08 sample entry"); + }; + assert_eq!((vp08.visual.width, vp08.visual.height), (320, 240)); +} + +/// A fixed codec description cannot recover on a later frame, so malformed +/// metadata must fail instead of leaving the exporter pending for geometry. +#[tokio::test(start_paused = true)] +async fn dimensionless_video_rejects_a_malformed_description() { + use hang::catalog::{Container, H264, VideoConfig}; + + let live = Live::new(".avc1", |catalog, name| { + let mut config = VideoConfig::new(H264 { + profile: 0x42, + constraints: 0, + level: 0x1f, + inline: false, + }); + config.description = Some(bytes::Bytes::from_static(&[1])); + config.container = Container::Legacy; + catalog.lock().video.renditions.insert(name, config); + }); + + let mut exporter = crate::container::fmp4::Export::new(live.source(), live.catalog_stream().await); + let error = exporter.next().await.expect_err("malformed fixed description"); + assert!(matches!( + error, + crate::Error::H264(crate::codec::h264::Error::AvccTooShort) + )); +} + /// VP9 source (catalog `Container::Legacy`, codec `vp09`, no `description`) → /// fMP4 export must synthesize a `vp09` sample entry whose `vpcC` round-trips /// the catalog's VP9 parameters. @@ -507,6 +583,8 @@ async fn unusable_framerate_uses_the_standard_fallback_rate() { let mut live = Live::new(".vp8", |catalog, name| { let mut config = VideoConfig::new(VideoCodec::VP8); + config.coded_width = Some(320); + config.coded_height = Some(240); config.framerate = Some(0.0005); config.container = Container::Legacy; catalog.lock().video.renditions.insert(name, config); diff --git a/rs/moq-mux/src/container/fmp4/mod.rs b/rs/moq-mux/src/container/fmp4/mod.rs index 6b11d319e0..d0768c3f95 100644 --- a/rs/moq-mux/src/container/fmp4/mod.rs +++ b/rs/moq-mux/src/container/fmp4/mod.rs @@ -120,6 +120,10 @@ pub enum Error { #[error("video track {0} missing in catalog")] MissingVideoTrack(String), + /// A synthesized video track has no usable encoded dimensions. + #[error("missing video dimensions for codec: {0}")] + MissingVideoDimensions(String), + #[error("audio track {0} missing in catalog")] MissingAudioTrack(String), @@ -529,8 +533,28 @@ pub(crate) fn synthesize_video_trak( config: &VideoConfig, description: Option<&[u8]>, ) -> Result { - let width = config.coded_width.unwrap_or(0) as u16; - let height = config.coded_height.unwrap_or(0) as u16; + if !matches!( + config.codec, + VideoCodec::H264(_) | VideoCodec::H265(_) | VideoCodec::AV1(_) | VideoCodec::VP8 | VideoCodec::VP9(_) + ) { + return Err(Error::UnsupportedSynthesis(format!("video codec {:?}", config.codec))); + } + + let width = u16::try_from( + config + .coded_width + .ok_or_else(|| Error::MissingVideoDimensions(config.codec.to_string()))?, + ) + .map_err(|_| Error::MissingVideoDimensions(config.codec.to_string()))?; + let height = u16::try_from( + config + .coded_height + .ok_or_else(|| Error::MissingVideoDimensions(config.codec.to_string()))?, + ) + .map_err(|_| Error::MissingVideoDimensions(config.codec.to_string()))?; + if width == 0 || height == 0 { + return Err(Error::MissingVideoDimensions(config.codec.to_string())); + } let visual = mp4_atom::Visual { data_reference_index: 1, width, @@ -584,7 +608,7 @@ pub(crate) fn synthesize_video_trak( vpcc: crate::codec::vp9::vpcc(vp9), ..Default::default() }), - other => return Err(Error::UnsupportedSynthesis(format!("video codec {:?}", other))), + other => unreachable!("unsupported codecs rejected before geometry synthesis: {other:?}"), }; Ok(build_video_trak( @@ -1115,6 +1139,8 @@ mod tests { #[test] fn synthesized_video_init_sets_the_track_flags() { let mut config = VideoConfig::new(hang::catalog::VideoCodec::VP8); + config.coded_width = Some(320); + config.coded_height = Some(240); config.framerate = Some(30.0); let video = synthesize_video_trak(1, 30_000, &config, None).unwrap(); let init = encode_init(None, vec![video], Vec::new()).unwrap(); @@ -1125,6 +1151,13 @@ mod tests { assert_eq!(tkhd.volume.integer(), 0); } + #[test] + fn synthesized_video_init_rejects_missing_dimensions() { + let config = VideoConfig::new(hang::catalog::VideoCodec::VP8); + let error = synthesize_video_trak(1, 30_000, &config, None).unwrap_err(); + assert!(matches!(error, Error::MissingVideoDimensions(_))); + } + #[test] fn decode_reads_trun_sample_duration() { use mp4_atom::Encode; diff --git a/rs/moq-mux/src/container/fmp4/muxer.rs b/rs/moq-mux/src/container/fmp4/muxer.rs index 9bf892d814..0f23576dc3 100644 --- a/rs/moq-mux/src/container/fmp4/muxer.rs +++ b/rs/moq-mux/src/container/fmp4/muxer.rs @@ -7,7 +7,7 @@ use hang::catalog::{AudioConfig, Container as CatalogContainer, VideoConfig}; use crate::catalog::hang::Container as HangContainer; use crate::container::Frame; -use crate::container::source::{VideoTransform, build_video_transform}; +use crate::container::source::{VideoTransform, build_video_transform, catalog_dimensions, codec_dimensions}; use super::export::{ apply_codec_durations, catalog_timescale_audio, catalog_timescale_video, extract_init, infer_missing_durations, @@ -42,8 +42,8 @@ enum Kind { /// [`fragmenter`](Self::fragmenter) instead cuts a stream into one separately addressable /// fragment per frame, for a consumer that stores media per encoded frame. /// -/// For inline-parameter-set codecs (catalog `description` absent), [`init`](Self::init) returns -/// `None` until a group has been [`read`](Self::read) to resolve the config from a keyframe. +/// For video missing codec configuration or geometry, [`init`](Self::init) returns `None` until a +/// group has been [`read`](Self::read) to resolve the missing fields from a keyframe. pub struct Muxer { kind: Kind, container: HangContainer, @@ -64,14 +64,22 @@ impl Muxer { pub fn video(config: &VideoConfig) -> crate::Result { let container = (&config.container).try_into()?; let framerate = super::usable_video_framerate(config).unwrap_or(30.0); + let description = config.description.as_ref().filter(|b| !b.is_empty()).cloned(); + let mut config = config.clone(); + if catalog_dimensions(&config).is_none() + && let Some((width, height)) = codec_dimensions(&config.codec, description.as_deref(), &[])? + { + config.coded_width = Some(width); + config.coded_height = Some(height); + } Ok(Self { container, - transform: build_video_transform(config), - description: config.description.as_ref().filter(|b| !b.is_empty()).cloned(), - timescale: moq_net::Timescale::new(catalog_timescale_video(config)?).map_err(Error::from)?, + transform: build_video_transform(&config), + description, + timescale: moq_net::Timescale::new(catalog_timescale_video(&config)?).map_err(Error::from)?, default_frame: Duration::from_secs_f64(1.0 / framerate), opus: false, - kind: Kind::Video(config.clone()), + kind: Kind::Video(config), }) } @@ -138,6 +146,7 @@ impl Muxer { while let Some(frames) = self.container.read(group).await? { for frame in frames { let Some(transform) = self.transform.as_mut() else { + self.resolve_video_dimensions(&frame.payload)?; out.push(frame); continue; }; @@ -149,6 +158,7 @@ impl Muxer { { self.description = Some(d.clone()); } + self.resolve_video_dimensions(&frame.payload)?; if let Some(payload) = payload { out.push(Frame { payload, ..frame }); } @@ -160,19 +170,40 @@ impl Muxer { Ok(out) } + fn resolve_video_dimensions(&mut self, payload: &[u8]) -> crate::Result<()> { + let Kind::Video(config) = &mut self.kind else { + return Ok(()); + }; + if catalog_dimensions(config).is_some() { + return Ok(()); + } + if let Some((width, height)) = codec_dimensions(&config.codec, self.description.as_deref(), payload)? { + config.coded_width = Some(width); + config.coded_height = Some(height); + } + Ok(()) + } + /// Build the rendition's CMAF init segment (ftyp+moov), or `None` if it isn't buildable yet. /// /// A `Cmaf` rendition's catalog init passes through (with the track id normalized to match /// [`fragment`](Self::fragment)); a `Legacy`/`Loc` rendition's is synthesized from the catalog - /// config. `None` means an inline-parameter-set video rendition whose codec config hasn't been - /// resolved yet: [`read`](Self::read) a group (its keyframe carries the parameter sets) and call - /// again. + /// config. `None` means a video rendition whose codec config or geometry hasn't been resolved + /// yet: [`read`](Self::read) a group (its keyframe carries those fields) and call again. pub fn init(&self) -> crate::Result> { // An inline codec carries its config in-band, so the init can't be built until a keyframe // group has been read. if self.transform.is_some() && self.description.is_none() { return Ok(None); } + if matches!( + self.catalog_container(), + CatalogContainer::Legacy | CatalogContainer::Loc + ) && let Kind::Video(config) = &self.kind + && catalog_dimensions(config).is_none() + { + return Ok(None); + } let mut traks: Vec = Vec::new(); let mut trexs: Vec = Vec::new(); @@ -318,9 +349,61 @@ mod tests { assert_eq!(decoded[1].timestamp.as_micros(), 10_033_000); } + #[tokio::test] + async fn dimensionless_vp8_init_waits_for_keyframe_geometry() { + let track = moq_net::broadcast::Info::new() + .produce() + .create_track("v", None) + .unwrap(); + let mut subscriber = track.subscribe(None); + let mut producer = crate::container::Producer::new(track, HangContainer::Legacy); + producer + .write(Frame { + timestamp: Timestamp::ZERO, + // Key frame tag, start code, and 320x240 geometry. + payload: Bytes::from_static(&[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00]), + keyframe: true, + duration: None, + }) + .unwrap(); + producer.finish().unwrap(); + let mut group = subscriber.next_group().await.unwrap().expect("a group"); + + let config = VideoConfig::new(VideoCodec::VP8); + let mut muxer = Muxer::video(&config).unwrap(); + assert!(muxer.init().unwrap().is_none(), "geometry is unresolved before media"); + + let frames = muxer.read(&mut group).await.unwrap(); + assert_eq!(frames.len(), 1); + let init = muxer.init().unwrap().expect("keyframe geometry makes init buildable"); + let wire = super::super::Wire::from_init(&init).unwrap(); + let mp4_atom::Codec::Vp08(vp08) = &wire.trak().mdia.minf.stbl.stsd.codecs[0] else { + panic!("expected VP8 sample entry"); + }; + assert_eq!((vp08.visual.width, vp08.visual.height), (320, 240)); + } + + #[test] + fn malformed_fixed_description_errors_instead_of_waiting_for_geometry() { + let mut config = VideoConfig::new(hang::catalog::H264 { + profile: 0x42, + constraints: 0, + level: 0x1f, + inline: false, + }); + config.description = Some(Bytes::from_static(&[1])); + + assert!(matches!( + Muxer::video(&config), + Err(crate::Error::H264(crate::codec::h264::Error::AvccTooShort)) + )); + } + // A 30 fps Legacy VP8 rendition: no description needed, so the muxer builds without media. fn video_muxer() -> Muxer { let mut config = VideoConfig::new(VideoCodec::VP8); + config.coded_width = Some(320); + config.coded_height = Some(240); config.framerate = Some(30.0); Muxer::video(&config).unwrap() } @@ -391,6 +474,8 @@ mod tests { #[test] fn low_framerate_fallback_fits_mp4_timing_fields() { let mut config = VideoConfig::new(VideoCodec::VP8); + config.coded_width = Some(320); + config.coded_height = Some(240); config.framerate = Some(0.0011); let muxer = Muxer::video(&config).unwrap(); assert_eq!(muxer.timescale().as_u64(), 11); @@ -487,6 +572,8 @@ mod tests { #[test] fn init_rejects_a_catalog_scale_too_large_for_mdhd() { let mut config = VideoConfig::new(VideoCodec::VP8); + config.coded_width = Some(320); + config.coded_height = Some(240); config.framerate = Some(5_000_000.0); // 5e9 ticks, past u32::MAX let err = Muxer::video(&config).unwrap().init().unwrap_err(); assert!( diff --git a/rs/moq-mux/src/container/group.rs b/rs/moq-mux/src/container/group.rs index 0801ffb056..b6ff4f32eb 100644 --- a/rs/moq-mux/src/container/group.rs +++ b/rs/moq-mux/src/container/group.rs @@ -109,7 +109,9 @@ mod tests { /// One CMAF fragment decodes to several samples, which are handed back one at a time. #[tokio::test] async fn hands_back_a_cmaf_batch_one_frame_at_a_time() { - let config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); + let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); + config.coded_width = Some(320); + config.coded_height = Some(240); let muxer = crate::container::fmp4::Muxer::video(&config).unwrap(); let init = muxer.init().unwrap().expect("VP8 init should be available"); let cmaf = hang::catalog::Container::Cmaf { init }; diff --git a/rs/moq-mux/src/container/mkv/export.rs b/rs/moq-mux/src/container/mkv/export.rs index 6f196bc5a1..c22e5098c3 100644 --- a/rs/moq-mux/src/container/mkv/export.rs +++ b/rs/moq-mux/src/container/mkv/export.rs @@ -219,15 +219,21 @@ impl Export { // without the header anyway, and parking them would stop us from // polling for the next SPS/PPS-bearing frame. let waiting_for_header = !self.header_emitted; - for track in self.tracks.values_mut() { + for (name, track) in &mut self.tracks { if track.pending.is_some() || track.finished { continue; } loop { match track.source.poll_read(waiter) { Poll::Ready(Ok(Some(frame))) => { - if waiting_for_header && !track.source.header_ready() { - // Drop this slice and keep polling for SPS/PPS. + let geometry_ready = track.kind == TrackKind::Audio + || self + .catalog_snapshot + .as_ref() + .and_then(|catalog| catalog.video.renditions.get(name)) + .is_some_and(|config| track.source.video_geometry_ready(config)); + if waiting_for_header && (!track.source.header_ready() || !geometry_ready) { + // Drop this slice and keep polling for codec configuration or geometry. continue; } track.pending = Some(frame); @@ -381,9 +387,16 @@ impl Export { /// catalog arrives `tracks` is empty, and `all()` would otherwise be /// vacuously true and send us into `build_header` with no snapshot. fn header_ready(&self) -> bool { - self.catalog_snapshot.is_some() - && !self.tracks.is_empty() + let Some(catalog) = self.catalog_snapshot.as_ref() else { + return false; + }; + !self.tracks.is_empty() && self.tracks.values().all(|t| t.source.header_ready()) + && catalog.video.renditions.iter().all(|(name, config)| { + self.tracks + .get(name) + .is_some_and(|track| track.source.video_geometry_ready(config)) + }) } fn build_header(&self) -> Result { @@ -408,9 +421,10 @@ impl Export { .tracks .get(name) .ok_or_else(|| Error::MissingVideoTrack(name.clone()))?; + let config = track.source.video_config(config).unwrap_or_else(|| config.clone()); entries.push(build_video_track_entry( track.track_number, - config, + &config, track.source.description(), )?); } diff --git a/rs/moq-mux/src/container/mkv/export_test.rs b/rs/moq-mux/src/container/mkv/export_test.rs index eeae180e33..a840b268cf 100644 --- a/rs/moq-mux/src/container/mkv/export_test.rs +++ b/rs/moq-mux/src/container/mkv/export_test.rs @@ -305,6 +305,45 @@ async fn export_waits_for_catalog_before_header() { drop(producer); } +/// A catalog may publish a codec before its optional dimensions. The MKV +/// header is immutable, so derive geometry from the first keyframe before +/// writing `PixelWidth` and `PixelHeight`. +#[tokio::test(start_paused = true)] +async fn export_derives_video_geometry_before_header() { + use hang::catalog::{Container, VideoConfig}; + + let mut live = Live::new(".vp8", |catalog, name| { + let mut config = VideoConfig::new(VideoCodec::VP8); + config.container = Container::Legacy; + catalog.lock().video.renditions.insert(name, config); + }); + // Geometry-less startup frames must not park the source before the keyframe. + live.track.write(raw_frame(0, &[0x31, 0x00, 0x00], true)).unwrap(); + live.track + .write(raw_frame( + 33_000, + &[0x10, 0x00, 0x00, 0x9d, 0x01, 0x2a, 0x40, 0x01, 0xf0, 0x00], + true, + )) + .unwrap(); + live.track.finish().unwrap(); + + let mut exporter = crate::container::mkv::Export::new(live.source(), live.catalog_stream().await); + let header = tokio::time::timeout(std::time::Duration::from_secs(1), exporter.next()) + .await + .expect("exporter timed out") + .expect("exporter result") + .expect("expected header bytes"); + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = crate::catalog::Producer::new(&mut broadcast).unwrap(); + let mut importer = crate::container::mkv::Import::new(broadcast, catalog.reserve()); + importer.decode(&bytes::BytesMut::from(header.as_ref())).unwrap(); + let video = catalog.snapshot().video.renditions.values().next().unwrap().clone(); + assert_eq!(video.coded_width, Some(320)); + assert_eq!(video.coded_height, Some(240)); +} + #[tokio::test(start_paused = true)] async fn export_emits_blocks_for_each_frame() { // Import a WebM that contains 3 video frames + 2 audio frames, export it, diff --git a/rs/moq-mux/src/container/source.rs b/rs/moq-mux/src/container/source.rs index 8a8e16f08e..a2d920b58d 100644 --- a/rs/moq-mux/src/container/source.rs +++ b/rs/moq-mux/src/container/source.rs @@ -71,6 +71,10 @@ pub(crate) struct ExportSource { /// OpusHead). Some once the codec config is available — from the catalog /// `description`, or synthesized by the transform. description: Option, + /// Video codec used to derive geometry from its configuration or keyframes. + video_codec: Option, + /// Geometry resolved from the initial catalog or codec data received afterward. + video_dimensions: Option<(u32, u32)>, } impl ExportSource { @@ -81,20 +85,7 @@ impl ExportSource { config: &VideoConfig, latency: Duration, ) -> Result, crate::Error> { - let media: HangContainer = (&config.container).try_into()?; - let transform = build_video_transform(config); - let description = config.description.as_ref().filter(|b| !b.is_empty()).cloned(); - let Some(request) = source.request(config.broadcast.as_ref()) else { - return Ok(None); - }; - - Ok(Some(Self { - state: SourceState::Requesting(request, name.to_string()), - media: Some(media), - latency, - transform, - description, - })) + Self::video(source, name, config, latency, build_video_transform(config)) } /// Subscribe to a video rendition without attaching any codec-shape @@ -106,6 +97,16 @@ impl ExportSource { name: &str, config: &VideoConfig, latency: Duration, + ) -> Result, crate::Error> { + Self::video(source, name, config, latency, None) + } + + fn video( + source: &crate::Source, + name: &str, + config: &VideoConfig, + latency: Duration, + transform: Option, ) -> Result, crate::Error> { let media: HangContainer = (&config.container).try_into()?; let description = config.description.as_ref().filter(|b| !b.is_empty()).cloned(); @@ -113,13 +114,17 @@ impl ExportSource { return Ok(None); }; - Ok(Some(Self { + let mut source = Self { state: SourceState::Requesting(request, name.to_string()), media: Some(media), latency, - transform: None, + transform, description, - })) + video_codec: Some(config.codec.clone()), + video_dimensions: catalog_dimensions(config), + }; + source.resolve_video_dimensions(&[])?; + Ok(Some(source)) } /// Subscribe to an audio rendition. Audio has no codec-shape transform; @@ -142,6 +147,8 @@ impl ExportSource { latency, transform: None, description, + video_codec: None, + video_dimensions: None, })) } @@ -156,6 +163,8 @@ impl ExportSource { latency, transform: None, description: None, + video_codec: None, + video_dimensions: None, }) } @@ -170,6 +179,27 @@ impl ExportSource { self.transform.is_none() || self.description.is_some() } + /// Combine the latest catalog config with geometry resolved from codec data. + pub fn video_config(&self, config: &VideoConfig) -> Option { + if catalog_dimensions(config).is_some() { + return Some(config.clone()); + } + + let (width, height) = self.video_dimensions?; + let mut config = config.clone(); + config.coded_width = Some(width); + config.coded_height = Some(height); + Some(config) + } + + /// True when this codec is unsupported or has enough geometry to build a video header. + pub fn video_geometry_ready(&self, config: &VideoConfig) -> bool { + !matches!( + config.codec, + VideoCodec::H264(_) | VideoCodec::H265(_) | VideoCodec::VP8 | VideoCodec::VP9(_) | VideoCodec::AV1(_) + ) || self.video_config(config).is_some() + } + /// Pull the next normalized frame. /// /// Parameter-only frames (SPS/PPS-only inputs to the Avc3 transform) are @@ -227,6 +257,7 @@ impl ExportSource { }; let Some(transform) = self.transform.as_mut() else { + self.resolve_video_dimensions(&frame.payload)?; return Poll::Ready(Ok(Some(frame))); }; @@ -236,10 +267,12 @@ impl ExportSource { // resolved description (it may have just become available) // and pull the next frame. self.refresh_description(); + self.resolve_video_dimensions(&frame.payload)?; continue; } Some(payload) => { self.refresh_description(); + self.resolve_video_dimensions(&payload)?; return Poll::Ready(Ok(Some(Frame { payload, ..frame }))); } } @@ -258,6 +291,50 @@ impl ExportSource { self.description = Some(d.clone()); } } + + fn resolve_video_dimensions(&mut self, payload: &[u8]) -> crate::Result<()> { + if self.video_dimensions.is_some() { + return Ok(()); + } + let Some(codec) = self.video_codec.as_ref() else { + return Ok(()); + }; + self.video_dimensions = codec_dimensions(codec, self.description.as_deref(), payload)?; + Ok(()) + } +} + +pub(crate) fn catalog_dimensions(config: &VideoConfig) -> Option<(u32, u32)> { + let dimensions = (config.coded_width?, config.coded_height?); + (dimensions.0 > 0 && dimensions.1 > 0).then_some(dimensions) +} + +/// Resolve encoded dimensions from codec configuration or an in-band keyframe. +pub(crate) fn codec_dimensions( + codec: &VideoCodec, + description: Option<&[u8]>, + payload: &[u8], +) -> crate::Result> { + let dimensions = match codec { + VideoCodec::H264(_) => match description { + Some(description) => catalog_dimensions(&crate::codec::h264::config(description)?), + None => None, + }, + VideoCodec::H265(_) => match description { + Some(description) => catalog_dimensions(&crate::codec::h265::config(description)?), + None => None, + }, + VideoCodec::VP8 if !payload.is_empty() => crate::codec::vp8::FrameHeader::parse(payload)? + .dimensions + .map(|(width, height)| (u32::from(width), u32::from(height))), + VideoCodec::VP9(_) if !payload.is_empty() => crate::codec::vp9::config_from_keyframe(payload)? + .as_ref() + .and_then(catalog_dimensions), + VideoCodec::AV1(_) if !payload.is_empty() => crate::codec::av1::dimensions(payload)?, + _ => None, + }; + + Ok(dimensions.filter(|(width, height)| *width > 0 && *height > 0)) } /// Build a video transform for an Annex-B source, or `None` if the catalog From 76ddd7ff5aba9c2c35a10aa75f3a89de821ee693 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 14 Aug 2026 15:51:03 -0700 Subject: [PATCH 08/12] reload custom root CAs without restart (#2863) --- doc/bin/relay/auth.md | 5 +- doc/bin/relay/config.md | 17 +- rs/moq-native/Cargo.toml | 6 +- rs/moq-native/src/noq.rs | 7 +- rs/moq-native/src/quiche.rs | 5 +- rs/moq-native/src/quinn.rs | 7 +- rs/moq-native/src/tls.rs | 820 +++++++++++++++++++++++++++++++++--- rs/moq-native/src/watch.rs | 66 +-- rs/moq-relay/src/web.rs | 35 +- 9 files changed, 855 insertions(+), 113 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 7677a790cf..9d4831fc24 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -288,8 +288,9 @@ advertises its own identity by setting `--cluster-mesh` to its externally-reachable URL, which it publishes on the cluster origin for other peers to discover and dial. -The `quinn` and `noq` QUIC backends support mTLS; configuring `tls.root` with a -backend that does not (e.g. `quiche`) is a startup error. +The `quinn`, `noq`, and `quiche` QUIC backends support mTLS. Quinn and noq hot +reload the trusted roots for new handshakes. Quiche currently requires a relay +restart after rotating inbound `server.tls.root` files. ## Stream Listeners diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index 748e255df0..a377b5e06b 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -91,11 +91,14 @@ generate = ["localhost", "127.0.0.1"] # Optional: root CAs to accept for mTLS peer authentication. # Clients that present a cert signed by one of these CAs are granted # full access (publish/subscribe/cluster). Intended for relay clustering. -# Supported by the quinn and noq backends. root = ["/path/to/peer-ca.pem"] ``` -For production, use certificates from Let's Encrypt or another CA. +For production, use certificates from Let's Encrypt or another CA. The Quinn +and Noq backends watch certificate, key, and root CA files and reload them for +new connections. Existing connections keep the identity established by their +original handshake. The Quiche backend reloads outbound client roots but +requires a relay restart after rotating its inbound TLS files. ### \[web.http] @@ -137,8 +140,14 @@ listen = "0.0.0.0:443" # TLS certificates (can be the same as server.tls) cert = "cert.pem" key = "key.pem" + +# Optional root CAs for HTTPS/WSS client certificate authentication. +root = ["/path/to/peer-ca.pem"] ``` +HTTPS/WSS certificate, key, and root CA files are watched and reloaded for new +connections. A failed reload retains the last valid configuration. + ### \[auth] Authentication configuration. @@ -234,6 +243,10 @@ tls.disable_verify = true # resolution_delay = "50ms" ``` +Custom client root files are watched and reloaded for new outbound connections. +If a changed file is temporarily missing, empty, or invalid, the relay retains +the last valid roots. + The connect timeout is also available as `--client-connect-timeout` or `MOQ_CLIENT_CONNECT_TIMEOUT`, the failover delay as `--client-failover-delay` or `MOQ_CLIENT_FAILOVER_DELAY`, and the resolution delay as diff --git a/rs/moq-native/Cargo.toml b/rs/moq-native/Cargo.toml index 859753726b..63eeea12d9 100644 --- a/rs/moq-native/Cargo.toml +++ b/rs/moq-native/Cargo.toml @@ -16,8 +16,8 @@ categories = ["multimedia", "network-programming", "web-programming"] default = ["quinn", "aws-lc-rs", "websocket", "tcp", "uds"] quinn = ["dep:quinn", "dep:web-transport-quinn", "dep:rcgen", "dep:reqwest", "dep:rustls-webpki", "watch", "dep:dns-lookup"] noq = ["dep:web-transport-noq", "dep:rcgen", "dep:reqwest", "dep:rustls-webpki", "watch", "dep:dns-lookup"] -quiche = ["dep:web-transport-quiche", "dep:rcgen", "dep:reqwest", "dep:rustls-webpki", "dep:rustls-native-certs", "dep:dns-lookup"] -# Filesystem watcher for hot-reloading on-disk TLS certs/keys; the QUIC backends imply it. +quiche = ["dep:web-transport-quiche", "dep:rcgen", "dep:reqwest", "dep:rustls-webpki", "dep:rustls-native-certs", "dep:dns-lookup", "watch"] +# Filesystem watcher for hot-reloading on-disk TLS certs, keys, and root CAs. watch = ["dep:notify"] # Capture qlog traces (`--*-quic-qlog `). Off by default: quinn and noq pull in # the qlog crate and its serde_json encoder, which a production build has no use for. @@ -30,7 +30,7 @@ qlog = ["quinn?/qlog", "web-transport-noq?/qlog"] aws-lc-rs = ["rustls/aws-lc-rs", "rcgen?/aws_lc_rs", "quinn?/rustls-aws-lc-rs", "web-transport-noq?/aws-lc-rs"] iroh = ["dep:web-transport-iroh", "dep:web-transport-proto", "dep:noq-proto"] jemalloc = ["dep:tikv-jemallocator", "dep:tikv-jemalloc-ctl"] -websocket = ["dep:qmux"] +websocket = ["dep:qmux", "watch"] # Plain-TCP qmux transport (`tcp://`), no TLS. Server-side and client-side. tcp = ["dep:qmux", "dep:dns-lookup"] # Unix-domain-socket qmux transport (`unix://`), unix-only. Adds peer-credential diff --git a/rs/moq-native/src/noq.rs b/rs/moq-native/src/noq.rs index 5f3f7d5a5d..874f15fab6 100644 --- a/rs/moq-native/src/noq.rs +++ b/rs/moq-native/src/noq.rs @@ -503,11 +503,7 @@ impl NoqServer { let mut tls = if config.tls.root.is_empty() { tls_builder.with_no_client_auth().with_cert_resolver(certs.clone()) } else { - let roots = config.tls.load_roots()?; - let verifier = rustls::server::WebPkiClientVerifier::builder_with_provider(Arc::new(roots), provider) - .allow_unauthenticated() - .build() - .map_err(Error::ClientVerifier)?; + let verifier = config.tls.client_verifier(provider)?; tls_builder .with_client_cert_verifier(verifier) .with_cert_resolver(certs.clone()) @@ -524,6 +520,7 @@ impl NoqServer { tls.alpn_protocols = alpns; tls.key_log = Arc::new(rustls::KeyLogFile::new()); + config.tls.disable_resumption(&mut tls); let tls: noq::crypto::rustls::QuicServerConfig = tls.try_into()?; let mut tls = noq::ServerConfig::with_crypto(Arc::new(tls)); diff --git a/rs/moq-native/src/quiche.rs b/rs/moq-native/src/quiche.rs index e2b5041d99..234597f2ec 100644 --- a/rs/moq-native/src/quiche.rs +++ b/rs/moq-native/src/quiche.rs @@ -304,7 +304,9 @@ impl QuicheClient { // handshake fails closed. let roots = match &verification { Verification::Roots { custom, system } => { - let mut roots = custom.clone(); + // Quiche takes concrete trust anchors per connection rather than a + // verifier, so refresh the custom bundle before each handshake. + let mut roots = custom.refresh(); if *system { let native = rustls_native_certs::load_native_certs(); for err in native.errors { @@ -619,6 +621,7 @@ impl QuicheServer { } if !config.tls.root.is_empty() { + tracing::warn!("the quiche backend snapshots server mTLS roots; restart after rotating --server-tls-root"); let roots = config .tls .root diff --git a/rs/moq-native/src/quinn.rs b/rs/moq-native/src/quinn.rs index 57f11254b7..163ed048ad 100644 --- a/rs/moq-native/src/quinn.rs +++ b/rs/moq-native/src/quinn.rs @@ -516,11 +516,7 @@ impl QuinnServer { let mut tls = if config.tls.root.is_empty() { tls_builder.with_no_client_auth().with_cert_resolver(certs.clone()) } else { - let roots = config.tls.load_roots()?; - let verifier = rustls::server::WebPkiClientVerifier::builder_with_provider(Arc::new(roots), provider) - .allow_unauthenticated() - .build() - .map_err(Error::ClientVerifier)?; + let verifier = config.tls.client_verifier(provider)?; tls_builder .with_client_cert_verifier(verifier) .with_cert_resolver(certs.clone()) @@ -537,6 +533,7 @@ impl QuinnServer { tls.alpn_protocols = alpns; tls.key_log = Arc::new(rustls::KeyLogFile::new()); + config.tls.disable_resumption(&mut tls); let tls: quinn::crypto::rustls::QuicServerConfig = tls.try_into()?; let mut tls = quinn::ServerConfig::with_crypto(Arc::new(tls)); diff --git a/rs/moq-native/src/tls.rs b/rs/moq-native/src/tls.rs index 771e240399..9aa0e5c067 100644 --- a/rs/moq-native/src/tls.rs +++ b/rs/moq-native/src/tls.rs @@ -5,14 +5,16 @@ //! supplies the certificate chain to serve, loaded from disk or self-signed on //! startup, and optionally the roots that authenticate mTLS clients. //! -//! Certificates loaded from disk are watched and hot reloaded, so rotating them -//! needs no restart. [`Certificates`] reads the current set back out. +//! Certificates, keys, and custom root CAs loaded from disk are normally hot +//! reloaded for new handshakes. Quiche servers are the exception: all inbound +//! TLS material is fixed when the listener is built. [`Certificates`] reads the +//! current served set back out. use crate::crypto; use rustls::pki_types::pem::PemObject; use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use std::{fs, io}; #[cfg(all( @@ -20,9 +22,6 @@ use std::{fs, io}; any(feature = "aws-lc-rs", feature = "ring") ))] use rustls::pki_types::PrivatePkcs8KeyDer; -#[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] -use std::sync::RwLock; - /// Errors loading or generating TLS certificates and keys. /// /// Shared by the client TLS config and the quinn/noq servers so each backend's @@ -127,6 +126,10 @@ pub enum Error { #[error("failed to build client certificate verifier")] ClientVerifier(#[source] rustls::server::VerifierBuilderError), + /// The server-certificate verifier couldn't be built from the configured roots. + #[error("failed to build server certificate verifier")] + ServerVerifier(#[source] rustls::client::VerifierBuilderError), + /// Generating a self-signed certificate failed. #[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] #[error(transparent)] @@ -155,6 +158,19 @@ pub(crate) fn read_certs(path: &Path) -> Result>> { .map_err(Error::Read) } +/// Load every configured custom root, rejecting a path that contains no certificates. +fn read_roots(paths: &[PathBuf]) -> Result>> { + let mut roots = Vec::new(); + for path in paths { + let certs = read_certs(path)?; + if certs.is_empty() { + return Err(Error::EmptyRoots(path.clone())); + } + roots.extend(certs); + } + Ok(roots) +} + // ── Client ────────────────────────────────────────────────────────── /// TLS configuration for the client. @@ -173,6 +189,8 @@ pub struct Client { /// roots are only loaded when no custom root is given, so passing a root /// replaces them; set `--client-tls-system-roots` to trust both (e.g. to reach a /// local relay with a private CA and a remote one with a public CA). + /// Files are hot reloaded for new connections, retaining the last valid roots + /// if a rotation is temporarily missing or malformed. #[serde(skip_serializing_if = "Vec::is_empty")] #[arg(id = "client-tls-root", long = "client-tls-root", env = "MOQ_CLIENT_TLS_ROOT")] #[serde_as(as = "serde_with::OneOrMany<_>")] @@ -299,6 +317,148 @@ struct Deprecated { disable_verify: Option, } +/// The last valid contents of a set of custom root files. +/// +/// Quiche consumes concrete DER roots per connection, while rustls consumes a +/// verifier. Keeping the parsed roots here gives both paths identical +/// last-known-good behavior when a file is briefly absent or incomplete during +/// rotation. +#[derive(Clone)] +pub(crate) struct CustomRoots { + paths: Vec, + current: Arc>>>, +} + +impl CustomRoots { + fn new(paths: Vec) -> Result { + let current = read_roots(&paths)?; + Ok(Self { + paths, + current: Arc::new(RwLock::new(current)), + }) + } + + fn load(&self) -> Result>> { + read_roots(&self.paths) + } + + fn replace(&self, roots: Vec>) { + *self.current.write().unwrap_or_else(std::sync::PoisonError::into_inner) = roots; + } + + pub(crate) fn current(&self) -> Vec> { + self.current + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + /// Refresh from disk, retaining and returning the last valid roots on failure. + #[cfg(feature = "quiche")] + pub(crate) fn refresh(&self) -> Vec> { + self.refresh_with(|| self.load()) + } + + #[cfg(feature = "quiche")] + fn refresh_with( + &self, + load: impl FnOnce() -> Result>>, + ) -> Vec> { + let mut current = self.current.write().unwrap_or_else(std::sync::PoisonError::into_inner); + match load().and_then(|roots| { + root_store(&roots)?; + Ok(roots) + }) { + Ok(roots) => { + *current = roots.clone(); + roots + } + Err(err) => { + tracing::warn!(%err, "failed to reload client root certificates; retaining previous roots"); + current.clone() + } + } + } +} + +#[cfg(feature = "watch")] +struct ReloadState { + current: RwLock>, + build: Box Result> + Send + Sync>, + role: &'static str, +} + +#[cfg(feature = "watch")] +impl ReloadState { + fn reload(&self) { + match (self.build)() { + Ok(next) => { + *self.current.write().unwrap_or_else(std::sync::PoisonError::into_inner) = next; + tracing::info!(role = self.role, "reloaded TLS root certificates"); + } + Err(err) => { + tracing::warn!(%err, role = self.role, "failed to reload TLS root certificates; retaining previous roots"); + } + } + } + + fn current(&self) -> Arc { + self.current + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +/// A verifier whose implementation is replaced after its root files change. +#[cfg(feature = "watch")] +struct Reloading { + state: Arc>, + // Holds the OS watcher alive for exactly as long as the TLS config. + _watcher: Option, +} + +#[cfg(feature = "watch")] +impl Reloading { + fn new( + paths: &[PathBuf], + initial: Arc, + role: &'static str, + build: impl Fn() -> Result> + Send + Sync + 'static, + ) -> Self { + let state = Arc::new(ReloadState { + current: RwLock::new(initial), + build: Box::new(build), + role, + }); + + let reload = state.clone(); + let watcher = match crate::watch::callback(paths, move || reload.reload()) { + Ok(watcher) => Some(watcher), + Err(err) => { + tracing::error!(%err, role, "failed to watch TLS root certificates; hot reload disabled"); + None + } + }; + + Self { + state, + _watcher: watcher, + } + } + + fn current(&self) -> Arc { + self.state.current() + } +} + +#[cfg(feature = "watch")] +impl std::fmt::Debug for Reloading { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Reloading").field("role", &self.state.role).finish() + } +} + /// The resolved server-certificate verification policy. /// /// Computed once by [Client::verification] and shared by every backend (the @@ -318,10 +478,7 @@ pub(crate) enum Verification { /// store is trusted too; each backend resolves that its own way (the rustls /// backends use the OS platform verifier, quiche loads the native roots). /// `custom` are extra PEM roots trusted in addition. - Roots { - custom: Vec>, - system: bool, - }, + Roots { custom: CustomRoots, system: bool }, } impl Client { @@ -407,20 +564,13 @@ impl Client { // root replaces them unless the system roots are explicitly re-enabled. let system = system_roots.unwrap_or(roots.is_empty()); - let mut custom = Vec::new(); - for root in &roots { - let certs = read_certs(root)?; - if certs.is_empty() { - return Err(Error::EmptyRoots(root.clone())); - } - custom.extend(certs); - } + let custom = CustomRoots::new(roots)?; // WebPKI needs at least one trusted root to ever succeed, so fail fast // instead of producing confusing handshake errors later. With system // trust enabled the verifier supplies its own roots, so custom roots are // optional. - if !system && custom.is_empty() { + if !system && custom.current().is_empty() { return Err(Error::NoRoots); } @@ -455,56 +605,86 @@ impl Client { pub fn build(&self) -> Result { let provider = crypto::provider(); let verification = self.verification()?; + let reloadable_roots = cfg!(feature = "watch") + && matches!(&verification, Verification::Roots { custom, .. } if !custom.paths.is_empty()); // Allow TLS 1.2 in addition to 1.3 for WebSocket compatibility. // QUIC always negotiates TLS 1.3 regardless of this setting. let builder = rustls::ClientConfig::builder_with_provider(provider.clone()) .with_protocol_versions(&[&rustls::version::TLS13, &rustls::version::TLS12])?; - // Install the server-certificate verifier. Disabled/Fingerprints get a - // placeholder empty store here and swap in their own verifier below. - let builder = match &verification { - Verification::Roots { custom, system: true } => Self::system_verifier(builder, custom, &provider)?, - Verification::Roots { custom, system: false } => builder.with_root_certificates(root_store(custom)?), - Verification::Disabled | Verification::Fingerprints(_) => { - builder.with_root_certificates(rustls::RootCertStore::empty()) - } - }; - - let mut tls = self.with_client_auth(builder)?; - - match verification { + let verifier: Arc = match verification { Verification::Disabled => { tracing::warn!( "TLS server certificate verification is disabled; A man-in-the-middle attack is possible." ); - tls.dangerous() - .set_certificate_verifier(Arc::new(NoCertificateVerification(provider))); + Arc::new(NoCertificateVerification(provider)) } Verification::Fingerprints(fingerprints) => { let fingerprints = fingerprints.into_iter().map(|fp| fp.to_vec()).collect(); - let verifier = FingerprintVerifier::new(provider, fingerprints); - tls.dangerous().set_certificate_verifier(Arc::new(verifier)); + Arc::new(FingerprintVerifier::new(provider, fingerprints)) } - // The verifier was installed by the builder above. - Verification::Roots { .. } => {} + Verification::Roots { custom, system } => Self::root_server_verifier(custom, system, provider)?, + }; + + let builder = builder.dangerous().with_custom_certificate_verifier(verifier); + let mut tls = self.with_client_auth(builder)?; + + // A resumed session skips certificate verification. Disable resumption when + // roots can change underneath this config so removing a CA takes effect on + // every subsequent handshake. + if reloadable_roots { + tls.resumption = rustls::client::Resumption::disabled(); } Ok(tls) } - /// Build the verifier for system/default trust on the rustls backends. + /// Build a reloadable verifier for custom roots and system/default trust. /// /// Uses the OS-native platform verifier (Keychain/SecTrust, Windows /// CryptoAPI, or the native store on Linux) everywhere it works, optionally /// extended with `custom` PEM roots. Android's platform verifier needs JNI /// setup (see [`init_android`]); until that has run we trust the bundled /// Mozilla roots so verification still works out of the box. - fn system_verifier( - builder: rustls::ConfigBuilder, + fn root_server_verifier( + custom: CustomRoots, + system: bool, + provider: crypto::Provider, + ) -> Result> { + let initial = Self::build_root_server_verifier(&custom.current(), system, &provider)?; + + #[cfg(feature = "watch")] + if !custom.paths.is_empty() { + let paths = custom.paths.clone(); + let reload = custom.clone(); + let reload_provider = provider.clone(); + let verifier = ReloadingServerVerifier::new(&paths, initial, move || { + let roots = reload.load()?; + let verifier = Self::build_root_server_verifier(&roots, system, &reload_provider)?; + reload.replace(roots); + Ok(verifier) + }); + return Ok(Arc::new(verifier)); + } + + Ok(initial) + } + + fn build_root_server_verifier( custom: &[CertificateDer<'static>], + system: bool, provider: &crypto::Provider, - ) -> Result> { + ) -> Result> { + if !system { + let roots = root_store(custom)?; + let verifier = + rustls::client::WebPkiServerVerifier::builder_with_provider(Arc::new(roots), provider.clone()) + .build() + .map_err(Error::ServerVerifier)?; + return Ok(verifier); + } + // Android's platform verifier needs JNI init (see `init_android`) and, // unlike the other platforms, can't be extended with custom roots. So use // it only once initialized and with no custom roots; otherwise trust the @@ -513,7 +693,7 @@ impl Client { { if ANDROID_INITIALIZED.load(std::sync::atomic::Ordering::Acquire) && custom.is_empty() { let verifier = rustls_platform_verifier::Verifier::new(provider.clone())?; - return Ok(builder.dangerous().with_custom_certificate_verifier(Arc::new(verifier))); + return Ok(Arc::new(verifier)); } let mut roots = rustls::RootCertStore::empty(); @@ -521,7 +701,11 @@ impl Client { for cert in custom { roots.add(cert.clone()).map_err(Error::AddRoot)?; } - Ok(builder.with_root_certificates(roots)) + let verifier = + rustls::client::WebPkiServerVerifier::builder_with_provider(Arc::new(roots), provider.clone()) + .build() + .map_err(Error::ServerVerifier)?; + Ok(verifier) } #[cfg(not(target_os = "android"))] @@ -531,7 +715,7 @@ impl Client { } else { rustls_platform_verifier::Verifier::new_with_extra_roots(custom.iter().cloned(), provider.clone())? }; - Ok(builder.dangerous().with_custom_certificate_verifier(Arc::new(verifier))) + Ok(Arc::new(verifier)) } } @@ -636,7 +820,9 @@ pub struct Server { /// do not present a certificate are unaffected. /// /// Plain-TLS listeners built via [`Self::server_config`] also use these roots - /// for optional mTLS. + /// for optional mTLS. Root files are hot reloaded for new handshakes on the + /// rustls-based backends; quiche servers require a restart because their TLS + /// hook fixes client-auth roots when the listener is built. #[arg( long = "server-tls-root", id = "server-tls-root", @@ -649,19 +835,53 @@ pub struct Server { } impl Server { + /// Disable cached client authentication when client roots can reload. + #[cfg(feature = "watch")] + pub(crate) fn disable_resumption(&self, tls: &mut rustls::ServerConfig) { + if !self.root.is_empty() { + tls.session_storage = Arc::new(rustls::server::NoServerSessionStorage {}); + tls.send_tls13_tickets = 0; + } + } + /// Load all configured root CAs into a [`rustls::RootCertStore`]. pub fn load_roots(&self) -> Result { - let mut roots = rustls::RootCertStore::empty(); - for path in &self.root { - let certs = read_certs(path)?; - if certs.is_empty() { - return Err(Error::Empty); - } - for cert in certs { - roots.add(cert).map_err(Error::AddRoot)?; - } + root_store(&read_roots(&self.root)?) + } + + /// Build the optional-client-auth verifier, reloading configured roots in place. + #[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] + pub(crate) fn client_verifier( + &self, + provider: crypto::Provider, + ) -> Result> { + let initial = Self::build_client_verifier(&self.root, &provider)?; + + #[cfg(feature = "watch")] + { + let paths = self.root.clone(); + let reload_paths = paths.clone(); + let reload_provider = provider.clone(); + let verifier = ReloadingClientVerifier::new(&paths, initial, move || { + Self::build_client_verifier(&reload_paths, &reload_provider) + }); + Ok(Arc::new(verifier)) } - Ok(roots) + + #[cfg(not(feature = "watch"))] + Ok(initial) + } + + #[cfg(any(feature = "quinn", feature = "noq", feature = "quiche"))] + fn build_client_verifier( + paths: &[PathBuf], + provider: &crypto::Provider, + ) -> Result> { + let roots = root_store(&read_roots(paths)?)?; + rustls::server::WebPkiClientVerifier::builder_with_provider(Arc::new(roots), provider.clone()) + .allow_unauthenticated() + .build() + .map_err(Error::ClientVerifier) } /// Build a [`rustls::ServerConfig`] for a plain-TLS (non-QUIC) server, e.g. an @@ -694,15 +914,12 @@ fn server_config(config: &Server, alpn: Vec>) -> Result, +} + +#[cfg(feature = "watch")] +impl ReloadingServerVerifier { + fn new( + paths: &[PathBuf], + initial: Arc, + build: impl Fn() -> Result> + Send + Sync + 'static, + ) -> Self { + Self { + inner: Reloading::new(paths, initial, "client", build), + } + } +} + +#[cfg(feature = "watch")] +impl rustls::client::danger::ServerCertVerifier for ReloadingServerVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + ocsp_response: &[u8], + now: UnixTime, + ) -> std::result::Result { + self.inner + .current() + .verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + self.inner.current().verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + self.inner.current().verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.current().supported_verify_schemes() + } + + fn requires_raw_public_keys(&self) -> bool { + self.inner.current().requires_raw_public_keys() + } +} + +/// Delegates each client-certificate check to the latest valid root verifier. +#[cfg(all(feature = "watch", any(feature = "quinn", feature = "noq", feature = "quiche")))] +#[derive(Debug)] +struct ReloadingClientVerifier { + inner: Reloading, +} + +#[cfg(all(feature = "watch", any(feature = "quinn", feature = "noq", feature = "quiche")))] +impl ReloadingClientVerifier { + fn new( + paths: &[PathBuf], + initial: Arc, + build: impl Fn() -> Result> + Send + Sync + 'static, + ) -> Self { + Self { + inner: Reloading::new(paths, initial, "server", build), + } + } +} + +#[cfg(all(feature = "watch", any(feature = "quinn", feature = "noq", feature = "quiche")))] +impl rustls::server::danger::ClientCertVerifier for ReloadingClientVerifier { + fn offer_client_auth(&self) -> bool { + self.inner.current().offer_client_auth() + } + + fn client_auth_mandatory(&self) -> bool { + self.inner.current().client_auth_mandatory() + } + + fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] { + // A live verifier cannot return a borrowed slice from a replaceable value. + // Empty hints ask clients to offer any configured identity, which rustls + // then verifies against the current roots. + &[] + } + + fn verify_client_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + now: UnixTime, + ) -> std::result::Result { + self.inner.current().verify_client_cert(end_entity, intermediates, now) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + self.inner.current().verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result { + self.inner.current().verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.current().supported_verify_schemes() + } + + fn requires_raw_public_keys(&self) -> bool { + self.inner.current().requires_raw_public_keys() + } +} + // ── NoCertificateVerification ─────────────────────────────────────── #[derive(Debug)] @@ -955,6 +1307,8 @@ mod tests { use super::*; use rustls::client::danger::ServerCertVerifier; use rustls::pki_types::ServerName; + #[cfg(feature = "watch")] + use rustls::server::danger::ClientCertVerifier; fn self_signed() -> CertificateDer<'static> { let key = rcgen::KeyPair::generate().unwrap(); @@ -1110,6 +1464,350 @@ mod tests { (file, path) } + #[cfg(all(feature = "watch", any(feature = "quinn", feature = "noq", feature = "quiche")))] + fn signed_certificates() -> ( + String, + CertificateDer<'static>, + PrivateKeyDer<'static>, + CertificateDer<'static>, + PrivateKeyDer<'static>, + ) { + use rcgen::{BasicConstraints, ExtendedKeyUsagePurpose, IsCa, Issuer}; + + let ca_key = rcgen::KeyPair::generate().unwrap(); + let mut ca_params = rcgen::CertificateParams::new(Vec::::new()).unwrap(); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + let ca = ca_params.self_signed(&ca_key).unwrap(); + let issuer = Issuer::from_params(&ca_params, &ca_key); + + let server_key = rcgen::KeyPair::generate().unwrap(); + let server_key_der = PrivatePkcs8KeyDer::from(server_key.serialize_der()); + let mut server_params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + let server = server_params.signed_by(&server_key, &issuer).unwrap(); + + let client_key = rcgen::KeyPair::generate().unwrap(); + let client_key_der = PrivatePkcs8KeyDer::from(client_key.serialize_der()); + let mut client_params = rcgen::CertificateParams::new(Vec::::new()).unwrap(); + client_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth]; + let client = client_params.signed_by(&client_key, &issuer).unwrap(); + + ( + ca.pem(), + server.into(), + server_key_der.into(), + client.into(), + client_key_der.into(), + ) + } + + #[cfg(all(feature = "watch", any(feature = "quinn", feature = "noq", feature = "quiche")))] + fn handshake_kinds( + client: Arc, + server: Arc, + ) -> std::result::Result<(rustls::HandshakeKind, rustls::HandshakeKind), rustls::Error> { + let name = ServerName::try_from("localhost").unwrap(); + let mut client = rustls::ClientConnection::new(client, name).unwrap(); + let mut server = rustls::ServerConnection::new(server).unwrap(); + + for _ in 0..100 { + let mut client_data = Vec::new(); + client.write_tls(&mut client_data).unwrap(); + if !client_data.is_empty() { + server.read_tls(&mut client_data.as_slice()).unwrap(); + server.process_new_packets()?; + } + + let mut server_data = Vec::new(); + server.write_tls(&mut server_data).unwrap(); + if !server_data.is_empty() { + client.read_tls(&mut server_data.as_slice()).unwrap(); + client.process_new_packets()?; + } + + if !client.is_handshaking() && !server.is_handshaking() && !client.wants_write() && !server.wants_write() { + return Ok((client.handshake_kind().unwrap(), server.handshake_kind().unwrap())); + } + } + + panic!("TLS handshake did not settle"); + } + + #[cfg(all(feature = "watch", any(feature = "quinn", feature = "noq", feature = "quiche")))] + #[test] + fn reloadable_roots_disable_session_resumption() { + use std::io::Write; + + let (ca, server_cert, server_key, _, _) = signed_certificates(); + let mut root_file = tempfile::NamedTempFile::new().unwrap(); + root_file.write_all(ca.as_bytes()).unwrap(); + let roots = read_roots(&[root_file.path().to_path_buf()]).unwrap(); + let provider = crypto::provider(); + + let control = rustls::ClientConfig::builder_with_provider(provider.clone()) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(root_store(&roots).unwrap()) + .with_no_client_auth(); + let reloadable = Client { + root: vec![root_file.path().to_path_buf()], + ..Default::default() + } + .build() + .unwrap(); + let server = rustls::ServerConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .unwrap() + .with_no_client_auth() + .with_single_cert(vec![server_cert], server_key) + .unwrap(); + let server = Arc::new(server); + + let control = Arc::new(control); + assert_eq!( + handshake_kinds(control.clone(), server.clone()).unwrap().0, + rustls::HandshakeKind::Full + ); + assert_eq!( + handshake_kinds(control, server.clone()).unwrap().0, + rustls::HandshakeKind::Resumed + ); + + let reloadable = Arc::new(reloadable); + assert_eq!( + handshake_kinds(reloadable.clone(), server.clone()).unwrap().0, + rustls::HandshakeKind::Full + ); + assert_eq!( + handshake_kinds(reloadable, server).unwrap().0, + rustls::HandshakeKind::Full + ); + } + + #[cfg(all(feature = "watch", any(feature = "quinn", feature = "noq", feature = "quiche")))] + #[test] + fn reloadable_client_roots_disable_server_resumption() { + use std::io::Write; + + let (ca_a, server_cert, server_key, client_cert, client_key) = signed_certificates(); + let (ca_b, _, _, _, _) = signed_certificates(); + let mut root_file = tempfile::NamedTempFile::new().unwrap(); + root_file.write_all(ca_a.as_bytes()).unwrap(); + let paths = vec![root_file.path().to_path_buf()]; + let provider = crypto::provider(); + + let build_client = |cert, key| { + rustls::ClientConfig::builder_with_provider(provider.clone()) + .with_safe_default_protocol_versions() + .unwrap() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoCertificateVerification(provider.clone()))) + .with_client_auth_cert(vec![cert], key) + .unwrap() + }; + let control_client = Arc::new(build_client(client_cert.clone(), client_key.clone_key())); + let reloadable_client = Arc::new(build_client(client_cert, client_key)); + + let verifier = Server::build_client_verifier(&paths, &provider).unwrap(); + let control = rustls::ServerConfig::builder_with_provider(provider.clone()) + .with_safe_default_protocol_versions() + .unwrap() + .with_client_cert_verifier(verifier) + .with_single_cert(vec![server_cert.clone()], server_key.clone_key()) + .unwrap(); + + let initial = Server::build_client_verifier(&paths, &provider).unwrap(); + let reload_paths = paths.clone(); + let reload_provider = provider.clone(); + let verifier = Arc::new(ReloadingClientVerifier::new(&paths, initial, move || { + Server::build_client_verifier(&reload_paths, &reload_provider) + })); + let reload = verifier.inner.state.clone(); + let mut reloadable = rustls::ServerConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .unwrap() + .with_client_cert_verifier(verifier) + .with_single_cert(vec![server_cert], server_key) + .unwrap(); + Server { + root: paths, + ..Default::default() + } + .disable_resumption(&mut reloadable); + + let control = Arc::new(control); + assert_eq!( + handshake_kinds(control_client.clone(), control.clone()).unwrap().1, + rustls::HandshakeKind::Full + ); + assert_eq!( + handshake_kinds(control_client, control).unwrap().1, + rustls::HandshakeKind::Resumed + ); + + let reloadable = Arc::new(reloadable); + assert_eq!( + handshake_kinds(reloadable_client.clone(), reloadable.clone()) + .unwrap() + .1, + rustls::HandshakeKind::Full + ); + + std::fs::write(root_file.path(), ca_b).unwrap(); + reload.reload(); + assert!(handshake_kinds(reloadable_client, reloadable).is_err()); + } + + #[cfg(all(feature = "watch", any(feature = "quinn", feature = "noq", feature = "quiche")))] + #[test] + fn custom_roots_reload_for_new_client_and_server_handshakes() { + use std::io::Write; + + let (ca_a, server_a, _, client_a, _) = signed_certificates(); + let (ca_b, server_b, _, client_b, _) = signed_certificates(); + let mut root_file = tempfile::NamedTempFile::new().unwrap(); + root_file.write_all(ca_a.as_bytes()).unwrap(); + let paths = vec![root_file.path().to_path_buf()]; + let provider = crypto::provider(); + + let custom = CustomRoots::new(paths.clone()).unwrap(); + let initial = Client::build_root_server_verifier(&custom.current(), false, &provider).unwrap(); + let reload_custom = custom.clone(); + let reload_provider = provider.clone(); + let server_verifier = ReloadingServerVerifier::new(&paths, initial, move || { + let roots = reload_custom.load()?; + let verifier = Client::build_root_server_verifier(&roots, false, &reload_provider)?; + reload_custom.replace(roots); + Ok(verifier) + }); + + let initial = Server::build_client_verifier(&paths, &provider).unwrap(); + let reload_paths = paths.clone(); + let reload_provider = provider.clone(); + let client_verifier = ReloadingClientVerifier::new(&paths, initial, move || { + Server::build_client_verifier(&reload_paths, &reload_provider) + }); + + let name = ServerName::try_from("localhost").unwrap(); + let now = UnixTime::now(); + assert!( + server_verifier + .verify_server_cert(&server_a, &[], &name, &[], now) + .is_ok() + ); + assert!( + server_verifier + .verify_server_cert(&server_b, &[], &name, &[], now) + .is_err() + ); + assert!(client_verifier.verify_client_cert(&client_a, &[], now).is_ok()); + assert!(client_verifier.verify_client_cert(&client_b, &[], now).is_err()); + + std::fs::write(root_file.path(), ca_b).unwrap(); + server_verifier.inner.state.reload(); + client_verifier.inner.state.reload(); + + assert!( + server_verifier + .verify_server_cert(&server_a, &[], &name, &[], now) + .is_err() + ); + assert!(client_verifier.verify_client_cert(&client_a, &[], now).is_err()); + + // A malformed replacement must not erase the last valid verifier. + std::fs::write(root_file.path(), "not a PEM certificate").unwrap(); + server_verifier.inner.state.reload(); + client_verifier.inner.state.reload(); + assert!( + server_verifier + .verify_server_cert(&server_b, &[], &name, &[], now) + .is_ok() + ); + assert!(client_verifier.verify_client_cert(&client_b, &[], now).is_ok()); + } + + #[cfg(all(feature = "quiche", feature = "watch"))] + #[test] + fn custom_root_refresh_retains_last_valid_bundle() { + use std::io::Write; + + let (ca_a, _, _, _, _) = signed_certificates(); + let (ca_b, _, _, _, _) = signed_certificates(); + let mut root_file = tempfile::NamedTempFile::new().unwrap(); + root_file.write_all(ca_a.as_bytes()).unwrap(); + let roots = CustomRoots::new(vec![root_file.path().to_path_buf()]).unwrap(); + let initial = roots.current(); + + std::fs::write(root_file.path(), "not a PEM certificate").unwrap(); + assert_eq!(roots.refresh(), initial); + + std::fs::write( + root_file.path(), + "-----BEGIN CERTIFICATE-----\nAQID\n-----END CERTIFICATE-----\n", + ) + .unwrap(); + assert_eq!(roots.refresh(), initial); + + std::fs::write(root_file.path(), ca_b).unwrap(); + let rotated = roots.refresh(); + assert_ne!(rotated, initial); + assert_eq!(roots.current(), rotated); + } + + #[cfg(all(feature = "quiche", feature = "watch"))] + #[test] + fn custom_root_refresh_serializes_cache_updates() { + let (ca_a, _, _, _, _) = signed_certificates(); + let (ca_b, _, _, _, _) = signed_certificates(); + let (ca_c, _, _, _, _) = signed_certificates(); + let parse = |pem: &str| { + CertificateDer::pem_slice_iter(pem.as_bytes()) + .collect::, _>>() + .unwrap() + }; + let initial = parse(&ca_a); + let bundle_b = parse(&ca_b); + let bundle_c = parse(&ca_c); + let roots = CustomRoots { + paths: Vec::new(), + current: Arc::new(RwLock::new(initial)), + }; + + let (first_loaded_tx, first_loaded_rx) = std::sync::mpsc::sync_channel(0); + let (release_first_tx, release_first_rx) = std::sync::mpsc::sync_channel(0); + let first_roots = roots.clone(); + let first = std::thread::spawn(move || { + first_roots.refresh_with(|| { + first_loaded_tx.send(()).unwrap(); + release_first_rx.recv().unwrap(); + Ok(bundle_b) + }) + }); + first_loaded_rx.recv().unwrap(); + + let (second_ready_tx, second_ready_rx) = std::sync::mpsc::sync_channel(0); + let (second_loaded_tx, second_loaded_rx) = std::sync::mpsc::channel(); + let second_roots = roots.clone(); + let expected = bundle_c.clone(); + let second = std::thread::spawn(move || { + second_ready_tx.send(()).unwrap(); + second_roots.refresh_with(|| { + second_loaded_tx.send(()).unwrap(); + Ok(bundle_c) + }) + }); + second_ready_rx.recv().unwrap(); + let overlapped = second_loaded_rx + .recv_timeout(std::time::Duration::from_millis(100)) + .is_ok(); + + release_first_tx.send(()).unwrap(); + first.join().unwrap(); + second.join().unwrap(); + assert!(!overlapped, "root cache refresh transactions must not overlap"); + assert_eq!(roots.current(), expected); + } + #[test] fn build_uses_platform_verifier_by_default() { // No custom roots, system trust on: resolves to the OS platform verifier diff --git a/rs/moq-native/src/watch.rs b/rs/moq-native/src/watch.rs index 6e6f7105e5..a0971dc582 100644 --- a/rs/moq-native/src/watch.rs +++ b/rs/moq-native/src/watch.rs @@ -1,4 +1,4 @@ -//! Watch on-disk files (TLS certs/keys) and get notified when they're rotated. +//! Watch on-disk TLS certificates, keys, and root CAs for rotation. use std::path::{Path, PathBuf}; @@ -34,33 +34,10 @@ impl FileWatcher { // notify emits per change (and any unrelated churn in the directory): a // full buffer already has a pending wakeup, so extra sends are dropped. let (tx, rx) = mpsc::channel(1); - let mut watcher = notify::recommended_watcher(move |res: notify::Result| { - let send = match res { - Ok(event) => is_reload_trigger(&event.kind), - // A watcher error (e.g. inotify queue overflow) may mean we missed a - // real change, so reload to be safe. - Err(_) => true, - }; - if send { - let _ = tx.try_send(()); - } + let watcher = callback(paths, move || { + let _ = tx.try_send(()); })?; - // Watch each distinct parent directory once. A bare filename like - // `cert.pem` has an empty-string parent (`Some("")`, not `None`), which the - // OS watcher rejects with "No path was found", so map that to the current - // directory. - let mut dirs: Vec<&Path> = paths - .iter() - .filter_map(|p| p.parent()) - .map(|p| if p.as_os_str().is_empty() { Path::new(".") } else { p }) - .collect(); - dirs.sort_unstable(); - dirs.dedup(); - for dir in dirs { - watcher.watch(dir, notify::RecursiveMode::NonRecursive)?; - } - Ok(Self { _watcher: watcher, events: rx, @@ -80,6 +57,43 @@ impl FileWatcher { } } +/// Watch `paths` and invoke `changed` after a write or atomic replacement. +/// +/// The callback runs on notify's worker thread, so it must finish promptly. +pub(crate) fn callback( + paths: &[PathBuf], + mut changed: impl FnMut() + Send + 'static, +) -> notify::Result { + let mut watcher = notify::recommended_watcher(move |res: notify::Result| { + let reload = match res { + Ok(event) => is_reload_trigger(&event.kind), + // A watcher error (e.g. inotify queue overflow) may mean we missed a + // real change, so reload to be safe. + Err(_) => true, + }; + if reload { + changed(); + } + })?; + + // Watch each distinct parent directory once. A bare filename like + // `cert.pem` has an empty-string parent (`Some("")`, not `None`), which the + // OS watcher rejects with "No path was found", so map that to the current + // directory. + let mut dirs: Vec<&Path> = paths + .iter() + .filter_map(|p| p.parent()) + .map(|p| if p.as_os_str().is_empty() { Path::new(".") } else { p }) + .collect(); + dirs.sort_unstable(); + dirs.dedup(); + for dir in dirs { + watcher.watch(dir, notify::RecursiveMode::NonRecursive)?; + } + + Ok(watcher) +} + /// Whether a raw notify event reflects a real change that should trigger a reload. /// /// The reload path opens and reads the watched files, and notify's inotify backend diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index e48c80517a..ead606e6b8 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -318,18 +318,22 @@ fn build_https_config( .context("failed to build https TLS config") } -/// Reload the HTTPS cert/key/root whenever they change on disk. +fn https_watch_paths(cert: &[PathBuf], key: &[PathBuf], root: &[PathBuf]) -> Vec { + cert.iter() + .cloned() + .chain(key.iter().cloned()) + .chain(root.iter().cloned()) + .collect() +} + +/// Reload the HTTPS certificate and key whenever they change on disk. /// /// `RustlsConfig::reload_from_pem_file` would rebuild with `with_no_client_auth` /// (silently stripping mTLS when configured), so we always rebuild via the full -/// [`build_https_config`] path. +/// [`build_https_config`] path. The client verifier watches root files itself, +/// while this watcher also uses them to retry a failed certificate/key rotation. async fn reload_https_config(config: RustlsConfig, cert: Vec, key: Vec, root: Vec) { - let paths: Vec = cert - .iter() - .cloned() - .chain(key.iter().cloned()) - .chain(root.iter().cloned()) - .collect(); + let paths = https_watch_paths(&cert, &key, &root); let mut watcher = match moq_native::watch::FileWatcher::new(&paths) { Ok(watcher) => watcher, @@ -752,6 +756,21 @@ mod tests { ); } + #[test] + fn https_watch_paths_include_roots() { + let cert = PathBuf::from("cert.pem"); + let key = PathBuf::from("key.pem"); + let root = PathBuf::from("root.pem"); + assert_eq!( + https_watch_paths( + std::slice::from_ref(&cert), + std::slice::from_ref(&key), + std::slice::from_ref(&root) + ), + vec![cert, key, root] + ); + } + #[tokio::test] async fn build_https_config_no_client_auth_when_ca_empty() { let dir = TempDir::new().unwrap(); From adad52b257d1721edbd7f14532c26a08d463806c Mon Sep 17 00:00:00 2001 From: "moq-bot[bot]" <186640430+moq-bot[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:54:53 -0700 Subject: [PATCH 09/12] chore: release (#2817) Co-authored-by: moq-bot[bot] <186640430+moq-bot[bot]@users.noreply.github.com> --- Cargo.lock | 135 +++++++++++++++++----------------- Cargo.toml | 8 +- rs/hang/CHANGELOG.md | 6 ++ rs/hang/Cargo.toml | 2 +- rs/libmoq/CHANGELOG.md | 6 ++ rs/libmoq/Cargo.toml | 2 +- rs/moq-boy/CHANGELOG.md | 6 ++ rs/moq-boy/Cargo.toml | 2 +- rs/moq-cli/CHANGELOG.md | 11 +++ rs/moq-cli/Cargo.toml | 2 +- rs/moq-ffi/CHANGELOG.md | 11 +++ rs/moq-ffi/Cargo.toml | 2 +- rs/moq-hls/CHANGELOG.md | 7 ++ rs/moq-hls/Cargo.toml | 2 +- rs/moq-mux/CHANGELOG.md | 13 ++++ rs/moq-mux/Cargo.toml | 2 +- rs/moq-native/CHANGELOG.md | 10 +++ rs/moq-native/Cargo.toml | 2 +- rs/moq-net/CHANGELOG.md | 10 +++ rs/moq-net/Cargo.toml | 2 +- rs/moq-relay/CHANGELOG.md | 11 +++ rs/moq-relay/Cargo.toml | 2 +- rs/moq-rtc/CHANGELOG.md | 7 ++ rs/moq-rtc/Cargo.toml | 2 +- rs/moq-token-cli/CHANGELOG.md | 6 ++ rs/moq-token-cli/Cargo.toml | 2 +- rs/moq-transcode/CHANGELOG.md | 7 ++ rs/moq-transcode/Cargo.toml | 2 +- rs/moq-video/CHANGELOG.md | 6 ++ rs/moq-video/Cargo.toml | 2 +- 30 files changed, 203 insertions(+), 85 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aecc211ef5..1789bd9119 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1071,9 +1071,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.2" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -2481,9 +2481,9 @@ checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] name = "find-msvc-tools" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "fixed-resample" @@ -2581,9 +2581,9 @@ dependencies = [ [[package]] name = "foundations" -version = "5.8.1" +version = "5.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e26ce0c3071293f6cb986cdd1e9f09d26d72313b7fc85010cbded344182c26df" +checksum = "84c5a08bded019d15c789a5e64aff8f0013416130fbfabd757d2232199cb0527" dependencies = [ "anyhow", "cf-rustracing", @@ -2621,9 +2621,9 @@ dependencies = [ [[package]] name = "foundations-macros" -version = "5.8.1" +version = "5.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "260f833086964a5ce5a0a28fa0f7f1101249eef3b3d45619d3437a74b65ad096" +checksum = "d46317fec5543083ec837c5d1d40f2ec813922f76962e745ce0db173579fe9c3" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -3180,7 +3180,7 @@ dependencies = [ [[package]] name = "hang" -version = "0.20.4" +version = "0.20.5" dependencies = [ "anyhow", "bytes", @@ -3606,9 +3606,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -3620,9 +3620,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -3633,9 +3633,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3647,16 +3647,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -3667,15 +3668,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -3764,9 +3765,9 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ "bitflags 2.13.1", "inotify-sys", @@ -4345,7 +4346,7 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmoq" -version = "0.5.7" +version = "0.5.8" dependencies = [ "anyhow", "bytes", @@ -4368,9 +4369,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" dependencies = [ "bitflags 2.13.1", "libc", @@ -4425,9 +4426,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -4559,9 +4560,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "minicov" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +checksum = "c3aa3aa12b448ac225b3102217d1ac5cc717908f02722926524b0599c933c7a0" dependencies = [ "cc", "walkdir", @@ -4663,7 +4664,7 @@ dependencies = [ [[package]] name = "moq-boy" -version = "0.4.7" +version = "0.4.8" dependencies = [ "anyhow", "boytacean", @@ -4683,7 +4684,7 @@ dependencies = [ [[package]] name = "moq-cli" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "axum", @@ -4714,7 +4715,7 @@ dependencies = [ [[package]] name = "moq-ffi" -version = "0.3.10" +version = "0.3.11" dependencies = [ "bytes", "hang", @@ -4762,7 +4763,7 @@ dependencies = [ [[package]] name = "moq-hls" -version = "0.4.7" +version = "0.4.8" dependencies = [ "anyhow", "axum", @@ -4820,7 +4821,7 @@ dependencies = [ [[package]] name = "moq-mux" -version = "0.9.6" +version = "0.9.7" dependencies = [ "anyhow", "base64 0.23.1", @@ -4851,7 +4852,7 @@ dependencies = [ [[package]] name = "moq-native" -version = "0.19.10" +version = "0.19.11" dependencies = [ "anyhow", "bytes", @@ -4904,7 +4905,7 @@ dependencies = [ [[package]] name = "moq-net" -version = "0.2.11" +version = "0.2.12" dependencies = [ "bytes", "criterion", @@ -4935,7 +4936,7 @@ dependencies = [ [[package]] name = "moq-relay" -version = "0.14.10" +version = "0.14.11" dependencies = [ "anyhow", "axum", @@ -4979,7 +4980,7 @@ dependencies = [ [[package]] name = "moq-rtc" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "aws-lc-rs", @@ -5072,7 +5073,7 @@ dependencies = [ [[package]] name = "moq-token-cli" -version = "0.5.43" +version = "0.5.44" dependencies = [ "anyhow", "clap", @@ -5082,7 +5083,7 @@ dependencies = [ [[package]] name = "moq-transcode" -version = "0.0.10" +version = "0.0.11" dependencies = [ "anyhow", "bytes", @@ -5115,7 +5116,7 @@ dependencies = [ [[package]] name = "moq-video" -version = "0.0.16" +version = "0.0.17" dependencies = [ "anyhow", "ashpd", @@ -5441,9 +5442,9 @@ dependencies = [ [[package]] name = "netlink-proto" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6" +checksum = "93af8261786086024cd5e96e0a991dd65ced07bbf7c233a487bbc96b971d5539" dependencies = [ "bytes", "futures-channel", @@ -6765,9 +6766,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plain" @@ -6921,9 +6922,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -7853,9 +7854,9 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "safe_arch" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a52ec151f024d703f9fd65abb7cbe81e7cdb39f18917a3a37e3014470dc7c59" +checksum = "42c6efa15875e6ecb39ca61fb0b0c1a40b84fac5a5ffe71eef7d1000c8eb3f5f" dependencies = [ "bytemuck", ] @@ -9186,9 +9187,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -9983,9 +9984,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -10198,9 +10199,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.16" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", @@ -11223,9 +11224,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "ws_stream_wasm" @@ -11539,9 +11540,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -11550,9 +11551,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -11561,13 +11562,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 44bf8fd546..593f397e9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,7 +81,7 @@ kio = { version = "0.5", path = "rs/kio" } loom = { version = "0.7.2", features = ["futures"] } moq-audio = { version = "0.0.17", path = "rs/moq-audio" } moq-flate = { version = "0.1.1", path = "rs/moq-flate" } -moq-hls = { version = "0.4.7", path = "rs/moq-hls", default-features = false } +moq-hls = { version = "0.4.8", path = "rs/moq-hls", default-features = false } moq-json = { version = "0.3.3", path = "rs/moq-json" } moq-loc = { version = "0.2", path = "rs/moq-loc" } moq-msf = { version = "0.4", path = "rs/moq-msf" } @@ -92,7 +92,7 @@ moq-net = { version = "0.2", path = "rs/moq-net" } # driver at runtime. Compiles on any platform (macOS included) but only actually # used by moq-video on Linux. moq-nvenc = { version = "0.0.3", path = "rs/moq-nvenc" } -moq-rtc = { version = "0.2.3", path = "rs/moq-rtc" } +moq-rtc = { version = "0.2.4", path = "rs/moq-rtc" } moq-rtmp = { version = "0.2.4", path = "rs/moq-rtmp" } moq-srt = { version = "0.2.3", path = "rs/moq-srt" } moq-stats = { version = "0.1.4", path = "rs/moq-stats" } @@ -103,7 +103,7 @@ moq-token-cli = { version = "0.5", path = "rs/moq-token-cli" } # can leave them off to drop the CUDA dependencies. Both crates still default # them on when depended on directly. VAAPI is opt-in everywhere, since that # backend has never been validated on real hardware. -moq-transcode = { version = "0.0.10", path = "rs/moq-transcode", default-features = false } +moq-transcode = { version = "0.0.11", path = "rs/moq-transcode", default-features = false } # Standalone crate (moq-dev/vaapi); vendored from cros-libva + cros-codecs. # dlopen's libva at runtime (no libva-dev at build, no NEEDED libva in the binary). moq-vaapi = "0.0.3" @@ -112,7 +112,7 @@ moq-vaapi = "0.0.3" # builds, which want the codecs and not the device stack. Adding # `features = ["capture"]` to a consumer that ships in those bindings pulls the # whole device graph into every one of them. -moq-video = { version = "0.0.16", path = "rs/moq-video", default-features = false } +moq-video = { version = "0.0.17", path = "rs/moq-video", default-features = false } percent-encoding = "2" qmux = { version = "0.4.0", default-features = false } serde = { version = "1", features = ["derive"] } diff --git a/rs/hang/CHANGELOG.md b/rs/hang/CHANGELOG.md index 8eb265dff7..f93c351455 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.20.5](https://github.com/moq-dev/moq/compare/hang-v0.20.4...hang-v0.20.5) - 2026-08-14 + +### Fixed + +- *(path)* resolve catalog references like URLs ([#2855](https://github.com/moq-dev/moq/pull/2855)) + ## [0.20.4](https://github.com/moq-dev/moq/compare/hang-v0.20.3...hang-v0.20.4) - 2026-08-06 ### Added diff --git a/rs/hang/Cargo.toml b/rs/hang/Cargo.toml index 3eb1d1d8f9..ea1b6a1bb8 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.20.4" +version = "0.20.5" edition = "2024" rust-version.workspace = true diff --git a/rs/libmoq/CHANGELOG.md b/rs/libmoq/CHANGELOG.md index 56a3d58c4c..9f9d6f6abc 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.5.8](https://github.com/moq-dev/moq/compare/libmoq-v0.5.7...libmoq-v0.5.8) - 2026-08-14 + +### Added + +- *(libmoq)* declare the catalog container for manually authored renditions ([#2805](https://github.com/moq-dev/moq/pull/2805)) + ## [0.5.7](https://github.com/moq-dev/moq/compare/libmoq-v0.5.6...libmoq-v0.5.7) - 2026-08-13 ### Added diff --git a/rs/libmoq/Cargo.toml b/rs/libmoq/Cargo.toml index f20482ea97..312baeb0cd 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.5.7" +version = "0.5.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-boy/CHANGELOG.md b/rs/moq-boy/CHANGELOG.md index a715aa19a8..c77bfaeb7e 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.4.8](https://github.com/moq-dev/moq/compare/moq-boy-v0.4.7...moq-boy-v0.4.8) - 2026-08-14 + +### Other + +- updated the following local packages: moq-video + ## [0.4.7](https://github.com/moq-dev/moq/compare/moq-boy-v0.4.6...moq-boy-v0.4.7) - 2026-08-13 ### Added diff --git a/rs/moq-boy/Cargo.toml b/rs/moq-boy/Cargo.toml index 7d8814a8dc..2644585fcd 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.4.7" +version = "0.4.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-cli/CHANGELOG.md b/rs/moq-cli/CHANGELOG.md index 0cd09d3cb9..e1280860b7 100644 --- a/rs/moq-cli/CHANGELOG.md +++ b/rs/moq-cli/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.11](https://github.com/moq-dev/moq/compare/moq-cli-v0.9.10...moq-cli-v0.9.11) - 2026-08-14 + +### Added + +- *(cli)* run multiple import/export stages over one connection ([#2809](https://github.com/moq-dev/moq/pull/2809)) + +### Fixed + +- *(path)* resolve catalog references like URLs ([#2855](https://github.com/moq-dev/moq/pull/2855)) +- *(net)* stop blocking connect on the initial announce set ([#2856](https://github.com/moq-dev/moq/pull/2856)) + ## [0.9.10](https://github.com/moq-dev/moq/compare/moq-cli-v0.9.9...moq-cli-v0.9.10) - 2026-08-13 ### Fixed diff --git a/rs/moq-cli/Cargo.toml b/rs/moq-cli/Cargo.toml index f2df69331c..cf3907e565 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.9.10" +version = "0.9.11" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-ffi/CHANGELOG.md b/rs/moq-ffi/CHANGELOG.md index eaa649fc7e..d3f1dea9a2 100644 --- a/rs/moq-ffi/CHANGELOG.md +++ b/rs/moq-ffi/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.11](https://github.com/moq-dev/moq/compare/moq-ffi-v0.3.10...moq-ffi-v0.3.11) - 2026-08-14 + +### Added + +- *(bindings)* fetch and decode a retained media group ([#2827](https://github.com/moq-dev/moq/pull/2827)) + +### Fixed + +- *(mux)* derive missing video geometry ([#2840](https://github.com/moq-dev/moq/pull/2840)) +- *(net)* stop blocking connect on the initial announce set ([#2856](https://github.com/moq-dev/moq/pull/2856)) + ## [0.3.10](https://github.com/moq-dev/moq/compare/moq-ffi-v0.3.9...moq-ffi-v0.3.10) - 2026-08-13 ### Added diff --git a/rs/moq-ffi/Cargo.toml b/rs/moq-ffi/Cargo.toml index e740dbd6bb..c21e0e167f 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.3.10" +version = "0.3.11" edition = "2024" keywords = ["quic", "http3", "webtransport", "media", "live"] diff --git a/rs/moq-hls/CHANGELOG.md b/rs/moq-hls/CHANGELOG.md index d61f4db544..207e712f04 100644 --- a/rs/moq-hls/CHANGELOG.md +++ b/rs/moq-hls/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.8](https://github.com/moq-dev/moq/compare/moq-hls-v0.4.7...moq-hls-v0.4.8) - 2026-08-14 + +### Fixed + +- *(mux)* derive missing video geometry ([#2840](https://github.com/moq-dev/moq/pull/2840)) +- *(path)* resolve catalog references like URLs ([#2855](https://github.com/moq-dev/moq/pull/2855)) + ## [0.4.7](https://github.com/moq-dev/moq/compare/moq-hls-v0.4.6...moq-hls-v0.4.7) - 2026-08-13 ### Fixed diff --git a/rs/moq-hls/Cargo.toml b/rs/moq-hls/Cargo.toml index 8dd7f8eb06..41acdfba1b 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.4.7" +version = "0.4.8" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-mux/CHANGELOG.md b/rs/moq-mux/CHANGELOG.md index 22337c0870..0b1ae8e697 100644 --- a/rs/moq-mux/CHANGELOG.md +++ b/rs/moq-mux/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.7](https://github.com/moq-dev/moq/compare/moq-mux-v0.9.6...moq-mux-v0.9.7) - 2026-08-14 + +### Added + +- *(bindings)* fetch and decode a retained media group ([#2827](https://github.com/moq-dev/moq/pull/2827)) + +### Fixed + +- *(mux)* derive missing video geometry ([#2840](https://github.com/moq-dev/moq/pull/2840)) +- *(path)* resolve catalog references like URLs ([#2855](https://github.com/moq-dev/moq/pull/2855)) +- *(moq-mux)* stop publishing audio spliced across a TS discontinuity ([#2823](https://github.com/moq-dev/moq/pull/2823)) +- *(moq-mux)* anchor the TS table cadence to the media timeline ([#2825](https://github.com/moq-dev/moq/pull/2825)) + ## [0.9.6](https://github.com/moq-dev/moq/compare/moq-mux-v0.9.5...moq-mux-v0.9.6) - 2026-08-13 ### Added diff --git a/rs/moq-mux/Cargo.toml b/rs/moq-mux/Cargo.toml index 9dc8386138..1cfa104cd3 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.9.6" +version = "0.9.7" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-native/CHANGELOG.md b/rs/moq-native/CHANGELOG.md index 0b1a32313f..9d052fbbe3 100644 --- a/rs/moq-native/CHANGELOG.md +++ b/rs/moq-native/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.19.11](https://github.com/moq-dev/moq/compare/moq-native-v0.19.10...moq-native-v0.19.11) - 2026-08-14 + +### Added + +- *(cli)* run multiple import/export stages over one connection ([#2809](https://github.com/moq-dev/moq/pull/2809)) + +### Other + +- reload custom root CAs without restart ([#2863](https://github.com/moq-dev/moq/pull/2863)) + ## [0.19.10](https://github.com/moq-dev/moq/compare/moq-native-v0.19.9...moq-native-v0.19.10) - 2026-08-13 ### Added diff --git a/rs/moq-native/Cargo.toml b/rs/moq-native/Cargo.toml index 63eeea12d9..34e39df7a5 100644 --- a/rs/moq-native/Cargo.toml +++ b/rs/moq-native/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.10" +version = "0.19.11" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-net/CHANGELOG.md b/rs/moq-net/CHANGELOG.md index fbb60450c6..3809a1be58 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.2.12](https://github.com/moq-dev/moq/compare/moq-net-v0.2.11...moq-net-v0.2.12) - 2026-08-14 + +### Fixed + +- *(path)* resolve catalog references like URLs ([#2855](https://github.com/moq-dev/moq/pull/2855)) +- *(moq-net)* serve an IETF subscribe from the live edge ([#2862](https://github.com/moq-dev/moq/pull/2862)) +- *(net)* stop blocking connect on the initial announce set ([#2856](https://github.com/moq-dev/moq/pull/2856)) +- *(net)* wake a capped subscriber when its parked group is evicted ([#2844](https://github.com/moq-dev/moq/pull/2844)) +- *(net)* decode largest object in subscribe ok ([#2837](https://github.com/moq-dev/moq/pull/2837)) + ## [0.2.11](https://github.com/moq-dev/moq/compare/moq-net-v0.2.10...moq-net-v0.2.11) - 2026-08-13 ### Added diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index 931bb074ec..939cdab2a6 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.2.11" +version = "0.2.12" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-relay/CHANGELOG.md b/rs/moq-relay/CHANGELOG.md index e26091348c..3aa29fdc8e 100644 --- a/rs/moq-relay/CHANGELOG.md +++ b/rs/moq-relay/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.11](https://github.com/moq-dev/moq/compare/moq-relay-v0.14.10...moq-relay-v0.14.11) - 2026-08-14 + +### Fixed + +- *(relay)* honor server version over WebSocket ([#2841](https://github.com/moq-dev/moq/pull/2841)) +- *(net)* stop blocking connect on the initial announce set ([#2856](https://github.com/moq-dev/moq/pull/2856)) + +### Other + +- reload custom root CAs without restart ([#2863](https://github.com/moq-dev/moq/pull/2863)) + ## [0.14.10](https://github.com/moq-dev/moq/compare/moq-relay-v0.14.9...moq-relay-v0.14.10) - 2026-08-13 ### Added diff --git a/rs/moq-relay/Cargo.toml b/rs/moq-relay/Cargo.toml index 449dd1fda8..16282ff348 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.14.10" +version = "0.14.11" edition = "2024" # sysinfo 0.39 (cache governor cgroup limits) needs 1.95, above the 1.91 # workspace floor. moq-relay is lib+bin, so this applies to its library target diff --git a/rs/moq-rtc/CHANGELOG.md b/rs/moq-rtc/CHANGELOG.md index d776373735..fb99660a96 100644 --- a/rs/moq-rtc/CHANGELOG.md +++ b/rs/moq-rtc/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.4](https://github.com/moq-dev/moq/compare/moq-rtc-v0.2.3...moq-rtc-v0.2.4) - 2026-08-14 + +### Fixed + +- *(path)* resolve catalog references like URLs ([#2855](https://github.com/moq-dev/moq/pull/2855)) +- *(rtc)* publish VP8 and VP9 dimensions ([#2845](https://github.com/moq-dev/moq/pull/2845)) + ## [0.2.3](https://github.com/moq-dev/moq/compare/moq-rtc-v0.2.2...moq-rtc-v0.2.3) - 2026-08-06 ### Added diff --git a/rs/moq-rtc/Cargo.toml b/rs/moq-rtc/Cargo.toml index a9f3c35977..f370aab7e1 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.2.3" +version = "0.2.4" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-token-cli/CHANGELOG.md b/rs/moq-token-cli/CHANGELOG.md index a26c128efb..62a39a8e9c 100644 --- a/rs/moq-token-cli/CHANGELOG.md +++ b/rs/moq-token-cli/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.44](https://github.com/moq-dev/moq/compare/moq-token-cli-v0.5.43...moq-token-cli-v0.5.44) - 2026-08-14 + +### Other + +- update Cargo.lock dependencies + ## [0.5.43](https://github.com/moq-dev/moq/compare/moq-token-cli-v0.5.42...moq-token-cli-v0.5.43) - 2026-08-07 ### Other diff --git a/rs/moq-token-cli/Cargo.toml b/rs/moq-token-cli/Cargo.toml index 87c8077c25..fea64204e7 100644 --- a/rs/moq-token-cli/Cargo.toml +++ b/rs/moq-token-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.5.43" +version = "0.5.44" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-transcode/CHANGELOG.md b/rs/moq-transcode/CHANGELOG.md index 73a4290436..2f64e51dcc 100644 --- a/rs/moq-transcode/CHANGELOG.md +++ b/rs/moq-transcode/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.11](https://github.com/moq-dev/moq/compare/moq-transcode-v0.0.10...moq-transcode-v0.0.11) - 2026-08-14 + +### Fixed + +- *(path)* resolve catalog references like URLs ([#2855](https://github.com/moq-dev/moq/pull/2855)) +- *(net)* stop blocking connect on the initial announce set ([#2856](https://github.com/moq-dev/moq/pull/2856)) + ## [0.0.10](https://github.com/moq-dev/moq/compare/moq-transcode-v0.0.9...moq-transcode-v0.0.10) - 2026-08-13 ### Added diff --git a/rs/moq-transcode/Cargo.toml b/rs/moq-transcode/Cargo.toml index 7665682357..2bfebe3036 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.0.10" +version = "0.0.11" edition = "2024" rust-version.workspace = true diff --git a/rs/moq-video/CHANGELOG.md b/rs/moq-video/CHANGELOG.md index 6ad935daec..ba1d2372ce 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.0.17](https://github.com/moq-dev/moq/compare/moq-video-v0.0.16...moq-video-v0.0.17) - 2026-08-14 + +### Added + +- *(libmoq)* declare the catalog container for manually authored renditions ([#2805](https://github.com/moq-dev/moq/pull/2805)) + ## [0.0.16](https://github.com/moq-dev/moq/compare/moq-video-v0.0.15...moq-video-v0.0.16) - 2026-08-13 ### Added diff --git a/rs/moq-video/Cargo.toml b/rs/moq-video/Cargo.toml index 7f163cdc13..0b67574028 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.0.16" +version = "0.0.17" edition = "2024" rust-version.workspace = true From e31bb62952e3ee493aa354d2327617cb3e940c4a Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 14 Aug 2026 17:29:47 -0700 Subject: [PATCH 10/12] feat(hang): signal stalled video renditions (#2865) --- doc/concept/layer/hang.md | 3 + doc/concept/standard/msf.md | 3 + doc/lib/c/index.md | 11 ++ doc/lib/go/moq.md | 2 + doc/lib/kt/moq.md | 2 + doc/lib/py/moq-rs.md | 2 + doc/lib/swift/moq.md | 2 + drafts/draft-lcurley-moq-hang.md | 7 + go/wrapper/moq/types.go | 2 +- js/hang/src/catalog/video.test.ts | 11 ++ js/hang/src/catalog/video.ts | 4 + js/msf/src/catalog.test.ts | 21 +++ js/msf/src/catalog.ts | 2 + js/watch/src/msf.test.ts | 19 +++ js/watch/src/msf.ts | 1 + js/watch/src/video/config.test.ts | 59 ++++++++ js/watch/src/video/config.ts | 38 ++++++ js/watch/src/video/decoder.ts | 61 ++++++--- js/watch/src/video/source.test.ts | 127 +++++++++++++++++- js/watch/src/video/source.ts | 68 ++++++++-- .../kotlin/dev/moq/Aliases.kt | 2 +- py/moq-rs/README.md | 2 +- rs/hang/src/catalog/root.rs | 1 + rs/hang/src/catalog/video/mod.rs | 22 +++ rs/libmoq/README.md | 1 + rs/libmoq/src/api.rs | 23 ++++ rs/libmoq/src/consume.rs | 13 ++ rs/libmoq/src/test.rs | 54 ++++++++ rs/moq-ffi/src/media.rs | 23 ++++ rs/moq-msf/src/lib.rs | 10 ++ rs/moq-mux/src/catalog/msf/consumer.rs | 3 + rs/moq-mux/src/catalog/producer.rs | 3 + swift/Sources/Moq/Aliases.swift | 4 +- 33 files changed, 567 insertions(+), 39 deletions(-) create mode 100644 js/watch/src/msf.test.ts create mode 100644 js/watch/src/video/config.test.ts create mode 100644 js/watch/src/video/config.ts diff --git a/doc/concept/layer/hang.md b/doc/concept/layer/hang.md index 446d1ce5ef..b2193303cc 100644 --- a/doc/concept/layer/hang.md +++ b/doc/concept/layer/hang.md @@ -75,6 +75,9 @@ For example, it's not possible to have a different `flip` or `rotation` value fo Each rendition is an extension of [VideoDecoderConfig](https://www.w3.org/TR/webcodecs/#video-decoder-config). This is the minimum amount of information required to initialize a video decoder. +A publisher can set a rendition's optional `stalled` field to recommend temporarily avoiding it without removing or closing the track. +Players prefer decoder-supported unstalled renditions and can fall back to a stalled rendition when none remain. + ### Cross-broadcast renditions A rendition may set an optional `broadcast` field: a path relative to the broadcast that served the catalog (e.g. `"./source"`), pointing at another broadcast that publishes the actual track. diff --git a/doc/concept/standard/msf.md b/doc/concept/standard/msf.md index d1c84724de..895ad05fc1 100644 --- a/doc/concept/standard/msf.md +++ b/doc/concept/standard/msf.md @@ -17,4 +17,7 @@ moved init data out of the track into a root `initDataList` referenced by `initR Our implementation hides this on the wire: the catalog API is a version-agnostic snapshot, draft-00 catalogs still decode, and init data is always presented inline regardless of how it was carried. +Our implementation also carries an optional `stalled` boolean on video tracks as a non-standard extension shared with the hang catalog. +It recommends temporarily avoiding a track without making that track unavailable. + [See the draft](https://www.ietf.org/archive/id/draft-ietf-moq-msf-01.html) for the latest details. diff --git a/doc/lib/c/index.md b/doc/lib/c/index.md index 0d1c40aa2b..96c6e33943 100644 --- a/doc/lib/c/index.md +++ b/doc/lib/c/index.md @@ -225,6 +225,17 @@ if (moq_consume_video_properties(catalog, &snapshot) < 0) { } ``` +## Stalled video renditions + +`moq_consume_video_stalled` reports whether the publisher recommends temporarily avoiding a rendition. The track remains directly usable, and catalogs that omit the hint report false. Pass the same catalog snapshot and rendition index used by `moq_consume_video_config`: + +```c +bool stalled; +if (moq_consume_video_stalled(catalog, index, &stalled) < 0) { + fprintf(stderr, "video stalled state failed: %s\n", moq_error()); +} +``` + ## Raw media The `moq_publish_media_*` and `moq_consume_video` / `moq_consume_audio` calls carry already-encoded frames, for a caller that brings its own codec. The `_raw` calls carry uncompressed media instead and run the codec inside libmoq, so a C application can publish pixels and PCM without linking one. diff --git a/doc/lib/go/moq.md b/doc/lib/go/moq.md index 944a1a3db6..79e1a5eef1 100644 --- a/doc/lib/go/moq.md +++ b/doc/lib/go/moq.md @@ -163,6 +163,8 @@ media, err := broadcast.PublishMedia("avc3", nil, moq.WithVideoHint(moq.VideoHin })) ``` +Each catalog `Video` has a `Stalled` boolean. A true value recommends temporarily avoiding that rendition, but the track remains directly usable. Existing catalogs default it to false. + Properties that apply to every video rendition are updated together. Nil fields clear the corresponding catalog property, and rotation is normalized to the nearest clockwise quarter turn: ```go diff --git a/doc/lib/kt/moq.md b/doc/lib/kt/moq.md index 72aab425ca..12c01a9b7f 100644 --- a/doc/lib/kt/moq.md +++ b/doc/lib/kt/moq.md @@ -120,6 +120,8 @@ Moq.connect("https://relay.example.com").use { moq -> } ``` +Each catalog `Video` has a `stalled` boolean. A true value recommends temporarily avoiding that rendition, but the track remains directly usable. Existing catalogs default it to false. + Properties that apply to every video rendition are updated together. `null` fields clear the corresponding catalog property, and rotation is normalized to the nearest clockwise quarter turn: ```kotlin diff --git a/doc/lib/py/moq-rs.md b/doc/lib/py/moq-rs.md index 0cba8422f7..9c14f937d0 100644 --- a/doc/lib/py/moq-rs.md +++ b/doc/lib/py/moq-rs.md @@ -125,6 +125,8 @@ video = broadcast.publish_media( A value the stream later detects fills only a gap the hint left, so a detected value always wins. Audio formats resolve entirely from their init bytes, so they take no hint. +Each catalog `Video` has a `stalled` boolean. A true value recommends temporarily avoiding that rendition, but the track remains directly usable. Existing catalogs default it to false. + Properties that apply to every video rendition are updated together. Omitted fields clear the corresponding catalog property, and rotation is normalized to the nearest clockwise quarter turn: ```python diff --git a/doc/lib/swift/moq.md b/doc/lib/swift/moq.md index 76b2450ee8..0e4701a275 100644 --- a/doc/lib/swift/moq.md +++ b/doc/lib/swift/moq.md @@ -127,6 +127,8 @@ try broadcast.finish() Video publishers can pass `video: VideoHint(...)` to seed catalog fields before the stream reveals them. Use `publishMedia(on:format:initData:video:)` to accept a media track obtained from `BroadcastDynamic`. +Each catalog `Video` has a `stalled` boolean. A true value recommends temporarily avoiding that rendition, but the track remains directly usable. Existing catalogs default it to false. + Properties that apply to every video rendition are updated together. `nil` fields clear the corresponding catalog property, and rotation is normalized to the nearest clockwise quarter turn: ```swift diff --git a/drafts/draft-lcurley-moq-hang.md b/drafts/draft-lcurley-moq-hang.md index b52603a8d9..71765fd55c 100644 --- a/drafts/draft-lcurley-moq-hang.md +++ b/drafts/draft-lcurley-moq-hang.md @@ -152,6 +152,7 @@ In addition to the WebCodecs fields, each rendition MAY carry the fields common type VideoDecoderConfigExtensions = { "displayAspectWidth": number | undefined, "displayAspectHeight": number | undefined, + "stalled": boolean | undefined, } ~~~ @@ -159,6 +160,11 @@ type VideoDecoderConfigExtensions = { A consumer that understands neither field MUST assume square pixels, a 1:1 ratio. Both MUST be present together; a consumer that sees only one MUST ignore it. +`stalled` indicates that the publisher recommends temporarily avoiding the rendition. +The track remains available when `stalled` is true. +A consumer SHOULD select an unstalled rendition when it supports one, but MAY select a stalled rendition when no unstalled rendition is suitable. +If absent, `stalled` defaults to false. + For example: ~~~ @@ -170,6 +176,7 @@ For example: "codedWidth": 1280, "codedHeight": 720, "bitrate": 6000000, + "stalled": true, "framerate": 30.0, "jitter": 33 }, diff --git a/go/wrapper/moq/types.go b/go/wrapper/moq/types.go index 81a5b1a9b3..6cfc22641a 100644 --- a/go/wrapper/moq/types.go +++ b/go/wrapper/moq/types.go @@ -42,7 +42,7 @@ type ( Subscription = ffi.MoqSubscription // TrackInfo holds publisher-side track properties: priority, ordering, latency budget, and timescale. TrackInfo = ffi.MoqTrackInfo - // Video describes one video rendition in a broadcast catalog: codec, dimensions, bitrate, framerate, and container. + // Video describes one catalog rendition, including whether the publisher recommends temporarily avoiding it. Video = ffi.MoqVideo // VideoHint supplies catalog fields a video stream can't reveal itself, such as bitrate, filling only the gaps. VideoHint = ffi.MoqVideoHint diff --git a/js/hang/src/catalog/video.test.ts b/js/hang/src/catalog/video.test.ts index e8877614d0..0c8cee3aa6 100644 --- a/js/hang/src/catalog/video.test.ts +++ b/js/hang/src/catalog/video.test.ts @@ -15,6 +15,17 @@ test("video config accepts canonical display aspect fields", () => { expect("displayRatioHeight" in parsed).toBe(false); }); +test("video config accepts optional stalled state", () => { + const active = VideoConfigSchema.parse({ + codec: "avc1.64001f", + container: { kind: "legacy" }, + }); + const stalled = VideoConfigSchema.parse({ ...active, stalled: true }); + + expect(active.stalled).toBeUndefined(); + expect(stalled.stalled).toBe(true); +}); + test("legacy video arrays derive display size from display aspect fields", () => { const parsed = VideoSchema.parse([ { diff --git a/js/hang/src/catalog/video.ts b/js/hang/src/catalog/video.ts index 7bc38543ce..248365e395 100644 --- a/js/hang/src/catalog/video.ts +++ b/js/hang/src/catalog/video.ts @@ -46,6 +46,10 @@ export const VideoConfigSchema = z.object({ // TODO: Support up to Number.MAX_SAFE_INTEGER bitrate: z.optional(u53Schema), + // Whether the publisher recommends temporarily avoiding this rendition. + // The track remains available and may still be selected as a fallback. + stalled: z.optional(z.boolean()), + // If true, the decoder will optimize for latency. // Default: true optimizeForLatency: z.optional(z.boolean()), diff --git a/js/msf/src/catalog.test.ts b/js/msf/src/catalog.test.ts index 10a8269da5..24532bcd8d 100644 --- a/js/msf/src/catalog.test.ts +++ b/js/msf/src/catalog.test.ts @@ -184,3 +184,24 @@ test("preserves SAP fields through decode and encode", () => { expect(wireTracks[0].maxObjSapStartingType).toBe(2); expect(wireTracks[0].jitter).toBe(15); }); + +test.each([ + ["omitted", undefined], + ["false", false], + ["true", true], +] as const)("preserves %s stalled state through decode and encode", (_name, stalled) => { + const track = { + name: "video0", + packaging: "loc", + isLive: true, + role: "video", + codec: "av01.0.08M.10.0.110.09", + ...(stalled === undefined ? {} : { stalled }), + }; + const catalog = decode(encodeJson({ version: "draft-01", tracks: [track] })); + + expect(catalog.tracks[0].stalled).toBe(stalled); + const wire = decodeJson(encode(catalog)); + const tracks = wire.tracks as { stalled?: boolean }[]; + expect(tracks[0].stalled).toBe(stalled); +}); diff --git a/js/msf/src/catalog.ts b/js/msf/src/catalog.ts index 625e4c84a9..24601e15ec 100644 --- a/js/msf/src/catalog.ts +++ b/js/msf/src/catalog.ts @@ -35,6 +35,8 @@ const trackShape = { samplerate: z.optional(z.number()), channelConfig: z.optional(z.string()), bitrate: z.optional(z.number()), + // Non-standard: whether the publisher recommends temporarily avoiding this track. + stalled: z.optional(z.boolean()), /** Resolved base64 initialization data (draft-01's initRef indirection is resolved away). */ initData: z.optional(z.string()), renderGroup: z.optional(z.number()), diff --git a/js/watch/src/msf.test.ts b/js/watch/src/msf.test.ts new file mode 100644 index 0000000000..90a44d6f61 --- /dev/null +++ b/js/watch/src/msf.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test"; +import type * as Msf from "@moq/msf"; +import { toHang } from "./msf"; + +test("preserves stalled video renditions", () => { + const catalog: Msf.Catalog = { + tracks: [ + { + name: "video", + packaging: "loc", + role: "video", + codec: "vp09.00.10.08", + stalled: true, + }, + ], + }; + + expect(toHang(catalog).video?.renditions.video?.stalled).toBe(true); +}); diff --git a/js/watch/src/msf.ts b/js/watch/src/msf.ts index 30fdc8ff2a..fc3eb9d3fe 100644 --- a/js/watch/src/msf.ts +++ b/js/watch/src/msf.ts @@ -52,6 +52,7 @@ function toVideoConfig(track: Msf.Track): Catalog.VideoConfig | undefined { codedHeight: track.height != null ? u53(track.height) : undefined, framerate: track.framerate, bitrate: track.bitrate != null ? u53(track.bitrate) : undefined, + stalled: track.stalled, jitter: track.jitter != null ? u53(track.jitter) : undefined, }; } diff --git a/js/watch/src/video/config.test.ts b/js/watch/src/video/config.test.ts new file mode 100644 index 0000000000..feb8dcf0b7 --- /dev/null +++ b/js/watch/src/video/config.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import * as Catalog from "@moq/hang/catalog"; +import { Effect, Signal } from "@moq/signals"; +import { playbackIdentity } from "./config"; + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function config(fields: Record = {}): Catalog.VideoConfig { + return Catalog.VideoConfigSchema.parse({ + codec: "avc1.64001f", + container: { kind: "legacy" }, + description: "0142002a", + optimizeForLatency: true, + ...fields, + }); +} + +test("metadata changes do not change the playback identity", async () => { + const rendition = new Signal(config()); + const root = new Effect(); + const identity = root.computed((effect) => playbackIdentity(effect.get(rendition))); + let runs = 0; + + root.run((effect) => { + effect.get(identity); + runs++; + }); + await flush(); + expect(runs).toBe(1); + + rendition.set( + config({ + bitrate: 2_000_000, + stalled: true, + codedWidth: 1280, + codedHeight: 720, + framerate: 30, + jitter: 33, + }), + ); + await flush(); + expect(runs).toBe(1); + + rendition.set(config({ codec: "avc1.640028" })); + await flush(); + expect(runs).toBe(2); + + root.close(); +}); + +test("routing and decoder inputs change the playback identity", () => { + const base = playbackIdentity(config()); + + expect(playbackIdentity(config({ broadcast: "../source" }))).not.toEqual(base); + expect(playbackIdentity(config({ description: "0164001f" }))).not.toEqual(base); + expect(playbackIdentity(config({ container: { kind: "loc" } }))).not.toEqual(base); + expect(playbackIdentity(config({ displayAspectWidth: 16, displayAspectHeight: 9 }))).not.toEqual(base); + expect(playbackIdentity(config({ optimizeForLatency: false }))).not.toEqual(base); +}); diff --git a/js/watch/src/video/config.ts b/js/watch/src/video/config.ts new file mode 100644 index 0000000000..eb2ebe9c89 --- /dev/null +++ b/js/watch/src/video/config.ts @@ -0,0 +1,38 @@ +import type * as Catalog from "@moq/hang/catalog"; + +/** Internal key used to describe which fields can change a support probe's result. */ +export const supportCacheKey = Symbol("supportCacheKey"); + +/** The catalog fields that determine the demuxer and WebCodecs decoder instance. */ +export type DecoderConfig = Pick< + Catalog.VideoConfig, + "codec" | "container" | "description" | "displayAspectWidth" | "displayAspectHeight" +> & { + optimizeForLatency: boolean; +}; + +/** The routing and decoder state whose changes require a new playback pipeline. */ +export type PlaybackIdentity = { + broadcast: Catalog.VideoConfig["broadcast"]; + decoder: DecoderConfig; +}; + +/** Reduce a rendition config to the fields that require a new playback pipeline. */ +export function playbackIdentity(config: Catalog.VideoConfig): PlaybackIdentity { + return { + broadcast: config.broadcast, + decoder: { + codec: config.codec, + container: config.container, + description: config.description, + displayAspectWidth: config.displayAspectWidth, + displayAspectHeight: config.displayAspectHeight, + optimizeForLatency: config.optimizeForLatency ?? true, + }, + }; +} + +/** Return a stable cache key for the fields used by the WebCodecs support probe. */ +export function decoderConfigKey(config: Catalog.VideoConfig): string { + return JSON.stringify(playbackIdentity(config).decoder); +} diff --git a/js/watch/src/video/decoder.ts b/js/watch/src/video/decoder.ts index 0bd7b160f8..efd6ee2768 100644 --- a/js/watch/src/video/decoder.ts +++ b/js/watch/src/video/decoder.ts @@ -3,10 +3,26 @@ import * as Container from "@moq/hang/container"; import * as Util from "@moq/hang/util"; import type * as Moq from "@moq/net"; import { Time } from "@moq/net"; -import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; +import { + type Computed, + Effect, + type Getter, + getter, + type Inputs, + type Readonlys, + readonlys, + Signal, +} from "@moq/signals"; import { base64ToBytes } from "../base64"; import type { Sync } from "../sync"; +import { + type DecoderConfig, + decoderConfigKey, + type PlaybackIdentity, + playbackIdentity, + supportCacheKey, +} from "./config"; import { caughtUp } from "./playhead"; import { rotateVideoDimensions } from "./presentation"; import type { Source } from "./source"; @@ -45,11 +61,6 @@ type DecoderOutput = { buffered: Signal; }; -// The types in VideoDecoderConfig that cause a hard reload. -// ex. codedWidth/Height are optional and can be changed in-band, so we don't want to trigger a reload. -// This way we can keep the current subscription active. -type RequiredDecoderConfig = Omit; - /** Downloads video from a track and decodes it into {@link VideoFrame}s with WebCodecs. */ export class Decoder { readonly in: Readonlys; @@ -68,6 +79,7 @@ export class Decoder { // The current track running, held so we can cancel it when the new track is ready. #active = new Signal(undefined); + readonly #identity: Computed; #signals = new Effect(); @@ -86,6 +98,10 @@ export class Decoder { this.source = source; this.sync = sync; + this.#identity = this.#signals.computed((effect) => { + const config = effect.get(this.source.out.config); + return config ? playbackIdentity(config) : undefined; + }); this.#signals.run(this.#runPending.bind(this)); this.#signals.run(this.#runActive.bind(this)); @@ -98,7 +114,7 @@ export class Decoder { this.in.enabled, this.source.in.broadcast, this.source.out.track, - this.source.out.config, + this.#identity, ]); if (!values) { // Close the active track when disabled (e.g. paused or not visible). @@ -106,11 +122,11 @@ export class Decoder { this.#active.set(undefined); return; } - const [_, broadcast, track, config] = values; + const [_, broadcast, track, identity] = values; // Honor a per-rendition `broadcast` override: subscribe on the resolved source // broadcast instead of the catalog's own broadcast. - const active: Moq.Broadcast.Consumer | undefined = broadcast.relativeBroadcast(effect, config.broadcast); + const active: Moq.Broadcast.Consumer | undefined = broadcast.relativeBroadcast(effect, identity.broadcast); if (!active) { // Going offline should clear the last rendered frame. this.#active.set(undefined); @@ -124,7 +140,7 @@ export class Decoder { sync: this.sync, broadcast: active, track, - config, + config: identity.decoder, stats: this.#out.stats, }); @@ -226,7 +242,7 @@ interface DecoderTrackProps { sync: Sync; broadcast: Moq.Broadcast.Consumer; track: string; - config: Catalog.VideoConfig; + config: DecoderConfig; stats: Signal; } @@ -235,7 +251,7 @@ class DecoderTrack { sync: Sync; broadcast: Moq.Broadcast.Consumer; track: string; - config: RequiredDecoderConfig; + config: DecoderConfig; stats: Signal; timestamp = new Signal(undefined); @@ -254,13 +270,10 @@ class DecoderTrack { #signals = new Effect(); constructor(props: DecoderTrackProps) { - // Remove the codedWidth/Height from the config to avoid a hard reload if nothing else has changed. - const { codedWidth: _, codedHeight: __, ...requiredConfig } = props.config; - this.sync = props.sync; this.broadcast = props.broadcast; this.track = props.track; - this.config = requiredConfig; + this.config = props.config; this.stats = props.stats; this.#signals.run(this.#run.bind(this)); @@ -348,9 +361,11 @@ class DecoderTrack { }); decoder.configure({ - ...this.config, + codec: this.config.codec, description: this.config.description ? Util.Hex.toBytes(this.config.description) : undefined, - optimizeForLatency: this.config.optimizeForLatency ?? true, + displayAspectWidth: this.config.displayAspectWidth, + displayAspectHeight: this.config.displayAspectHeight, + optimizeForLatency: this.config.optimizeForLatency, // @ts-expect-error Only supported by Chrome, so the renderer has to flip manually. flip: false, }); @@ -425,7 +440,9 @@ class DecoderTrack { decoder.configure({ codec: this.config.codec, description, - optimizeForLatency: this.config.optimizeForLatency ?? true, + displayAspectWidth: this.config.displayAspectWidth, + displayAspectHeight: this.config.displayAspectHeight, + optimizeForLatency: this.config.optimizeForLatency, // @ts-expect-error Only supported by Chrome, so the renderer has to flip manually. flip: false, }); @@ -557,6 +574,8 @@ async function supported(config: Catalog.VideoConfig): Promise { const { supported } = await VideoDecoder.isConfigSupported({ codec: config.codec, description, + displayAspectWidth: config.displayAspectWidth, + displayAspectHeight: config.displayAspectHeight, optimizeForLatency: config.optimizeForLatency ?? true, }); @@ -570,6 +589,8 @@ async function supported(config: Catalog.VideoConfig): Promise { const retry = await VideoDecoder.isConfigSupported({ codec: avc1, description, + displayAspectWidth: config.displayAspectWidth, + displayAspectHeight: config.displayAspectHeight, optimizeForLatency: config.optimizeForLatency ?? true, }); if (retry.supported) { @@ -580,3 +601,5 @@ async function supported(config: Catalog.VideoConfig): Promise { return false; } + +Object.assign(supported, { [supportCacheKey]: decoderConfigKey }); diff --git a/js/watch/src/video/source.test.ts b/js/watch/src/video/source.test.ts index 52576ab127..0a52ef8388 100644 --- a/js/watch/src/video/source.test.ts +++ b/js/watch/src/video/source.test.ts @@ -3,6 +3,7 @@ import * as Catalog from "@moq/hang/catalog"; import { Path } from "@moq/net"; import { Signal } from "@moq/signals"; import { Broadcast } from "../broadcast"; +import { decoderConfigKey, supportCacheKey } from "./config"; import { Source } from "./source"; const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -11,8 +12,8 @@ async function settle(): Promise { for (let i = 0; i < 5; i++) await flush(); } -function config(codec: string): Catalog.VideoConfig { - return { codec, container: { kind: "legacy" } }; +function config(codec: string, fields: Record = {}): Catalog.VideoConfig { + return Catalog.VideoConfigSchema.parse({ codec, container: { kind: "legacy" }, ...fields }); } function mockBroadcast(renditions: Record): Broadcast { @@ -26,6 +27,20 @@ function mockBroadcast(renditions: Record): Broadca } as unknown as Broadcast; } +function mutableBroadcast(renditions: Record): { + broadcast: Broadcast; + catalog: Signal; +} { + const catalog = new Signal({ video: { renditions } }); + return { + broadcast: { + in: { connection: new Signal(undefined) }, + out: { catalog }, + } as unknown as Broadcast, + catalog, + }; +} + async function withoutWarnings(fn: () => Promise): Promise { const warn = console.warn; console.warn = () => {}; @@ -148,3 +163,111 @@ describe("Source error signal", () => { broadcast.close(); }); }); + +describe("Source stalled rendition selection", () => { + it("skips a stalled manual target while an unstalled rendition exists", async () => { + const source = new Source({ + broadcast: mockBroadcast({ + low: config("avc1.64001e", { bitrate: 1_000_000 }), + high: config("avc1.640028", { bitrate: 2_000_000, stalled: true }), + }), + target: { name: "high" }, + supported: async () => true, + }); + + await settle(); + expect(source.out.track.peek()).toBe("low"); + expect(Object.keys(source.out.available.peek())).toEqual(["low", "high"]); + source.close(); + }); + + it("selects the lowest rendition when every supported rendition is stalled", async () => { + const source = new Source({ + broadcast: mockBroadcast({ + high: config("avc1.640028", { bitrate: 2_000_000, stalled: true }), + low: config("avc1.64001e", { bitrate: 1_000_000, stalled: true }), + }), + supported: async () => true, + }); + + await settle(); + expect(source.out.track.peek()).toBe("low"); + source.close(); + }); + + it("uses coded dimensions when stalled renditions omit bitrate", async () => { + const source = new Source({ + broadcast: mockBroadcast({ + high: config("avc1.640028", { codedWidth: 1920, codedHeight: 1080, stalled: true }), + low: config("avc1.64001e", { codedWidth: 854, codedHeight: 480, stalled: true }), + }), + supported: async () => true, + }); + + await settle(); + expect(source.out.track.peek()).toBe("low"); + source.close(); + }); + + it("does not repeat support probes for metadata-only changes", async () => { + const state = mutableBroadcast({ high: config("avc1.640028", { bitrate: 2_000_000 }) }); + let probes = 0; + const supported = async () => { + probes++; + return true; + }; + Object.assign(supported, { [supportCacheKey]: decoderConfigKey }); + const source = new Source({ + broadcast: state.broadcast, + supported, + }); + + await settle(); + expect(probes).toBe(1); + + state.catalog.set({ + video: { + renditions: { + high: config("avc1.640028", { + bitrate: 1_500_000, + stalled: true, + codedWidth: 1280, + codedHeight: 720, + }), + }, + }, + }); + await settle(); + + expect(probes).toBe(1); + expect(Number(source.out.config.peek()?.bitrate)).toBe(1_500_000); + expect(source.out.config.peek()?.stalled).toBe(true); + source.close(); + }); + + it("repeats custom support probes when metadata changes", async () => + withoutWarnings(async () => { + const state = mutableBroadcast({ high: config("avc1.640028", { codedWidth: 1280 }) }); + let probes = 0; + const source = new Source({ + broadcast: state.broadcast, + supported: async (rendition) => { + probes++; + return (rendition.codedWidth ?? 0) <= 1920; + }, + }); + + await settle(); + expect(probes).toBe(1); + expect(Object.keys(source.out.available.peek())).toEqual(["high"]); + + state.catalog.set({ + video: { renditions: { high: config("avc1.640028", { codedWidth: 3840 }) } }, + }); + await settle(); + + expect(probes).toBe(2); + expect(source.out.available.peek()).toEqual({}); + source.close(); + })); +}); diff --git a/js/watch/src/video/source.ts b/js/watch/src/video/source.ts index a5a21c1262..5308c7a5d5 100644 --- a/js/watch/src/video/source.ts +++ b/js/watch/src/video/source.ts @@ -2,6 +2,7 @@ import type * as Catalog from "@moq/hang/catalog"; import type * as Moq from "@moq/net"; import { Effect, type Getter, getter, type Inputs, type Readonlys, readonlys, Signal } from "@moq/signals"; import type { Broadcast } from "../broadcast"; +import { supportCacheKey } from "./config"; import { renditionJitter } from "./playhead"; /** @@ -11,6 +12,10 @@ import { renditionJitter } from "./playhead"; */ export type Supported = (config: Catalog.VideoConfig) => Promise; +type CacheableSupported = Supported & { + [supportCacheKey]?: (config: Catalog.VideoConfig) => string; +}; + /** A video source error that prevents choosing a usable rendition. */ export type SourceError = "unsupported"; @@ -96,7 +101,7 @@ function byPixels(target: number): RenditionFilter { return [rest[0].name]; } - // No entries had resolution metadata — return all names unranked. + // No entries had resolution metadata, so return all names unranked. return entries.map(([name]) => name); }; } @@ -137,7 +142,7 @@ function byDimensions(width?: number, height?: number): RenditionFilter { return [rest[0].name]; } - // No entries had resolution metadata — return all names unranked. + // No entries had resolution metadata, so return all names unranked. return entries.map(([name]) => name); }; } @@ -174,7 +179,7 @@ function byBitrate(target: number): RenditionFilter { return [rest[0].name]; } - // No entries had bitrate metadata — return all names unranked. + // No entries had bitrate metadata, so return all names unranked. return entries.map(([name]) => name); }; } @@ -207,6 +212,18 @@ function bestRendition(entries: [string, Catalog.VideoConfig][]): string { return best[0]; } +/** Return unstalled renditions, or the lowest bitrate or resolution when every option is stalled. */ +function selectableRenditions(renditions: Record): Record { + const active = Object.entries(renditions).filter(([, config]) => !config.stalled); + if (active.length > 0) return Object.fromEntries(active); + + const entries = Object.entries(renditions); + if (entries.length === 0) return {}; + const byRate = byBitrate(0)(entries); + const lowest = byRate.length === 1 ? byRate[0] : byDimensions(0, 0)(entries)[0]; + return { [lowest]: renditions[lowest] }; +} + /** * Source handles catalog extraction, support checking, and rendition selection * for video playback. The Decoder consumes whichever rendition it picks. @@ -225,6 +242,7 @@ export class Source { readonly out = readonlys(this.#out); #signals = new Effect(); + #supportCache = new WeakMap>(); constructor(props?: Inputs) { this.in = { @@ -257,6 +275,15 @@ export class Source { const renditions = effect.get(this.#out.catalog)?.renditions ?? {}; this.#out.error.set(undefined); + let cache = this.#supportCache.get(supported); + if (!cache) { + cache = new Map(); + this.#supportCache.set(supported, cache); + } + const names = new Set(Object.keys(renditions)); + for (const name of cache.keys()) { + if (!names.has(name)) cache.delete(name); + } effect.spawn(async () => { const available: Record = {}; @@ -267,14 +294,29 @@ export class Source { const cancelled = effect.cancel.then(() => undefined); for (const [name, config] of Object.entries(renditions)) { + const cacheKey = (supported as CacheableSupported)[supportCacheKey]; + const key = cacheKey ? cacheKey(config) : JSON.stringify(config); + const cached = cache.get(name); let isSupported: boolean | undefined = false; - try { - isSupported = await Promise.race([supported(config), cancelled]); - } catch (err) { - console.warn( - `[Source] video rendition ${name} (${config.codec}) support probe failed; treating as unsupported`, - err, - ); + if (cached?.key === key) { + isSupported = cached.supported; + } else { + let failed = false; + try { + isSupported = await Promise.race([supported(config), cancelled]); + } catch (err) { + failed = true; + console.warn( + `[Source] video rendition ${name} (${config.codec}) support probe failed; treating as unsupported`, + err, + ); + } + if (!failed && isSupported !== undefined) { + cache.set(name, { + key: cacheKey ? cacheKey(config) : JSON.stringify(config), + supported: isSupported, + }); + } } // Torn down: stop probing and publish nothing, since the rerun redoes this. @@ -295,12 +337,12 @@ export class Source { } #runSelected(effect: Effect): void { - const available = effect.get(this.#out.available); + const available = selectableRenditions(effect.get(this.#out.available)); if (Object.keys(available).length === 0) return; const target = effect.get(this.in.target); - // Manual selection by name — skip all ABR logic. + // Manual selection by name skips all ABR logic. if (target?.name && target.name in available) { const config = available[target.name]; effect.set(this.#out.track, target.name); @@ -357,7 +399,7 @@ export class Source { filters.push(byBitrate(target.bitrate)); } - // No filters — pick the best rendition by quality. + // With no filters, pick the best rendition by quality. if (filters.length === 0) { return bestRendition(entries); } diff --git a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt index cfa3422088..bee9bf2b81 100644 --- a/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt +++ b/kt/moq/src/jvmAndAndroidMain/kotlin/dev/moq/Aliases.kt @@ -93,7 +93,7 @@ typealias Datagram = uniffi.moq.MoqDatagram typealias Frame = uniffi.moq.MoqFrame /** A [Frame] plus the codec metadata a media track carries. */ typealias MediaFrame = uniffi.moq.MoqMediaFrame -/** The catalog description of a video track: codec, dimensions, bitrate, and container. */ +/** The catalog description of a video track, including whether the publisher recommends temporarily avoiding it. */ typealias Video = uniffi.moq.MoqVideo /** Caller-provided catalog fields for a video track. */ typealias VideoHint = uniffi.moq.MoqVideoHint diff --git a/py/moq-rs/README.md b/py/moq-rs/README.md index 651dc97555..0648e4ee30 100644 --- a/py/moq-rs/README.md +++ b/py/moq-rs/README.md @@ -197,7 +197,7 @@ All consumers (`CatalogConsumer`, `MediaConsumer`, `TrackConsumer`, `AudioConsum - **`MediaFrame`**. `.payload: bytes`, `.timestamp_us: int`, `.keyframe: bool`. Returned by media subscriptions. - **`Datagram`**. `.sequence: int`, `.timestamp_us: int`, `.payload: bytes`. Delivered only on datagram-capable transports and lite-05 or newer moq-lite. - **`Audio`**. `.codec`, `.sample_rate`, `.channel_count`, `.bitrate`, `.description`. -- **`Video`**. `.codec`, `.coded: Dimensions`, `.display_aspect`, `.bitrate`, `.framerate`, `.description`. +- **`Video`**. `.codec`, `.coded: Dimensions`, `.display_aspect`, `.bitrate`, `.stalled`, `.framerate`, `.description`. A true `.stalled` recommends temporarily avoiding the rendition without making it unavailable. - **`Subscription`**. Subscriber delivery preferences: priority, ordering priority, staleness, and optional group range. - **`TrackInfo`**. Publisher track properties: priority, ordering priority, cache window, and timescale. - **`Dimensions`**. `.width: int`, `.height: int`. diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index efd2ca0126..40d89b0543 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -215,6 +215,7 @@ mod test { display_aspect_width: None, display_aspect_height: None, bitrate: None, + stalled: None, framerate: None, optimize_for_latency: None, container: Container::Legacy, diff --git a/rs/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index 3596555733..7b093d08e6 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -187,6 +187,13 @@ pub struct VideoConfig { #[serde(default)] pub bitrate: Option, + /// Whether the publisher recommends temporarily avoiding this rendition. + /// + /// The track remains available. Consumers may still select it when no + /// unstalled rendition is suitable. + #[serde(default)] + pub stalled: Option, + /// The frame rate of the video track, if known. #[serde(default)] pub framerate: Option, @@ -239,6 +246,7 @@ impl VideoConfig { display_aspect_width: None, display_aspect_height: None, bitrate: None, + stalled: None, framerate: None, optimize_for_latency: None, container: Container::default(), @@ -287,6 +295,20 @@ mod test { assert_eq!(config.display_aspect_height, Some(9)); } + #[test] + fn stalled_is_optional_and_round_trips() { + let mut config = VideoConfig::new(VideoCodec::VP8); + let encoded = serde_json::to_value(&config).expect("failed to encode"); + assert!(encoded.get("stalled").is_none()); + + config.stalled = Some(true); + let encoded = serde_json::to_value(&config).expect("failed to encode"); + assert_eq!(encoded["stalled"], true); + + let decoded: VideoConfig = serde_json::from_value(encoded).expect("failed to decode"); + assert_eq!(decoded.stalled, Some(true)); + } + #[test] fn normalizes_video_rotation_to_quarter_turns() { for (rotation, expected) in [ diff --git a/rs/libmoq/README.md b/rs/libmoq/README.md index b9e469d1e4..1fedfaa244 100644 --- a/rs/libmoq/README.md +++ b/rs/libmoq/README.md @@ -62,6 +62,7 @@ int32_t moq_consume_catalog(uint32_t broadcast, void (*on_catalog)(void *user_da int32_t moq_consume_catalog_close(uint32_t catalog); int32_t moq_consume_catalog_free(uint32_t catalog); int32_t moq_consume_video_config(uint32_t catalog, uint32_t index, moq_video_config *dst); +int32_t moq_consume_video_stalled(uint32_t catalog, uint32_t index, bool *dst); int32_t moq_consume_audio_config(uint32_t catalog, uint32_t index, moq_audio_config *dst); // Consuming: Video diff --git a/rs/libmoq/src/api.rs b/rs/libmoq/src/api.rs index 659f001a6a..e46190a2e9 100644 --- a/rs/libmoq/src/api.rs +++ b/rs/libmoq/src/api.rs @@ -2359,6 +2359,29 @@ pub unsafe extern "C" fn moq_consume_video_config(catalog: u32, index: u32, dst: }) } +/// Query whether the publisher recommends temporarily avoiding a video rendition. +/// +/// The track remains available. A false value also covers catalogs that omit the +/// optional field. +/// +/// Returns zero on success, or a negative code on failure. +/// +/// # Safety +/// - The caller must ensure that `dst` points to properly aligned, writable storage for a `bool`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn moq_consume_video_stalled(catalog: u32, index: u32, dst: *mut bool) -> i32 { + ffi::enter(move || { + let catalog = ffi::parse_id(catalog)?; + if dst.is_null() { + return Err(Error::InvalidPointer); + } + + let stalled = State::lock().consume.video_stalled(catalog, index as usize)?; + unsafe { dst.write(stalled) }; + Ok(()) + }) +} + /// Query the catalog properties shared by every video rendition. /// /// The destination is filled by value and remains valid after the catalog snapshot is freed. diff --git a/rs/libmoq/src/consume.rs b/rs/libmoq/src/consume.rs index af3313ba63..cce8b3a005 100644 --- a/rs/libmoq/src/consume.rs +++ b/rs/libmoq/src/consume.rs @@ -219,6 +219,19 @@ impl Consume { Ok(()) } + /// Return whether the publisher recommends temporarily avoiding a video rendition. + pub fn video_stalled(&self, catalog: Id, index: usize) -> Result { + let consume = self.catalog.get(catalog).ok_or(Error::CatalogNotFound)?; + let (_, config) = consume + .catalog + .video + .renditions + .iter() + .nth(index) + .ok_or(Error::NoIndex)?; + Ok(config.stalled.unwrap_or(false)) + } + /// Fill `dst` with the properties shared by every video rendition. pub fn video_properties(&self, catalog: Id, dst: &mut moq_video_properties) -> Result<(), Error> { let consume = self.catalog.get(catalog).ok_or(Error::CatalogNotFound)?; diff --git a/rs/libmoq/src/test.rs b/rs/libmoq/src/test.rs index 8a424384f8..7844f504e7 100644 --- a/rs/libmoq/src/test.rs +++ b/rs/libmoq/src/test.rs @@ -285,6 +285,30 @@ fn publish_catalog_roundtrip() { container: moq_container::default(), }; assert_eq!(unsafe { moq_publish_video_config(broadcast, &video) }, 0); + let stalled_video_name = "video-stalled"; + let stalled_video = moq_video_config { + name: stalled_video_name.as_ptr() as *const c_char, + name_len: stalled_video_name.len(), + codec: video_codec.as_ptr() as *const c_char, + codec_len: video_codec.len(), + description: description.as_ptr(), + description_len: description.len(), + coded_width: &width, + coded_height: &height, + container: moq_container::default(), + }; + assert_eq!(unsafe { moq_publish_video_config(broadcast, &stalled_video) }, 0); + { + let mut state = State::lock(); + let (_, catalog) = state.publish.pair_mut(Id::try_from(broadcast).unwrap()).unwrap(); + catalog + .lock() + .video + .renditions + .get_mut(stalled_video_name) + .unwrap() + .stalled = Some(true); + } let properties = moq_video_properties { display_width: 1080, display_height: 1920, @@ -340,6 +364,35 @@ fn publish_catalog_roundtrip() { assert_eq!(codec, "vp8"); assert_eq!(unsafe { *video_cfg.coded_width }, 1920); assert_eq!(unsafe { *video_cfg.coded_height }, 1080); + let mut stalled = std::mem::MaybeUninit::::uninit(); + assert_eq!( + unsafe { moq_consume_video_stalled(catalog_id, 0, stalled.as_mut_ptr()) }, + 0 + ); + assert!(!unsafe { stalled.assume_init() }); + + let mut stalled = std::mem::MaybeUninit::::uninit(); + assert_eq!( + unsafe { moq_consume_video_stalled(catalog_id, 1, stalled.as_mut_ptr()) }, + 0 + ); + assert!(unsafe { stalled.assume_init() }); + assert_eq!( + unsafe { moq_consume_video_stalled(catalog_id, 0, std::ptr::null_mut()) }, + -6, + "null stalled pointer should return InvalidPointer (-6)" + ); + assert_eq!( + unsafe { + moq_publish_video_remove( + broadcast, + stalled_video_name.as_ptr() as *const c_char, + stalled_video_name.len(), + ) + }, + 0 + ); + let active_catalog_id = id(catalog_cb.recv()); let mut properties = moq_video_properties::default(); assert_eq!(unsafe { moq_consume_video_properties(catalog_id, &mut properties) }, 0); @@ -380,6 +433,7 @@ fn publish_catalog_roundtrip() { assert_eq!(unsafe { moq_consume_audio_config(catalog_id2, 0, &mut audio_cfg) }, 0); assert_eq!(moq_consume_catalog_free(catalog_id), 0); + assert_eq!(moq_consume_catalog_free(active_catalog_id), 0); assert_eq!(moq_consume_catalog_free(catalog_id2), 0); assert_eq!(moq_consume_catalog_close(catalog_task), 0); assert_eq!(catalog_cb.recv_terminal(), 0, "catalog close delivers terminal 0"); diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index 4937b31ec7..67934e1efd 100644 --- a/rs/moq-ffi/src/media.rs +++ b/rs/moq-ffi/src/media.rs @@ -84,6 +84,9 @@ pub struct MoqVideo { pub coded: Option, pub display_aspect: Option, pub bitrate: Option, + /// Whether the publisher recommends temporarily avoiding this rendition. + #[uniffi(default = false)] + pub stalled: bool, pub framerate: Option, pub container: MoqContainer, } @@ -217,6 +220,7 @@ pub(crate) fn convert_catalog(catalog: &moq_mux::catalog::hang::Catalog None, }, bitrate: config.bitrate, + stalled: config.stalled.unwrap_or(false), framerate: config.framerate, container: MoqContainer::from_catalog(&config.container)?, }, @@ -262,3 +266,22 @@ pub(crate) fn convert_catalog(catalog: &moq_mux::catalog::hang::Catalog, + /// Whether the publisher recommends temporarily avoiding this track. + /// + /// This is a non-standard extension shared with the hang catalog. + pub stalled: Option, + /// Resolved base64 initialization data. /// /// On the wire this is carried indirectly through draft-01's `initDataList` + @@ -372,6 +377,7 @@ impl Track { samplerate: None, channel_config: None, bitrate: None, + stalled: None, init_data: None, init_ref: None, render_group: None, @@ -522,6 +528,7 @@ mod test { samplerate: None, channel_config: None, bitrate: Some(6_000_000), + stalled: Some(true), init_data: None, init_ref: None, render_group: Some(1), @@ -545,6 +552,7 @@ mod test { samplerate: Some(48_000), channel_config: Some("2".to_string()), bitrate: Some(128_000), + stalled: None, init_data: None, init_ref: None, render_group: Some(1), @@ -568,6 +576,7 @@ mod test { samplerate: None, channel_config: None, bitrate: Some(5_000_000), + stalled: None, init_data: None, init_ref: None, render_group: Some(1), @@ -596,6 +605,7 @@ mod test { assert!(track.get("maxGrpSapStartingType").is_none()); assert!(track.get("maxObjSapStartingType").is_none()); assert!(track.get("jitter").is_none()); + assert_eq!(track["stalled"], true); } #[test] diff --git a/rs/moq-mux/src/catalog/msf/consumer.rs b/rs/moq-mux/src/catalog/msf/consumer.rs index 1a573d18f1..05425518d7 100644 --- a/rs/moq-mux/src/catalog/msf/consumer.rs +++ b/rs/moq-mux/src/catalog/msf/consumer.rs @@ -215,6 +215,7 @@ fn video_config_from_msf(track: &moq_msf::Track) -> Result> config.coded_width = track.width; config.coded_height = track.height; config.bitrate = track.bitrate; + config.stalled = track.stalled; config.framerate = track.framerate; config.container = container; config.jitter = track.jitter; @@ -386,6 +387,7 @@ mod test { track.height = Some(1080); track.framerate = Some(30.0); track.bitrate = Some(5_000_000); + track.stalled = Some(true); track.init_data = init_data.map(str::to_string); track.render_group = Some(1); track @@ -423,6 +425,7 @@ mod test { assert_eq!(video.coded_height, Some(1080)); assert_eq!(video.framerate, Some(30.0)); assert_eq!(video.bitrate, Some(5_000_000)); + assert_eq!(video.stalled, Some(true)); } #[test] diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 128e6169e4..a237431b2f 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -500,6 +500,7 @@ fn to_msf(catalog: &hang::Catalog) -> moq_msf::Catalog { track.height = config.coded_height; track.framerate = config.framerate; track.bitrate = config.bitrate; + track.stalled = config.stalled; track.init_data = init_data; track.render_group = Some(1); track.alt_group = if has_multiple_video { Some(1) } else { None }; @@ -795,6 +796,7 @@ mod test { video_config.coded_width = Some(1280); video_config.coded_height = Some(720); video_config.bitrate = Some(6_000_000); + video_config.stalled = Some(true); video_config.framerate = Some(30.0); video_config.container = Container::Legacy; @@ -825,6 +827,7 @@ mod test { assert_eq!(video.height, Some(720)); assert_eq!(video.framerate, Some(30.0)); assert_eq!(video.bitrate, Some(6_000_000)); + assert_eq!(video.stalled, Some(true)); assert!(video.init_data.is_none()); // H.264 may carry B-frames, so SAP starting type is 2 (leading pictures allowed). assert_eq!(video.max_grp_sap_starting_type, Some(2)); diff --git a/swift/Sources/Moq/Aliases.swift b/swift/Sources/Moq/Aliases.swift index e36e6ebe50..69a1db8373 100644 --- a/swift/Sources/Moq/Aliases.swift +++ b/swift/Sources/Moq/Aliases.swift @@ -14,8 +14,8 @@ public typealias MediaFrame = MoqFFI.MoqMediaFrame /// The JSON manifest describing a broadcast's tracks: video and audio /// renditions, display geometry, and untyped application sections. public typealias Catalog = MoqFFI.MoqCatalog -/// A video rendition in the catalog: codec, coded/display dimensions, bitrate, -/// framerate, and container. +/// A video rendition in the catalog: codec, dimensions, bitrate, temporary +/// avoidance recommendation, framerate, and container. public typealias Video = MoqFFI.MoqVideo /// Caller-provided catalog fields for a video track. public typealias VideoHint = MoqFFI.MoqVideoHint From facca0fe96347be26d0582424f47877171b9938c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 15 Aug 2026 00:07:50 -0700 Subject: [PATCH 11/12] fix(relay): redial reconfigured cluster peers (#2874) Co-authored-by: GPT-5 --- doc/bin/relay/cluster.md | 12 +- doc/bin/relay/config.md | 8 +- rs/moq-relay/src/cluster.rs | 763 ++++++++++++++++++++++++++---------- 3 files changed, 562 insertions(+), 221 deletions(-) diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md index 7c5916c828..96c56cd3aa 100644 --- a/doc/bin/relay/cluster.md +++ b/doc/bin/relay/cluster.md @@ -95,25 +95,25 @@ connect_api = "https://api.example.com/cluster/connect" node = "us-west.example.com:4443" ``` -The source returns a bare JSON array of peer hostnames: +The source returns a JSON array of peer URLs. Legacy bare hosts remain accepted: ```json -["eu-west.example.com:4443", "us-east.example.com:4443"] +["https://eu-west.example.com/?cost=10", "us-east.example.com:4443"] ``` -The relay reconciles that list against its live dials: new entries are dialed, entries that disappear are dropped. It composes with `connect` (static seeds that are never reconciled away) and `mesh` (gossip). The relay's own `node` value, when set, is sent as a `?node=` query parameter so the endpoint can return the peers for that specific node; for mTLS-gated endpoints the cluster client certificate identifies the caller as well. +The relay reconciles that list against its live dials: new entries are dialed, entries that disappear are dropped, and a changed URL for a `connect_api`-owned peer replaces its session. That includes dial-side inputs such as `?cost=` and an inline `?jwt=`. An identical render is a no-op. It composes with `connect` (static seeds that are never reconciled away) and `mesh` (gossip). If another source already owns a peer's session, the API entry remains its updated fallback until that source disappears. The relay's own `node` value, when set, is sent as a `?node=` query parameter so the endpoint can return the peers for that specific node; for mTLS-gated endpoints the cluster client certificate identifies the caller as well. - **HTTP(S) URL**: re-checked every 30s, but freshness is delegated to a standard HTTP cache (`http-cache`), so the response's `Cache-Control` controls how often a check turns into a real fetch. While the cached list is still fresh (`max-age`), the re-check is served from cache with no network round-trip; once it's stale the cache issues a conditional GET (`ETag` / `Last-Modified`) and falls back to the last cached body if revalidation fails (stale-if-error). Set a longer `max-age` to reduce load on your endpoint, or `no-cache` to force a conditional GET on every tick. Transient endpoint blips don't churn the dial set. - **Local file** (a path or `file://` URL): watched via OS filesystem notifications (inotify / FSEvents / kqueue), with a periodic re-check as a safety net. -If a fetch fails or returns garbage, the relay logs and keeps the last good list rather than tearing the cluster down. This keeps the moq-relay binary generic: all routing decisions (which node connects where) live in whatever service answers the endpoint. +If a fetch fails, an entry is invalid, or one identity has conflicting entries, the relay logs and keeps the entire last good list rather than applying a partial topology. This keeps the moq-relay binary generic: all routing decisions (which node connects where) live in whatever service answers the endpoint. ## Authentication Cluster peers must authenticate to each other: - **mTLS** (recommended). Set `tls.root` to the CA that signed the cluster certificates. Inbound connections presenting a valid client cert are granted full access; outbound dials use `client.tls.cert` / `client.tls.key`. -- **JWT**. For static `connect` peers, supply the token inline as a `?jwt=` query parameter on the URL. For gossip- and `connect_api`-discovered peers (whose addresses can't carry an inline token), set `cluster.token` to a file holding the JWT; it's presented on any dial whose URL has no inline `?jwt=` (so an inline token wins per-peer). Either way the token needs broad enough scope to cover whatever paths the cluster carries. +- **JWT**. Supply a per-peer token inline as a `?jwt=` query parameter on a static or `connect_api` URL. Alternatively, set `cluster.token` to a file holding the shared JWT; it is presented on any dial whose URL has no inline token. Gossip must use the shared token or mTLS: never put a JWT in `cluster.node`, because that URL is advertised to the mesh and written to logs. Either way the token needs broad enough scope to cover whatever paths the cluster carries. See [Authentication](/bin/relay/auth) for the full setup. @@ -127,7 +127,7 @@ peer stays loudly visible in the logs and a returning one is picked up within se `cluster.root` was removed. To dial cluster peers use `cluster.connect`; to advertise this relay's own address set `cluster.node` and enable `cluster.mesh`. `cluster.mesh` is now a boolean gossip toggle (it used to take this relay's URL); the URL moved to `cluster.node`. The old `mesh = ""` form still works for backwards compatibility: it enables gossip and is treated as `cluster.node`, with a deprecation warning (or an error if it conflicts with an explicit `cluster.node`). -`cluster.connect` entries are now full URLs; a bare host or `host:port` still works but logs a deprecation warning. A JWT for a static peer belongs inline as a `?jwt=` query parameter (the `cluster.token` file remains for gossip / `connect_api` peers, which can't carry an inline token). +`cluster.connect` entries are now full URLs; a bare host or `host:port` still works but logs a deprecation warning. A per-peer JWT belongs inline as a `?jwt=` query parameter on a static or `connect_api` URL. The `cluster.token` file remains the shared fallback and is required for JWT-authenticated gossip; never put a JWT in the advertised `cluster.node` URL. | Old | New | |---|---| diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index a377b5e06b..721d08808e 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -186,12 +186,14 @@ node = "us-west.example.com:4443" mesh = true # Optional. Fetch the peer list from an HTTP(S) endpoint or local file (a JSON -# array of hostnames) and reconcile it at runtime, no restart needed. +# array of peer URLs) and reconcile it at runtime, replacing sessions when URL +# configuration such as ?cost= or ?jwt= changes. connect_api = "https://api.example.com/cluster/connect" # JWT for outbound cluster dials (alternative to mTLS), applied to any peer -# whose URL has no inline ?jwt=. Required to authenticate gossip / connect_api -# discovered peers; for static `connect` peers, prefer an inline ?jwt=. +# whose URL has no inline ?jwt=. An inline token works for static and +# connect_api-discovered peers. Gossip must use this shared token or mTLS because +# the advertised cluster.node URL is public. token = "cluster.jwt" # Optional. How long a broadcast stays alive and announced after abruptly diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index eeb500133a..f3d4168937 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -1,5 +1,5 @@ use std::{ - collections::{HashMap, HashSet}, + collections::HashMap, path::PathBuf, sync::{ Arc, Mutex, @@ -51,6 +51,94 @@ fn should_dial(self_url: &str, peer: &str) -> bool { peer > self_url } +/// One peer's stable identity and the dial-affecting configuration currently +/// supplied by a discovery source. +#[derive(Clone, PartialEq, Eq)] +struct DialTarget { + key: String, + url: Url, + cost: Option, +} + +impl DialTarget { + fn parse(peer: &str) -> anyhow::Result { + let mut url = peer_url(peer)?; + let key = { + let mut identity = url.clone(); + identity.set_query(None); + identity.into() + }; + let cost = take_cost(&mut url)?; + Ok(Self { key, url, cost }) + } +} + +/// Every currently-live gossip path for a canonical peer, in announcement order. +/// Query changes create a new path before the old path necessarily unannounces, +/// so the latest live path owns the dial configuration. +#[derive(Default)] +struct GossipTargets { + by_key: HashMap>, +} + +enum GossipUpdate { + /// The current advertisement changed to this target. + Current(DialTarget), + /// A non-current or unknown advertisement disappeared. + Unchanged, + /// The peer has no live advertisements left. + Gone, +} + +impl GossipTargets { + fn announce(&mut self, advertisement: String, target: DialTarget) -> DialTarget { + let targets = self.by_key.entry(target.key.clone()).or_default(); + targets.retain(|(existing, _)| existing != &advertisement); + targets.push((advertisement, target.clone())); + target + } + + fn unannounce(&mut self, advertisement: &str, key: &str) -> GossipUpdate { + let Some(targets) = self.by_key.get_mut(key) else { + return GossipUpdate::Unchanged; + }; + let was_current = targets.last().is_some_and(|(current, _)| current == advertisement); + let previous_len = targets.len(); + targets.retain(|(existing, _)| existing != advertisement); + if targets.len() == previous_len { + return GossipUpdate::Unchanged; + } + if targets.is_empty() { + self.by_key.remove(key); + return GossipUpdate::Gone; + } + if was_current { + return GossipUpdate::Current(targets.last().expect("live target exists").1.clone()); + } + GossipUpdate::Unchanged + } +} + +/// Parse a complete dynamic peer list before mutating the live dial set. A bad +/// entry or conflicting duplicate rejects the whole update, preserving the +/// last-known-good topology. +fn parse_peer_list(list: Vec, node: Option<&str>) -> anyhow::Result> { + let self_key = node.map(canonicalize_peer_key); + let mut desired = HashMap::new(); + for (index, peer) in list.into_iter().enumerate() { + let target = DialTarget::parse(&peer).with_context(|| format!("invalid peer at index {index}"))?; + if Some(&target.key) == self_key.as_ref() { + continue; + } + if let Some(previous) = desired.insert(target.key.clone(), target.clone()) + && previous != target + { + anyhow::bail!("peer identity {} has conflicting configurations", target.key); + } + } + Ok(desired) +} + /// A mechanism that wants a dial kept alive. A single peer can be wanted by more /// than one at once (e.g. gossiped *and* listed by `--cluster-connect-api`), so /// [`DialEntry`] tracks a set of these and only tears the dial down when the last @@ -69,32 +157,44 @@ enum DialSource { } /// The set of [`DialSource`]s currently keeping a dial alive. -#[derive(Clone, Copy, Default)] +#[derive(Clone, Default)] struct DialSources { - seeded: bool, - gossip: bool, - api: bool, + seeded: Option, + gossip: Option, + api: Option, } impl DialSources { - fn set(&mut self, source: DialSource) { + fn get(&self, source: DialSource) -> Option<&DialTarget> { match source { - DialSource::Static => self.seeded = true, - DialSource::Gossip => self.gossip = true, - DialSource::Api => self.api = true, + DialSource::Static => self.seeded.as_ref(), + DialSource::Gossip => self.gossip.as_ref(), + DialSource::Api => self.api.as_ref(), + } + } + + fn set(&mut self, source: DialSource, target: DialTarget) { + match source { + DialSource::Static => self.seeded = Some(target), + DialSource::Gossip => self.gossip = Some(target), + DialSource::Api => self.api = Some(target), } } fn clear(&mut self, source: DialSource) { match source { - DialSource::Static => self.seeded = false, - DialSource::Gossip => self.gossip = false, - DialSource::Api => self.api = false, + DialSource::Static => self.seeded = None, + DialSource::Gossip => self.gossip = None, + DialSource::Api => self.api = None, } } - fn any(&self) -> bool { - self.seeded || self.gossip || self.api + fn fallback(&self) -> Option<(DialSource, &DialTarget)> { + self.seeded + .as_ref() + .map(|target| (DialSource::Static, target)) + .or_else(|| self.api.as_ref().map(|target| (DialSource::Api, target))) + .or_else(|| self.gossip.as_ref().map(|target| (DialSource::Gossip, target))) } } @@ -104,10 +204,11 @@ impl DialSources { struct DialEntry { handle: AbortHandle, sources: DialSources, + active: DialSource, unannounced_at: Option, } -/// Map of in-flight cluster dials, keyed by peer URL. Cloneable: the inner +/// Map of in-flight cluster dials, keyed by canonical peer identity. Cloneable: the inner /// map is shared via `Arc>` so the discovery task and the static-seed /// phase write to the same set of entries. #[derive(Clone, Default)] @@ -121,43 +222,80 @@ impl DialMap { self.inner.lock().expect("dial map poisoned").contains_key(peer) } - /// Record a freshly-spawned dial for `peer` under `source`. If `peer` is - /// already dialed, add `source` to its set and abort the redundant `handle` - /// (the existing dial stands, since dialing dedupes by URL). Always spawn the - /// task first, then call this: it resolves the "two sources discover the same - /// peer at once" race without leaking a task. - fn insert(&self, peer: String, handle: AbortHandle, source: DialSource) { + /// Record an already-spawned dial under `source`. If the source is redundant, + /// abort its task instead of leaking a second session. + fn insert(&self, target: DialTarget, handle: AbortHandle, source: DialSource) { + let mut handle = Some(handle); + self.upsert(target, source, &mut |_| handle.take().expect("dial handle used once")); + if let Some(handle) = handle { + handle.abort(); + } + } + + /// Add or update one source's target, replacing the live dial only when that + /// source already owns it. Other sources retain their latest target as a + /// fallback without changing the first source's active configuration. + fn upsert(&self, target: DialTarget, source: DialSource, spawn: &mut F) -> bool + where + F: FnMut(DialTarget) -> AbortHandle, + { let mut map = self.inner.lock().expect("dial map poisoned"); - if let Some(entry) = map.get_mut(&peer) { - entry.sources.set(source); - if source == DialSource::Gossip { - entry.unannounced_at = None; + if let Some(entry) = map.get_mut(&target.key) { + let replace = entry.active == source && entry.sources.get(source) != Some(&target); + entry.sources.set(source, target.clone()); + let reannounced = source == DialSource::Gossip && entry.unannounced_at.take().is_some(); + if replace { + entry.handle.abort(); + entry.handle = spawn(target); } - drop(map); - handle.abort(); - } else { - let mut sources = DialSources::default(); - sources.set(source); - map.insert( - peer, - DialEntry { - handle, - sources, - unannounced_at: None, - }, - ); + return reannounced; } + + let key = target.key.clone(); + let handle = spawn(target.clone()); + let mut sources = DialSources::default(); + sources.set(source, target); + map.insert( + key, + DialEntry { + handle, + sources, + active: source, + unannounced_at: None, + }, + ); + false } - /// Add `source` to an already-dialed `peer` (no-op if absent). Used when a - /// peer reached via one source is also discovered via another, without opening - /// a second dial. Adding [`DialSource::Gossip`] also clears any pending - /// unannounce; returns whether such a timestamp was cleared (a reannounce). - fn add_source(&self, peer: &str, source: DialSource) -> bool { + /// Release one source. If it owned the live dial, switch to a remaining + /// source's latest target or abandon the peer when no source remains. + fn release(&self, peer: &str, source: DialSource, spawn: &mut F) + where + F: FnMut(DialTarget) -> AbortHandle, + { let mut map = self.inner.lock().expect("dial map poisoned"); - let Some(entry) = map.get_mut(peer) else { return false }; - entry.sources.set(source); - source == DialSource::Gossip && entry.unannounced_at.take().is_some() + let Some(entry) = map.get_mut(peer) else { return }; + if entry.sources.get(source).is_none() { + return; + } + let active_target = entry.sources.get(source).cloned(); + entry.sources.clear(source); + if entry.active != source { + return; + } + + if let Some((next_source, next_target)) = entry.sources.fallback() { + let next_target = next_target.clone(); + entry.active = next_source; + if active_target.as_ref() != Some(&next_target) { + entry.handle.abort(); + entry.handle = spawn(next_target); + } + return; + } + + let entry = map.remove(peer).expect("entry exists"); + entry.handle.abort(); } /// Start the gossip stale timer on `peer` if it isn't already pending. No-op @@ -166,7 +304,7 @@ impl DialMap { fn mark_unannounced(&self, peer: &str, now: Instant) { let mut map = self.inner.lock().expect("dial map poisoned"); if let Some(entry) = map.get_mut(peer) - && entry.sources.gossip + && entry.sources.gossip.is_some() { entry.unannounced_at.get_or_insert(now); } @@ -174,60 +312,49 @@ impl DialMap { /// Release the gossip source from entries whose unannounce has stuck for at /// least `threshold`, aborting the dial only if no other source still wants it. - fn sweep_stale(&self, now: Instant, threshold: Duration) { + fn sweep_stale(&self, now: Instant, threshold: Duration, spawn: &mut F) + where + F: FnMut(DialTarget) -> AbortHandle, + { let mut map = self.inner.lock().expect("dial map poisoned"); - map.retain(|peer, entry| { - let Some(at) = entry.unannounced_at else { return true }; - if now.duration_since(at) < threshold { - return true; - } - entry.unannounced_at = None; - entry.sources.clear(DialSource::Gossip); - if entry.sources.any() { - tracing::debug!(%peer, "peer no longer gossiped; still wanted by another source"); - true - } else { - tracing::info!(%peer, "peer no longer gossiped; abandoning dial"); - entry.handle.abort(); - false - } - }); - } + let expired: Vec = map + .iter_mut() + .filter_map(|(peer, entry)| { + let at = entry.unannounced_at?; + (now.duration_since(at) >= threshold).then(|| { + entry.unannounced_at = None; + peer.clone() + }) + }) + .collect(); + drop(map); - /// Reconcile the API source against `desired`: release [`DialSource::Api`] from - /// entries no longer listed (aborting only those nothing else wants), mark the - /// API source on already-dialed peers that are listed, and return the desired - /// peers not yet dialed (the caller spawns those and re-inserts them). - fn reconcile_api(&self, desired: &HashSet) -> Vec { - let mut map = self.inner.lock().expect("dial map poisoned"); + for peer in expired { + self.release(&peer, DialSource::Gossip, spawn); + } + } - // One mutable pass: set the API source on listed peers, release it from the - // rest (aborting only those nothing else wants). - map.retain(|peer, entry| { - if desired.contains(peer) { - entry.sources.set(DialSource::Api); - return true; - } - if !entry.sources.api { - return true; - } - entry.sources.clear(DialSource::Api); - if entry.sources.any() { - tracing::debug!(%peer, "peer dropped from cluster-connect-api; still wanted by another source"); - true - } else { - tracing::info!(%peer, "peer dropped from cluster-connect-api; abandoning dial"); - entry.handle.abort(); - false - } - }); + /// Reconcile the API source against `desired`, including changes to a peer's + /// dial-affecting URL or link cost while its canonical identity stays fixed. + fn reconcile_api(&self, desired: &HashMap, mut spawn: F) + where + F: FnMut(DialTarget) -> AbortHandle, + { + for target in desired.values() { + self.upsert(target.clone(), DialSource::Api, &mut spawn); + } - // Whatever's left in `desired` but absent from the map needs a fresh dial. - desired + let removed: Vec = self + .inner + .lock() + .expect("dial map poisoned") .iter() - .filter(|peer| !map.contains_key(*peer)) - .cloned() - .collect() + .filter(|(peer, entry)| entry.sources.api.is_some() && !desired.contains_key(*peer)) + .map(|(peer, _)| peer.clone()) + .collect(); + for peer in removed { + self.release(&peer, DialSource::Api, &mut spawn); + } } } @@ -280,7 +407,7 @@ pub struct ClusterConfig { /// Fetch the list of peers to dial from an HTTP(S) URL or a local file, /// reloading at runtime without a restart. The source returns a JSON array - /// of peer hostnames: `["a.pop.example", "b.pop.example"]`. An http(s) URL is + /// of peer URLs: `["https://a.pop.example/?cost=1", "b.pop.example"]`. An http(s) URL is /// re-checked on a fixed cadence, with caching, conditional revalidation /// (`ETag` / `Last-Modified`), and stale-if-error handled by the shared HTTP /// cache client, so the response's `Cache-Control` controls how often a real @@ -325,8 +452,9 @@ pub struct ClusterConfig { /// JWT presented on outbound cluster dials, read from this file. Applied to /// any peer whose URL doesn't already carry a `?jwt=` (so it authenticates - /// gossip- and `connect_api`-discovered peers, whose addresses can't embed a - /// token). For static `--cluster-connect` peers, prefer an inline `?jwt=`. + /// any peer whose URL has no inline token). An inline `?jwt=` can provide a + /// per-peer credential for static or `connect_api` peers. Gossip should use + /// this shared token or mTLS because the advertised node URL is public. #[arg(id = "cluster-token", long = "cluster-token", env = "MOQ_CLUSTER_TOKEN")] pub token: Option, @@ -577,9 +705,8 @@ impl Cluster { } // Token presented on outbound dials whose URL doesn't already carry a - // `?jwt=`. This is how gossip- and connect_api-discovered peers (whose - // addresses can't carry an inline token) authenticate, so it isn't - // deprecated; for static `connect` peers, an inline `?jwt=` is preferred. + // `?jwt=`. This remains the shared credential for any peer without a + // per-peer inline token. let token = match &self.config.token { Some(path) => std::fs::read_to_string(path) .context("failed to read cluster token")? @@ -590,7 +717,7 @@ impl Cluster { // Static `--cluster-connect` peers and gossip-discovered peers share one // dial map so a peer reached via both paths only opens a single dial. - // Gossip-driven unannounces don't abort immediately — the discovery loop + // Gossip-driven unannounces don't abort immediately. The discovery loop // runs a periodic sweep that only aborts entries whose unannounce has // stuck for [`STALE_AFTER`]. That filters out the prefer-shorter-hop flap // (sub-millisecond unannounce-then-announce) while still cleaning up @@ -599,8 +726,8 @@ impl Cluster { let mut tasks = tokio::task::JoinSet::new(); for peer in &self.config.connect { - let key = canonicalize_peer_key(peer); - if dialed.contains(&key) { + let target = DialTarget::parse(peer).context("invalid --cluster-connect peer URL")?; + if dialed.contains(&target.key) { continue; } if is_legacy_peer(peer) { @@ -612,13 +739,9 @@ impl Cluster { } let this = self.clone(); let token = token.clone(); - let peer_for_task = peer.clone(); - let handle = tasks.spawn(async move { - if let Err(err) = this.run_remote(&peer_for_task, token).await { - tracing::warn!(%err, peer = %peer_for_task, "cluster peer connection ended"); - } - }); - dialed.insert(key, handle, DialSource::Static); + let peer_for_task = target.clone(); + let handle = tasks.spawn(this.supervise_remote(peer_for_task, token)); + dialed.insert(target, handle, DialSource::Static); } if let Some(source) = self.config.connect_api.clone() { @@ -696,6 +819,7 @@ impl Cluster { return; }; let mut announced = consumer.announced(); + let mut live = GossipTargets::default(); let mut sweep = tokio::time::interval(SWEEP_INTERVAL); sweep.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); @@ -709,41 +833,50 @@ impl Cluster { // The address to dial, which keeps its query: `run_remote` reads // `?cost=` and `?jwt=` off it. The key is only its identity. let peer = advertised_node_url(relative.as_str()); - let key = canonicalize_peer_key(&peer); + let target = match DialTarget::parse(&peer) { + Ok(target) => target, + Err(err) => { + tracing::warn!(%err, "invalid gossiped cluster peer URL; ignoring update"); + continue; + } + }; // Skip self and any peer we lose the tiebreaker to; that side // dials us instead, so each pair forms a single session. - if !should_dial(&self_url, &key) { + if !should_dial(&self_url, &target.key) { continue; } + let advertisement = relative.as_str().to_owned(); match broadcast { Some(_) => { - if dialed.contains(&key) { - // Already dialed (possibly via another source). Mark gossip as - // a wanter and cancel any pending stale-sweep. - if dialed.add_source(&key, DialSource::Gossip) { - tracing::debug!(peer = %key, "reannounce within sweep window; keeping dial"); - } - continue; + let target = live.announce(advertisement, target); + let mut spawn = |target: DialTarget| { + tracing::info!(peer = %target.key, "discovered cluster peer; dialing"); + tokio::spawn(self.clone().supervise_remote(target, token.clone())).abort_handle() + }; + let key = target.key.clone(); + if dialed.upsert(target, DialSource::Gossip, &mut spawn) { + tracing::debug!(peer = %key, "reannounce within sweep window; keeping dial"); } - tracing::info!(peer = %key, "discovered cluster peer; dialing"); - let this = self.clone(); - let token = token.clone(); - // Logged by key, never `peer`, so an inline jwt stays out of the logs. - let log_peer = key.clone(); - let handle = tokio::spawn(async move { - if let Err(err) = this.run_remote(&peer, token).await { - tracing::warn!(%err, peer = %log_peer, "cluster peer connection ended"); - } - }); - dialed.insert(key, handle.abort_handle(), DialSource::Gossip); - } - None => { - dialed.mark_unannounced(&key, Instant::now()); } + None => match live.unannounce(&advertisement, &target.key) { + GossipUpdate::Current(target) => { + let mut spawn = |target: DialTarget| { + tracing::info!(peer = %target.key, "cluster peer advertisement changed; redialing"); + tokio::spawn(self.clone().supervise_remote(target, token.clone())).abort_handle() + }; + dialed.upsert(target, DialSource::Gossip, &mut spawn); + } + GossipUpdate::Unchanged => {} + GossipUpdate::Gone => dialed.mark_unannounced(&target.key, Instant::now()), + }, } } _ = sweep.tick() => { - dialed.sweep_stale(Instant::now(), STALE_AFTER); + let mut spawn = |target: DialTarget| { + tracing::info!(peer = %target.key, "cluster peer source changed; redialing"); + tokio::spawn(self.clone().supervise_remote(target, token.clone())).abort_handle() + }; + dialed.sweep_stale(Instant::now(), STALE_AFTER, &mut spawn); } } } @@ -751,7 +884,7 @@ impl Cluster { /// Drive `--cluster-connect-api`: an http(s) URL is polled, a local path (or /// `file://` URL) is watched for changes. Either way the source yields a JSON - /// array of peer hostnames that's reconciled into the shared dial map. + /// array of peer URLs that's reconciled into the shared dial map. async fn run_connect_api(self, source: String, node: Option, token: String, dialed: DialMap) { match Url::parse(&source) { Ok(url) if matches!(url.scheme(), "http" | "https") => { @@ -867,50 +1000,45 @@ impl Cluster { .await .context("failed to read cluster.connect_api body")?; - serde_json::from_str(&body).context("cluster.connect_api response is not a JSON array of hostnames") + serde_json::from_str(&body).context("cluster.connect_api response is not a JSON array of peer URLs") } /// Reconcile a freshly fetched peer list into the dial map: dial peers that /// are new and drop API peers that disappeared. The relay's own [`node`] URL /// is filtered out so it never dials itself. fn apply_peer_list(&self, list: Vec, node: &Option, token: &str, dialed: &DialMap) { - // Dedupe against the shared dial map (and filter out self) on the canonical - // key, so an API entry matches the same peer reached via `connect`/gossip - // regardless of how each spells it. reconcile_api then yields canonical keys. - let self_key = node.as_deref().map(canonicalize_peer_key); - let desired: HashSet = list - .into_iter() - .map(|peer| canonicalize_peer_key(&peer)) - .filter(|key| Some(key) != self_key.as_ref()) - .collect(); + // Dedupe against the shared dial map (and filter out self) on stable identity, + // while retaining the full dial configuration so a cost or credential update + // replaces the existing session. + let desired = match parse_peer_list(list, node.as_deref()) { + Ok(desired) => desired, + Err(err) => { + tracing::warn!(%err, "invalid cluster.connect_api peer list; keeping current peers"); + return; + } + }; - for peer in dialed.reconcile_api(&desired) { - tracing::info!(%peer, "cluster.connect_api peer; dialing"); - let this = self.clone(); - let token = token.to_string(); - let peer_for_task = peer.clone(); - let handle = tokio::spawn(async move { - if let Err(err) = this.run_remote(&peer_for_task, token).await { - tracing::warn!(%err, peer = %peer_for_task, "cluster peer connection ended"); - } - }); - dialed.insert(peer, handle.abort_handle(), DialSource::Api); + dialed.reconcile_api(&desired, |target| { + tracing::info!(peer = %target.key, "cluster.connect_api peer; dialing"); + let handle = tokio::spawn(self.clone().supervise_remote(target, token.to_string())); + handle.abort_handle() + }); + } + + async fn supervise_remote(self, target: DialTarget, token: String) { + let log_peer = target.key.clone(); + if let Err(err) = self.run_remote(&target, token).await { + tracing::warn!(%err, peer = %log_peer, "cluster peer connection ended"); } } - #[tracing::instrument("remote", skip_all, err, fields(%remote))] - async fn run_remote(self, remote: &str, token: String) -> anyhow::Result<()> { - let mut url = peer_url(remote)?; - // The link's price, declared by us as the dialing side and charged to - // every announcement crossing the connection (see - // `moq_net::Client::with_cost`). Carried as a `?cost=` query - // param on the peer URL so static lists, gossip, and connect-api feeds can - // each price their links: 0 for a same-datacenter sibling, higher for a - // metered backbone. Stripped here; the value rides SETUP, not the URL. - let cost = take_cost(&mut url)?; + #[tracing::instrument("remote", skip_all, err, fields(remote = %target.key))] + async fn run_remote(self, target: &DialTarget, token: String) -> anyhow::Result<()> { + let mut url = target.url.clone(); + let cost = target.cost; // Apply the shared cluster token unless the URL already carries its own - // non-empty `?jwt=` (an inline token on a static `connect` peer wins; the - // shared token still covers discovered peers that have none). An empty + // non-empty `?jwt=` (a per-peer inline token wins; the shared token still + // covers peers that have none). An empty // `?jwt=` counts as absent, matching `AuthParams::from_url`. if !token.is_empty() && !url.query_pairs().any(|(key, value)| key == "jwt" && !value.is_empty()) { url.query_pairs_mut().append_pair("jwt", &token); @@ -1078,10 +1206,10 @@ fn peer_url(peer: &str) -> anyhow::Result { // A full URL has a scheme separator; a bare host or `host:port` does not // (and `Url::parse` would otherwise mis-read `host:port` as scheme `host`). if peer.contains("://") { - return Url::parse(peer).with_context(|| format!("invalid cluster peer URL: {peer}")); + return Url::parse(peer).context("invalid cluster peer URL"); } - Url::parse(&format!("https://{peer}/")).with_context(|| format!("invalid cluster peer host: {peer}")) + Url::parse(&format!("https://{peer}/")).context("invalid cluster peer host") } /// The address to dial for a node advertised under `MESH_PREFIX`. @@ -1248,16 +1376,64 @@ mod tests { tokio::spawn(std::future::pending::<()>()).abort_handle() } + fn target(key: &str) -> DialTarget { + DialTarget { + key: key.to_string(), + url: Url::parse(&format!("https://{key}/")).expect("test URL"), + cost: None, + } + } + + fn desired(keys: &[&str]) -> HashMap { + keys.iter().map(|key| ((*key).to_string(), target(key))).collect() + } + + /// A query change creates a new gossip path before the old path necessarily + /// unannounces. Removing that old path must not stale the replacement. + #[test] + fn gossip_old_unannounce_keeps_new_target() { + let mut live = GossipTargets::default(); + let old = DialTarget::parse("https://peer.example/?cost=1").unwrap(); + let new = DialTarget::parse("https://peer.example/?cost=2").unwrap(); + live.announce("peer.example/?cost=1".to_string(), old.clone()); + live.announce("peer.example/?cost=2".to_string(), new.clone()); + + assert!(matches!( + live.unannounce("peer.example/?cost=1", &old.key), + GossipUpdate::Unchanged + )); + assert!(matches!( + live.unannounce("peer.example/?cost=2", &new.key), + GossipUpdate::Gone + )); + } + + /// If the current gossip path disappears while an older one is still live, + /// the remaining target becomes current again. + #[test] + fn gossip_current_unannounce_restores_live_fallback() { + let mut live = GossipTargets::default(); + let old = DialTarget::parse("https://peer.example/?cost=1").unwrap(); + let new = DialTarget::parse("https://peer.example/?cost=2").unwrap(); + live.announce("peer.example/?cost=1".to_string(), old.clone()); + live.announce("peer.example/?cost=2".to_string(), new.clone()); + + let GossipUpdate::Current(current) = live.unannounce("peer.example/?cost=2", &new.key) else { + panic!("older live advertisement must become current"); + }; + assert!(current == old); + } + /// `mark_unannounced` is a no-op for static peers (operator intent says /// "always dial"), so the sweep never has a stale timestamp to act on. #[tokio::test] async fn sweep_preserves_static_peer() { let dialed = DialMap::default(); - dialed.insert("static-peer:4443".into(), placeholder_handle(), DialSource::Static); + dialed.insert(target("static-peer:4443"), placeholder_handle(), DialSource::Static); let long_ago = Instant::now() - Duration::from_secs(3600); dialed.mark_unannounced("static-peer:4443", long_ago); - dialed.sweep_stale(Instant::now(), STALE_AFTER); + dialed.sweep_stale(Instant::now(), STALE_AFTER, &mut |_| panic!("must not redial")); assert!(dialed.contains("static-peer:4443")); } @@ -1268,10 +1444,10 @@ mod tests { async fn sweep_evicts_stale_gossip_peer() { let dialed = DialMap::default(); let now = Instant::now(); - dialed.insert("gone:4443".into(), placeholder_handle(), DialSource::Gossip); + dialed.insert(target("gone:4443"), placeholder_handle(), DialSource::Gossip); dialed.mark_unannounced("gone:4443", now - STALE_AFTER - Duration::from_secs(1)); - dialed.sweep_stale(now, STALE_AFTER); + dialed.sweep_stale(now, STALE_AFTER, &mut |_| panic!("must not redial")); assert!(!dialed.contains("gone:4443")); } @@ -1284,10 +1460,10 @@ mod tests { async fn sweep_keeps_recently_unannounced_peer() { let dialed = DialMap::default(); let now = Instant::now(); - dialed.insert("flapping:4443".into(), placeholder_handle(), DialSource::Gossip); + dialed.insert(target("flapping:4443"), placeholder_handle(), DialSource::Gossip); dialed.mark_unannounced("flapping:4443", now - Duration::from_millis(50)); - dialed.sweep_stale(now, STALE_AFTER); + dialed.sweep_stale(now, STALE_AFTER, &mut |_| panic!("must not redial")); assert!(dialed.contains("flapping:4443")); } @@ -1296,10 +1472,10 @@ mod tests { #[tokio::test] async fn sweep_keeps_currently_announced_peer() { let dialed = DialMap::default(); - dialed.insert("healthy:4443".into(), placeholder_handle(), DialSource::Gossip); + dialed.insert(target("healthy:4443"), placeholder_handle(), DialSource::Gossip); // No mark_unannounced -> stays announced. - dialed.sweep_stale(Instant::now(), STALE_AFTER); + dialed.sweep_stale(Instant::now(), STALE_AFTER, &mut |_| panic!("must not redial")); assert!(dialed.contains("healthy:4443")); } @@ -1311,19 +1487,20 @@ mod tests { async fn reannounce_cancels_pending_sweep() { let dialed = DialMap::default(); let now = Instant::now(); - dialed.insert("flap:4443".into(), placeholder_handle(), DialSource::Gossip); + let target = target("flap:4443"); + dialed.insert(target.clone(), placeholder_handle(), DialSource::Gossip); dialed.mark_unannounced("flap:4443", now - STALE_AFTER - Duration::from_secs(1)); // Re-adding the gossip source (a reannounce) clears the pending sweep. assert!( - dialed.add_source("flap:4443", DialSource::Gossip), + dialed.upsert(target.clone(), DialSource::Gossip, &mut |_| panic!("must not redial")), "should report a cleared pending-sweep" ); - dialed.sweep_stale(now, STALE_AFTER); + dialed.sweep_stale(now, STALE_AFTER, &mut |_| panic!("must not redial")); assert!(dialed.contains("flap:4443")); // A second reannounce has nothing to clear. - assert!(!dialed.add_source("flap:4443", DialSource::Gossip)); + assert!(!dialed.upsert(target, DialSource::Gossip, &mut |_| panic!("must not redial"))); } /// A peer wanted by both gossip and the API survives losing either source: the @@ -1333,20 +1510,16 @@ mod tests { let dialed = DialMap::default(); let now = Instant::now(); // Gossiped first, then also appears in the API list. - dialed.insert("both:4443".into(), placeholder_handle(), DialSource::Gossip); - let desired: HashSet = ["both:4443".to_string()].into_iter().collect(); - assert!( - dialed.reconcile_api(&desired).is_empty(), - "already dialed; no new spawn" - ); + dialed.insert(target("both:4443"), placeholder_handle(), DialSource::Gossip); + dialed.reconcile_api(&desired(&["both:4443"]), |_| panic!("already dialed")); // Dropped from the API list -> still wanted by gossip. - assert!(dialed.reconcile_api(&HashSet::new()).is_empty()); + dialed.reconcile_api(&HashMap::new(), |_| panic!("gossip dial stays active")); assert!(dialed.contains("both:4443"), "gossip still wants it"); // Now gossip goes stale too -> the dial is finally released. dialed.mark_unannounced("both:4443", now - STALE_AFTER - Duration::from_secs(1)); - dialed.sweep_stale(now, STALE_AFTER); + dialed.sweep_stale(now, STALE_AFTER, &mut |_| panic!("must not redial")); assert!(!dialed.contains("both:4443")); } @@ -1355,11 +1528,11 @@ mod tests { #[tokio::test] async fn insert_merges_redundant_dial() { let dialed = DialMap::default(); - dialed.insert("p:4443".into(), placeholder_handle(), DialSource::Gossip); - dialed.insert("p:4443".into(), placeholder_handle(), DialSource::Api); + dialed.insert(target("p:4443"), placeholder_handle(), DialSource::Gossip); + dialed.insert(target("p:4443"), placeholder_handle(), DialSource::Api); // Dropping the API source leaves the gossip source holding the dial. - assert!(dialed.reconcile_api(&HashSet::new()).is_empty()); + dialed.reconcile_api(&HashMap::new(), |_| panic!("gossip dial stays active")); assert!(dialed.contains("p:4443"), "gossip source still holds the dial"); } @@ -1369,17 +1542,18 @@ mod tests { #[tokio::test] async fn reconcile_api_adds_and_removes_only_api() { let dialed = DialMap::default(); - dialed.insert("static:4443".into(), placeholder_handle(), DialSource::Static); - dialed.insert("gossip:4443".into(), placeholder_handle(), DialSource::Gossip); - dialed.insert("api-keep:4443".into(), placeholder_handle(), DialSource::Api); - dialed.insert("api-drop:4443".into(), placeholder_handle(), DialSource::Api); + dialed.insert(target("static:4443"), placeholder_handle(), DialSource::Static); + dialed.insert(target("gossip:4443"), placeholder_handle(), DialSource::Gossip); + dialed.insert(target("api-keep:4443"), placeholder_handle(), DialSource::Api); + dialed.insert(target("api-drop:4443"), placeholder_handle(), DialSource::Api); // Desired: keep one existing API peer, drop the other, add a new one. // Static/Gossip peers are not in the list but must survive. - let desired: HashSet = ["api-keep:4443".to_string(), "api-new:4443".to_string()] - .into_iter() - .collect(); - let mut to_add = dialed.reconcile_api(&desired); + let mut to_add = Vec::new(); + dialed.reconcile_api(&desired(&["api-keep:4443", "api-new:4443"]), |target| { + to_add.push(target.key); + placeholder_handle() + }); to_add.sort(); assert_eq!(to_add, vec!["api-new:4443".to_string()]); @@ -1394,14 +1568,171 @@ mod tests { #[tokio::test] async fn reconcile_api_dedupes_against_other_sources() { let dialed = DialMap::default(); - dialed.insert("shared:4443".into(), placeholder_handle(), DialSource::Static); + dialed.insert(target("shared:4443"), placeholder_handle(), DialSource::Static); - let desired: HashSet = ["shared:4443".to_string()].into_iter().collect(); - assert!(dialed.reconcile_api(&desired).is_empty()); + dialed.reconcile_api(&desired(&["shared:4443"]), |_| panic!("already dialed")); assert!(dialed.contains("shared:4443")); } - /// The peer-list wire format is a bare JSON array of host strings. + /// A secondary source can update its target without disturbing the source that + /// opened the session. If the active source disappears, its latest fallback + /// configuration is used for the replacement. + #[tokio::test] + async fn inactive_source_update_applies_on_takeover() { + let dialed = DialMap::default(); + let gossip = DialTarget::parse("https://peer.example/?cost=1").unwrap(); + let api = DialTarget::parse("https://peer.example/?cost=3").unwrap(); + let old_task = tokio::spawn(std::future::pending::<()>()); + dialed.insert(gossip.clone(), old_task.abort_handle(), DialSource::Gossip); + + let desired = [(api.key.clone(), api.clone())].into_iter().collect(); + dialed.reconcile_api(&desired, |_| panic!("inactive source must not redial")); + assert!(!old_task.is_finished()); + + let now = Instant::now(); + dialed.mark_unannounced(&gossip.key, now - STALE_AFTER - Duration::from_secs(1)); + let mut spawned = Vec::new(); + dialed.sweep_stale(now, STALE_AFTER, &mut |target| { + spawned.push(target); + placeholder_handle() + }); + + tokio::task::yield_now().await; + assert!(old_task.is_finished(), "old source must be aborted"); + assert_eq!(spawned.len(), 1); + assert!(spawned[0] == api); + } + + /// If the fallback source requests the same target, ownership transfers without + /// interrupting the healthy session. + #[tokio::test] + async fn identical_fallback_takeover_keeps_task() { + let dialed = DialMap::default(); + let target = DialTarget::parse("https://peer.example/?cost=1").unwrap(); + let task = tokio::spawn(std::future::pending::<()>()); + dialed.insert(target.clone(), task.abort_handle(), DialSource::Gossip); + + let desired = [(target.key.clone(), target.clone())].into_iter().collect(); + dialed.reconcile_api(&desired, |_| panic!("inactive source must not redial")); + + let now = Instant::now(); + dialed.mark_unannounced(&target.key, now - STALE_AFTER - Duration::from_secs(1)); + dialed.sweep_stale(now, STALE_AFTER, &mut |_| panic!("identical fallback must not redial")); + + assert!(!task.is_finished(), "healthy dial must be preserved"); + let map = dialed.inner.lock().expect("dial map"); + let entry = map.get(&target.key).expect("current dial"); + assert_eq!(entry.active, DialSource::Api); + assert!(entry.sources.get(entry.active) == Some(&target)); + drop(map); + task.abort(); + } + + /// A cost-only API update has the same identity but different SETUP input, so + /// it must replace the live task instead of being mistaken for an unchanged + /// peer. + #[tokio::test] + async fn reconcile_api_replaces_cost_only_change() { + let dialed = DialMap::default(); + let old = DialTarget::parse("https://peer.example/?cost=1").unwrap(); + let new = DialTarget::parse("https://peer.example/?cost=2").unwrap(); + assert_eq!(old.key, new.key); + assert!(old != new); + + let old_task = tokio::spawn(std::future::pending::<()>()); + dialed.insert(old, old_task.abort_handle(), DialSource::Api); + let desired = [(new.key.clone(), new.clone())].into_iter().collect(); + let mut spawned = Vec::new(); + dialed.reconcile_api(&desired, |target| { + spawned.push(target); + placeholder_handle() + }); + + tokio::task::yield_now().await; + assert!(old_task.is_finished(), "old dial must be aborted"); + assert_eq!(spawned.len(), 1); + assert!(spawned[0] == new); + } + + /// An identical API render keeps the live task, avoiding connection churn. + #[tokio::test] + async fn reconcile_api_identical_target_is_noop() { + let dialed = DialMap::default(); + let target = DialTarget::parse("https://peer.example/?cost=2&jwt=secret").unwrap(); + let task = tokio::spawn(std::future::pending::<()>()); + dialed.insert(target.clone(), task.abort_handle(), DialSource::Api); + let desired = [(target.key.clone(), target)].into_iter().collect(); + + dialed.reconcile_api(&desired, |_| panic!("identical target must not redial")); + assert!(!task.is_finished(), "live dial must be preserved"); + task.abort(); + } + + /// Inline credentials are dial-affecting even though they are excluded from + /// peer identity, so rotating one replaces the session too. + #[tokio::test] + async fn reconcile_api_replaces_inline_credential() { + let dialed = DialMap::default(); + let old = DialTarget::parse("https://peer.example/?jwt=old").unwrap(); + let new = DialTarget::parse("https://peer.example/?jwt=new").unwrap(); + assert_eq!(old.key, new.key); + + let old_task = tokio::spawn(std::future::pending::<()>()); + dialed.insert(old, old_task.abort_handle(), DialSource::Api); + let desired = [(new.key.clone(), new.clone())].into_iter().collect(); + let mut spawned = Vec::new(); + dialed.reconcile_api(&desired, |target| { + spawned.push(target); + placeholder_handle() + }); + + tokio::task::yield_now().await; + assert!(old_task.is_finished(), "old dial must be aborted"); + assert_eq!(spawned.len(), 1); + assert!(spawned[0] == new); + } + + /// A malformed replacement is rejected before reconciliation, so it cannot + /// tear down or reconfigure the last-known-good dial set. + #[tokio::test] + async fn malformed_peer_list_preserves_current_dial() { + let cluster = Cluster::new(ClusterConfig::default()).expect("cluster"); + let dialed = DialMap::default(); + let current = DialTarget::parse("https://peer.example/?cost=1").unwrap(); + let task = tokio::spawn(std::future::pending::<()>()); + dialed.insert(current.clone(), task.abort_handle(), DialSource::Api); + + cluster.apply_peer_list( + vec!["https://peer.example/?cost=invalid".to_string()], + &None, + "", + &dialed, + ); + + assert!(!task.is_finished(), "last-known-good dial must stay active"); + let map = dialed.inner.lock().expect("dial map"); + let entry = map.get(¤t.key).expect("current dial"); + assert!(entry.sources.get(entry.active) == Some(¤t)); + task.abort(); + } + + /// The same identity cannot appear twice with different SETUP inputs because + /// input ordering must not choose which configuration wins. + #[test] + fn peer_list_rejects_conflicting_duplicate() { + let Err(err) = parse_peer_list( + vec![ + "https://peer.example/?cost=1".to_string(), + "https://peer.example/?cost=2".to_string(), + ], + None, + ) else { + panic!("conflicting duplicate must fail"); + }; + assert!(format!("{err:#}").contains("conflicting configurations")); + } + + /// The peer-list wire format is a JSON array of URL strings. #[test] fn peer_list_parses_as_string_array() { let body = r#"["a.pop.example", "b.pop.example:4443"]"#; @@ -1565,6 +1896,14 @@ mod tests { assert!(!is_legacy_peer("https://cdn.example.com/?jwt=abc")); } + /// A malformed URL may contain a credential, so parse errors must not echo the + /// raw input into logs. + #[test] + fn peer_url_error_redacts_inline_credential() { + let err = peer_url("https://peer.example:bad/?jwt=top-secret").unwrap_err(); + assert!(!format!("{err:#}").contains("top-secret")); + } + /// What a discovering relay reads off `announced()` for this node. fn advertised(node: &str) -> String { Path::new(MESH_PREFIX) @@ -1584,7 +1923,7 @@ mod tests { "https://a.example/", "https://b.example:4443/", "tcp://c.example:4443", - "https://d.example/?jwt=abc", + "https://d.example/?cost=7", "https://e.example/deep/path", // Legacy bare forms, which carry no scheme and default to https. "rendezvous.example.com:4443", @@ -1604,13 +1943,13 @@ mod tests { /// every gossip-discovered link silently at the default cost. #[test] fn an_advertised_url_keeps_the_query_it_is_dialed_with() { - let dialed = advertised_node_url(&advertised("https://a.example/?cost=10&jwt=abc")); - assert_eq!(dialed, "https://a.example/?cost=10&jwt=abc"); + let dialed = advertised_node_url(&advertised("https://a.example/?cost=10")); + assert_eq!(dialed, "https://a.example/?cost=10"); // The value has to reach where `run_remote` reads it. let mut url = peer_url(&dialed).unwrap(); assert_eq!(take_cost(&mut url).unwrap(), Some(10)); - assert_eq!(url.as_str(), "https://a.example/?jwt=abc"); + assert_eq!(url.as_str(), "https://a.example/"); } /// The colon that ends a scheme is the only signal, so a bare `host:port` (whose @@ -1650,7 +1989,7 @@ mod tests { // A URL form and the legacy host:port form dedupe against each other. let dialed = DialMap::default(); dialed.insert( - canonicalize_peer_key("https://host:4443/?jwt=abc"), + DialTarget::parse("https://host:4443/?jwt=abc").unwrap(), placeholder_handle(), DialSource::Static, ); From 4ce12d5d9a8954f8b96792bfc5c6d8575384062e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 15 Aug 2026 17:55:23 -0700 Subject: [PATCH 12/12] fix(mux): filter escaping text renditions on the publisher side `retain_valid`/`retain_valid_media` came from main, which has no `text` catalog section; `text` is dev's captions work. Merging the two left the publisher-side containment filter covering video and audio only, while the consumer-side `check_resolvable` already covered all three. That is exactly the drift dev's own comment warns about: a section left out silently exempts its renditions from the check. Latent rather than live today, since no exporter consumes text renditions yet and the consumer rejects such a catalog outright. It would have surfaced as a silent hole the moment text export landed. Co-Authored-By: Claude Opus 5 --- rs/moq-mux/src/source.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/rs/moq-mux/src/source.rs b/rs/moq-mux/src/source.rs index 07d0d2bac0..c4ccfd8fc8 100644 --- a/rs/moq-mux/src/source.rs +++ b/rs/moq-mux/src/source.rs @@ -116,18 +116,23 @@ impl Source { } /// Remove renditions whose broadcast reference escapes above the origin root. + /// + /// Every section carrying renditions must be listed here; one left out silently exempts + /// its renditions from the containment check, exactly as on the consumer side. pub(crate) fn retain_valid( &self, catalog: &mut crate::catalog::hang::Catalog, ) { self.retain_valid_references("video", &mut catalog.video.renditions); self.retain_valid_references("audio", &mut catalog.audio.renditions); + self.retain_valid_references("text", &mut catalog.text.renditions); } /// Remove media renditions whose broadcast reference escapes above the origin root. pub(crate) fn retain_valid_media(&self, catalog: &mut hang::Catalog) { self.retain_valid_references("video", &mut catalog.video.renditions); self.retain_valid_references("audio", &mut catalog.audio.renditions); + self.retain_valid_references("text", &mut catalog.text.renditions); } fn retain_valid_references( @@ -202,6 +207,12 @@ impl BroadcastConfig for hang::catalog::AudioConfig { } } +impl BroadcastConfig for hang::catalog::TextConfig { + fn broadcast(&self) -> Option<&moq_net::PathRelativeOwned> { + self.broadcast.as_ref() + } +} + /// Test helper: serve `broadcast` on a throwaway origin's dynamic handler and return a /// [`Source`] rooted at it, so exporter tests that build a local broadcast can still resolve /// it by path. The origin is leaked so the broadcast stays reachable for the source's @@ -362,6 +373,28 @@ mod tests { assert!(catalog.video.renditions.contains_key("sibling")); } + /// The filter covers every section carrying renditions, not just the media ones. Text + /// is the section that only exists on one side of the containment check by default, so + /// it is the one that silently goes unchecked when the two sides drift. + #[test] + fn escaping_text_rendition_is_removed() { + let origin = Origin::random().produce(); + let source = Source::new(origin.consume(), "a/pub"); + + let mut escaped = hang::catalog::TextConfig::new(hang::catalog::TextFormat::Vtt); + escaped.broadcast = Some(PathRelative::new("../../source").to_owned()); + let mut sibling = escaped.clone(); + sibling.broadcast = Some(PathRelative::new("./source").to_owned()); + + let mut catalog = hang::Catalog::default(); + catalog.text.renditions.insert("escaped".to_string(), escaped); + catalog.text.renditions.insert("sibling".to_string(), sibling); + source.retain_valid_media(&mut catalog); + + assert!(!catalog.text.renditions.contains_key("escaped")); + assert!(catalog.text.renditions.contains_key("sibling")); + } + #[tokio::test] async fn subscribe_track_resolves_referenced_broadcast() { let origin = Origin::random().produce();