Skip to content

Commit bc017a9

Browse files
kixelatedclaude
andcommitted
moq-lite-05: move immutable track props to a Track Stream (TRACK_INFO)
Replace the per-response publisher metadata in SUBSCRIBE_OK with a dedicated, on-demand Track Stream, per moq-dev/drafts#25. Scoped to the WIP Lite05 version; Lite01-04 keep SUBSCRIBE_OK unchanged. - New Track Stream (0x6): a TRACK request (broadcast path + track name) answered with a single TRACK_INFO carrying the immutable publisher properties (Priority, Ordered, Cache, Timescale, Compression), then a FIN (or reset on error / missing track). - Removed the static props (compression/timescale/cache) from SUBSCRIBE_OK; on Lite05 a subscription is accepted implicitly (rejection is a reset) and the publisher sends nothing on the subscribe stream. - Subscriber flights TRACK and SUBSCRIBE in parallel, so the first group still arrives in one round trip. A pending TrackEntry is inserted before SUBSCRIBE, so group streams that race ahead of TRACK_INFO park on a resolved channel (buffered by QUIC flow control) instead of being dropped. The resolved (producer, compression, timescale) is reused for every group's decode instead of being re-derived per response, and is fetched once for the upstream subscription's lifetime (linger). Publisher resolves TRACK_INFO by subscribing to read the track's .info and dropping the subscription; a parallel SUBSCRIBE coalesces onto the same upstream producer. Priority/Ordered are sent as 0/false for now since the model Track carries no publisher priority/order field yet. Not included (no functional gap in the Rust impl, which never resolves a group range or emits drops): SUBSCRIBE_START/SUBSCRIBE_END and the SUBSCRIBE_DROP renumber. Cross-package sync to js/net and doc/concept is also deferred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e453634 commit bc017a9

6 files changed

Lines changed: 435 additions & 213 deletions

File tree

rs/moq-net/src/lite/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ mod session;
1919
mod stream;
2020
mod subscribe;
2121
mod subscriber;
22+
mod track;
2223
mod version;
2324

2425
pub use announce::*;
@@ -37,4 +38,6 @@ pub(super) use session::*;
3738
pub use stream::*;
3839
pub use subscribe::*;
3940
use subscriber::*;
41+
#[allow(unused_imports)]
42+
pub use track::*;
4043
pub use version::Version;

rs/moq-net/src/lite/publisher.rs

Lines changed: 98 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ impl<S: web_transport_trait::Session> Publisher<S> {
6262
if let Err(err) = match kind {
6363
lite::ControlType::Announce => self.recv_announce(stream).await,
6464
lite::ControlType::Subscribe => self.recv_subscribe(stream).await,
65+
lite::ControlType::Track => self.recv_track(stream).await,
6566
lite::ControlType::Probe => {
6667
self.recv_probe(stream);
6768
Ok(())
@@ -455,8 +456,9 @@ impl<S: web_transport_trait::Session> Publisher<S> {
455456
.await?;
456457

457458
// Compress only when the producer marked the track worth it and the
458-
// negotiated draft understands the SUBSCRIBE_OK codec field. Older drafts
459-
// (lite-04 and below) get None and the frames stream verbatim.
459+
// negotiated draft understands the codec field (carried in SUBSCRIBE_OK on
460+
// lite-04 and below, in TRACK_INFO on lite-05+). Older drafts without it
461+
// get None and the frames stream verbatim.
460462
let supports_compression = !matches!(
461463
version,
462464
Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04
@@ -477,20 +479,23 @@ impl<S: web_transport_trait::Session> Publisher<S> {
477479
None
478480
};
479481

480-
let info = lite::SubscribeOk {
481-
priority: subscribe.priority,
482-
ordered: false,
483-
max_latency: std::time::Duration::ZERO,
484-
start_group: None,
485-
end_group: None,
486-
compression,
487-
timescale,
488-
// Announce the publisher's cache window so the subscriber (a relay)
489-
// re-serves with the same eviction window. Pre-lite-05 peers ignore it.
490-
cache: track.cache,
491-
};
482+
// Lite05+ accepts a subscription implicitly (rejection is a stream reset)
483+
// and serves the immutable properties over a TRACK_INFO stream instead.
484+
// Older drafts confirm acceptance with SUBSCRIBE_OK here.
485+
if matches!(
486+
version,
487+
Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04
488+
) {
489+
let info = lite::SubscribeOk {
490+
priority: subscribe.priority,
491+
ordered: false,
492+
max_latency: std::time::Duration::ZERO,
493+
start_group: None,
494+
end_group: None,
495+
};
492496

493-
stream.writer.encode(&lite::SubscribeResponse::Ok(info)).await?;
497+
stream.writer.encode(&lite::SubscribeResponse::Ok(info)).await?;
498+
}
494499

495500
// Track-level subscriber priority. SUBSCRIBE_UPDATE messages broadcast new values
496501
// to both run_track (so future groups inherit the new priority) and serve_group
@@ -528,6 +533,84 @@ impl<S: web_transport_trait::Session> Publisher<S> {
528533
stream.writer.finish()?;
529534
stream.writer.closed().await
530535
}
536+
537+
/// Serve a Track Stream: reply with the track's immutable [`lite::TrackInfo`]
538+
/// and FIN, or reset on error (e.g. the track does not exist). Lite05+ only.
539+
pub async fn recv_track(&mut self, mut stream: Stream<S, Version>) -> Result<(), Error> {
540+
let req = stream.reader.decode::<lite::Track>().await?;
541+
542+
let track = req.track.to_string();
543+
let absolute = self.origin.absolute(&req.broadcast).to_owned();
544+
545+
tracing::debug!(broadcast = %absolute, %track, "track info requested");
546+
547+
let broadcast = self.origin.get_broadcast(&req.broadcast);
548+
let version = self.version;
549+
550+
web_async::spawn(async move {
551+
if let Err(err) = Self::run_track_info(&mut stream, &track, broadcast, version).await {
552+
match &err {
553+
Error::Cancel | Error::Transport(_) => {
554+
tracing::debug!(broadcast = %absolute, %track, "track info cancelled")
555+
}
556+
err => tracing::warn!(broadcast = %absolute, %track, %err, "track info error"),
557+
}
558+
stream.writer.abort(&err);
559+
}
560+
});
561+
562+
Ok(())
563+
}
564+
565+
async fn run_track_info(
566+
stream: &mut Stream<S, Version>,
567+
track_name: &str,
568+
consumer: Option<BroadcastConsumer>,
569+
version: Version,
570+
) -> Result<(), Error> {
571+
let broadcast = consumer.ok_or(Error::NotFound)?;
572+
573+
// The immutable properties are delivered when the (possibly dynamic)
574+
// producer is accepted, so resolving them means subscribing. We read them
575+
// off the subscriber and drop it immediately; a SUBSCRIBE flighted in
576+
// parallel coalesces onto the same upstream producer, which linger keeps
577+
// alive across this brief gap.
578+
let track = broadcast
579+
.consume_track(track_name)
580+
.subscribe(crate::Subscription::default())
581+
.await?;
582+
583+
// Mirror the negotiation in `run_subscribe` so the subscriber decodes
584+
// frames the same way it'll see them served.
585+
let supports_compression = !matches!(
586+
version,
587+
Version::Lite01 | Version::Lite02 | Version::Lite03 | Version::Lite04
588+
);
589+
let compression = if track.compress && supports_compression {
590+
Compression::Deflate
591+
} else {
592+
Compression::None
593+
};
594+
let timescale = if version.has_timestamps() {
595+
track.timescale
596+
} else {
597+
None
598+
};
599+
600+
let info = lite::TrackInfo {
601+
// The model carries no publisher-chosen priority/order yet, so both
602+
// default to the tie-break-neutral values.
603+
priority: 0,
604+
ordered: false,
605+
cache: track.cache,
606+
timescale,
607+
compression,
608+
};
609+
610+
stream.writer.encode(&info).await?;
611+
stream.writer.finish()?;
612+
stream.writer.closed().await
613+
}
531614
}
532615

533616
/// Shared per-subscription state for the publisher side. Cloned (cheaply — every

rs/moq-net/src/lite/stream.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ pub enum ControlType {
1313
Fetch = 3,
1414
Probe = 4,
1515
Goaway = 5,
16+
/// Track Stream: a subscriber requests a track's immutable publisher
17+
/// properties (TRACK_INFO) without subscribing or fetching. Lite05+ only.
18+
Track = 6,
1619
}
1720

1821
impl Decode<Version> for ControlType {

rs/moq-net/src/lite/subscribe.rs

Lines changed: 18 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::borrow::Cow;
22

33
use crate::{
4-
Compression, Path, Timescale,
4+
Path,
55
coding::{Decode, DecodeError, Encode, EncodeError, Sizer},
66
};
77

@@ -72,28 +72,18 @@ impl Message for Subscribe<'_> {
7272
}
7373
}
7474

75+
/// Sent by the publisher to accept a subscription (Lite01-04 only).
76+
///
77+
/// On Lite05+ a subscription is accepted implicitly (rejection is a stream
78+
/// reset) and the immutable publisher properties moved to [`TrackInfo`], fetched
79+
/// once over a [Track Stream](super::Track). This message is no longer sent.
7580
#[derive(Clone, Debug)]
7681
pub struct SubscribeOk {
7782
pub priority: u8,
7883
pub ordered: bool,
7984
pub max_latency: std::time::Duration,
8085
pub start_group: Option<u64>,
8186
pub end_group: Option<u64>,
82-
/// Codec the publisher will use for every frame on this track. Negotiated
83-
/// here (not in SUBSCRIBE) so the subscriber blocks on this message before it
84-
/// can decode any frame payload. Lite05+ only; older drafts always get
85-
/// [`Compression::None`].
86-
pub compression: Compression,
87-
/// Per-frame timestamp scale advertised by the publisher. `None` means the
88-
/// publisher doesn't carry per-frame timestamps on the wire (so frame
89-
/// headers omit them). Lite05+ only; older drafts always decode as `None`.
90-
/// On the wire `None` is `0` and `Some(n)` is `n`.
91-
pub timescale: Option<Timescale>,
92-
/// How long the publisher keeps old groups available before evicting them.
93-
/// A relay re-serves with the same window and clamps each subscriber's stale
94-
/// preference to it. Lite05+ only; older drafts always get
95-
/// [`crate::DEFAULT_CACHE`].
96-
pub cache: std::time::Duration,
9787
}
9888

9989
impl Message for SubscribeOk {
@@ -103,23 +93,14 @@ impl Message for SubscribeOk {
10393
self.priority.encode(w, version)?;
10494
}
10595
Version::Lite02 => {}
106-
Version::Lite03 | Version::Lite04 => {
107-
self.priority.encode(w, version)?;
108-
(self.ordered as u8).encode(w, version)?;
109-
self.max_latency.encode(w, version)?;
110-
self.start_group.encode(w, version)?;
111-
self.end_group.encode(w, version)?;
112-
}
96+
// Lite05+ no longer sends SUBSCRIBE_OK, but keep the Lite03/04 layout as
97+
// the forward default so an accidental encode stays well-formed.
11398
_ => {
11499
self.priority.encode(w, version)?;
115100
(self.ordered as u8).encode(w, version)?;
116101
self.max_latency.encode(w, version)?;
117102
self.start_group.encode(w, version)?;
118103
self.end_group.encode(w, version)?;
119-
// Order matches draft-lcurley-moq-lite-05 SUBSCRIBE_OK: Timescale, Cache, Compression.
120-
self.timescale.map(u64::from).unwrap_or(0).encode(w, version)?;
121-
self.cache.encode(w, version)?;
122-
self.compression.to_code().encode(w, version)?;
123104
}
124105
}
125106

@@ -134,61 +115,21 @@ impl Message for SubscribeOk {
134115
max_latency: std::time::Duration::ZERO,
135116
start_group: None,
136117
end_group: None,
137-
compression: Compression::None,
138-
timescale: None,
139-
cache: crate::DEFAULT_CACHE,
140118
}),
141119
Version::Lite02 => Ok(Self {
142120
priority: 0,
143121
ordered: false,
144122
max_latency: std::time::Duration::ZERO,
145123
start_group: None,
146124
end_group: None,
147-
compression: Compression::None,
148-
timescale: None,
149-
cache: crate::DEFAULT_CACHE,
150125
}),
151-
Version::Lite03 | Version::Lite04 => {
152-
let priority = u8::decode(r, version)?;
153-
let ordered = u8::decode(r, version)? != 0;
154-
let max_latency = std::time::Duration::decode(r, version)?;
155-
let start_group = Option::<u64>::decode(r, version)?;
156-
let end_group = Option::<u64>::decode(r, version)?;
157-
158-
Ok(Self {
159-
priority,
160-
ordered,
161-
max_latency,
162-
start_group,
163-
end_group,
164-
compression: Compression::None,
165-
timescale: None,
166-
cache: crate::DEFAULT_CACHE,
167-
})
168-
}
169-
_ => {
170-
let priority = u8::decode(r, version)?;
171-
let ordered = u8::decode(r, version)? != 0;
172-
let max_latency = std::time::Duration::decode(r, version)?;
173-
let start_group = Option::<u64>::decode(r, version)?;
174-
let end_group = Option::<u64>::decode(r, version)?;
175-
// Order matches draft-lcurley-moq-lite-05 SUBSCRIBE_OK: Timescale, Cache, Compression.
176-
let timescale = Timescale::new(u64::decode(r, version)?).ok();
177-
let cache = std::time::Duration::decode(r, version)?;
178-
let compression =
179-
Compression::from_code(u64::decode(r, version)?).map_err(|_| DecodeError::InvalidValue)?;
180-
181-
Ok(Self {
182-
priority,
183-
ordered,
184-
max_latency,
185-
start_group,
186-
end_group,
187-
compression,
188-
timescale,
189-
cache,
190-
})
191-
}
126+
_ => Ok(Self {
127+
priority: u8::decode(r, version)?,
128+
ordered: u8::decode(r, version)? != 0,
129+
max_latency: std::time::Duration::decode(r, version)?,
130+
start_group: Option::<u64>::decode(r, version)?,
131+
end_group: Option::<u64>::decode(r, version)?,
132+
}),
192133
}
193134
}
194135
}
@@ -398,9 +339,6 @@ mod test {
398339
max_latency: Duration::from_millis(250),
399340
start_group: Some(3),
400341
end_group: None,
401-
compression: Compression::Deflate,
402-
timescale: Some(Timescale::MICRO),
403-
cache: Duration::from_secs(10),
404342
}
405343
}
406344

@@ -412,60 +350,12 @@ mod test {
412350
}
413351

414352
#[test]
415-
fn compression_roundtrips_on_lite05() {
416-
let got = roundtrip(Version::Lite05Wip, &sample());
417-
assert_eq!(got.compression, Compression::Deflate);
353+
fn fields_roundtrip_on_lite04() {
354+
let got = roundtrip(Version::Lite04, &sample());
418355
assert_eq!(got.priority, 7);
419356
assert!(got.ordered);
357+
assert_eq!(got.max_latency, Duration::from_millis(250));
420358
assert_eq!(got.start_group, Some(3));
421359
assert_eq!(got.end_group, None);
422360
}
423-
424-
#[test]
425-
fn compression_absent_before_lite05() {
426-
let ok = sample();
427-
428-
// The compression varint only exists on lite-05+, so the older encoding is
429-
// strictly shorter and always decodes back as None.
430-
let mut buf04 = Vec::new();
431-
ok.encode_msg(&mut buf04, Version::Lite04).unwrap();
432-
let mut buf05 = Vec::new();
433-
ok.encode_msg(&mut buf05, Version::Lite05Wip).unwrap();
434-
assert!(
435-
buf05.len() > buf04.len(),
436-
"lite-05 carries extra compression + timescale varints"
437-
);
438-
439-
assert_eq!(roundtrip(Version::Lite04, &ok).compression, Compression::None);
440-
}
441-
442-
#[test]
443-
fn timescale_roundtrips_on_lite05() {
444-
let got = roundtrip(Version::Lite05Wip, &sample());
445-
assert_eq!(got.timescale, Some(Timescale::MICRO));
446-
}
447-
448-
#[test]
449-
fn timescale_absent_before_lite05() {
450-
// Lite04 doesn't carry the timescale varint, so it always decodes as None.
451-
assert_eq!(roundtrip(Version::Lite04, &sample()).timescale, None);
452-
}
453-
454-
#[test]
455-
fn timescale_zero_on_wire_decodes_as_none() {
456-
let mut ok = sample();
457-
ok.timescale = None;
458-
assert_eq!(roundtrip(Version::Lite05Wip, &ok).timescale, None);
459-
}
460-
461-
#[test]
462-
fn cache_roundtrips_on_lite05() {
463-
assert_eq!(roundtrip(Version::Lite05Wip, &sample()).cache, Duration::from_secs(10));
464-
}
465-
466-
#[test]
467-
fn cache_absent_before_lite05() {
468-
// Lite04 doesn't carry the cache varint, so it always decodes as the default.
469-
assert_eq!(roundtrip(Version::Lite04, &sample()).cache, crate::DEFAULT_CACHE);
470-
}
471361
}

0 commit comments

Comments
 (0)