diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index 9151120b0e5..8fe07c7a682 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -371,15 +371,16 @@ the groups it limits itself to, and ends once the track does. The publisher may declare a max age per track: how long a superseded group stays fresh. On the wire, omission sets no limit; zero makes a group stale as soon as -its successor arrives. Relays preserve that declaration, and may still cap it -lower under their own ceiling. The Rust and JS libraries give an authored track +its successor arrives. Relays preserve that declaration across moq-lite hops, and +may still cap it lower under their own ceiling. The Rust and JS libraries give an authored track zero, the live edge only, unless its publisher asks for more or for no limit; media tracks declare 30 seconds, so a segmented egress can still find its segments. A relay bounds what it keeps with its own cache, not the publisher's default. Max age ages a group by the same two clocks as max delay and never makes the newest group stale. A fetch obeys the same bound, and may name a tighter max delay of its own. -IETF carries the same idea as `MAX_CACHE_DURATION`, measured in wall time alone. +moq-transport drops it: its `MAX_CACHE_DURATION` would also expire the newest group, +so a hop past one keeps superseded groups only as long as its own cache allows. Put together, a conference might use: diff --git a/drafts/draft-lcurley-moq-ietf.md b/drafts/draft-lcurley-moq-ietf.md index bfb6fb929f8..f1d6514818c 100644 --- a/drafts/draft-lcurley-moq-ietf.md +++ b/drafts/draft-lcurley-moq-ietf.md @@ -184,13 +184,14 @@ A message MUST NOT carry more than 64 properties, nor any a relay interprets rat TRACK_INFO carries what a relay keeps of them, so a second copy could only contradict it. A message that breaks either rule is malformed. -A relay restates up to five of those from TRACK_INFO, so it forwards at most 59 properties, and it refuses a track with more, from a {{moqt}} publisher or over this extension, as unsupported. +A relay restates up to four of those from TRACK_INFO, never MAX_CACHE_DURATION, and it forwards at most 59 properties, and it refuses a track with more, from a {{moqt}} publisher or over this extension, as unsupported. Dropping those five can widen the delta that spans them, so a relay also refuses a {{moqt}} track whose remaining list would not fit a TRACK_PROPERTIES message beside its Version, as unsupported. -The Immutable Properties may still hold one of those five, which a relay forwards in place of the one it would restate. -A relay acts on TRACK_INFO, so a sealed value MUST equal the one it restates: MAX_CACHE_DURATION the Max Age in milliseconds, TIMESCALE the Timescale, DEFAULT_PUBLISHER_PRIORITY the priority, and DYNAMIC_GROUPS whether its Refresh is nonzero. -A sealed DEFAULT_PUBLISHER_GROUP_ORDER is advisory: a relay serves its own order and forwards the sealed value unchanged, whatever it says. -A relay refuses a track whose sealed value differs, from a {{moqt}} publisher or over this extension, as unsupported. +The Immutable Properties may still hold one of the properties a relay restates, which it forwards in place of the one it would restate. +A sealed MAX_CACHE_DURATION has no restated counterpart, so it travels beside none and is never compared. +A relay acts on TRACK_INFO, so a sealed TIMESCALE MUST equal the Timescale, DEFAULT_PUBLISHER_PRIORITY the priority, and DYNAMIC_GROUPS whether its Refresh is nonzero. +A sealed DEFAULT_PUBLISHER_GROUP_ORDER or MAX_CACHE_DURATION is advisory: a relay serves its own order and Max Age and forwards the sealed value unchanged, whatever it says. +A relay refuses a track whose sealed value breaks that rule, from a {{moqt}} publisher or over this extension, as unsupported. A relay neither processes nor forwards a track with a Mandatory Track Property (0x4000 to 0x7FFF), mutable or inside the Immutable Properties, so a publisher never sends one. A receiver refuses such a track as {{moqt}} ingress does, with an unsupported extension error. @@ -264,6 +265,7 @@ A high, distinctive value is used to avoid collisions with the low values {{moql ## Since draft-lcurley-moq-ietf-00 (in progress) +- A relay never restates the Max Age as MAX_CACHE_DURATION, and a sealed MAX_CACHE_DURATION is advisory. - Preserve relayed track timestamps and their unchecked MoQ Transport provenance across negotiated hops. Without the extension a relay serves them as native moq-lite timestamps rather than untimed. - Initial version. diff --git a/js/net/src/ietf/fetch.test.ts b/js/net/src/ietf/fetch.test.ts index e8c01b56566..4987e553afe 100644 --- a/js/net/src/ietf/fetch.test.ts +++ b/js/net/src/ietf/fetch.test.ts @@ -709,8 +709,8 @@ test("draft-18: a joining FETCH serves the head its subscription starts after", new FetchOk({ endOfTrack: false, endLocation: at(5, 3), - // The zero authored default max age, advertised as MAX_CACHE_DURATION. - properties: { timescale: Timescale.MILLI, priority: 127, groupOrder: 0x2, maxCacheDuration: 0n }, + // The max age is never advertised as MAX_CACHE_DURATION. + properties: { timescale: Timescale.MILLI, priority: 127, groupOrder: 0x2 }, }), ); expect(head.requestId).toBe(FETCH_ID); diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 9a0dd2f252f..13f69a59709 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -261,7 +261,6 @@ test("TRACK_STATUS is answered on every draft without subscribing", async () => timescale: Timescale.MILLI, priority: toWire(2), groupOrder: GroupOrder.Descending, - maxCacheDuration: 5000n, }; expect(ok.properties).toEqual(expected); origin.close(); @@ -4886,10 +4885,10 @@ for (const endObject of [BigInt(Number.MAX_SAFE_INTEGER), (1n << 60n) + 1n]) { }); } -test("a fractional max age reaches MAX_CACHE_DURATION as whole milliseconds rounded up", () => { +test("a max age is never sent as MAX_CACHE_DURATION", () => { const properties = trackProperties( - { compression: "none", timescale: null, maxAge: Duration.micros(1500), priority: 0, refresh: null }, + { compression: "none", timescale: null, maxAge: Duration.millis(7000), priority: 0, refresh: null }, true, ); - expect(properties.maxCacheDuration).toBe(2n); + expect(properties.maxCacheDuration).toBeUndefined(); }); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index 7d97fc3365e..6abbacca42c 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -3009,7 +3009,8 @@ export function trackProperties(info: Info, wanted: boolean): Properties.Propert priority: toWire(info.priority), // The publisher's default, newest first. groupOrder: Properties.DESCENDING, - maxCacheDuration: info.maxAge === null ? undefined : BigInt(Math.ceil(info.maxAge.asMillis())), + // No MAX_CACHE_DURATION: our max age expires only superseded groups, while that + // property expires every object, the newest included, after the given time. }; } diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 29c32a85502..98a074c24fa 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -1209,7 +1209,8 @@ test("integration: lite draft-07 track info follows a republished broadcast", as // An authored track omitting maxAge advertises the zero default, an explicit null advertises no // limit, which a consumer decodes as absence, and a number goes out as itself. Before lite-05 -// nothing is carried, and a received track without one has no publisher limit. +// nothing is carried, nor over moq-transport, whose MAX_CACHE_DURATION would expire the newest +// group too. A received track without one has no publisher limit. test.each([Lite.ALPN_03, Lite.ALPN_05, Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_22])( "integration: %s carries the authored max age default, null, and a value", async (alpn) => { @@ -1240,7 +1241,7 @@ test.each([Lite.ALPN_03, Lite.ALPN_05, Lite.ALPN_07_WIP, Ietf.ALPN.DRAFT_22])( sub.close(); } }; - const carried = alpn !== Lite.ALPN_03; + const carried = alpn !== Lite.ALPN_03 && alpn !== Ietf.ALPN.DRAFT_22; expect((await info("omitted")).maxAge).toEqual(carried ? Duration.ZERO : null); expect((await info("unlimited")).maxAge).toBeNull(); expect((await info("explicit")).maxAge).toEqual(carried ? Duration.millis(5000) : null); diff --git a/quest/m0/ietf-max-age.md b/quest/m0/ietf-max-age.md deleted file mode 100644 index ea0d322c4b3..00000000000 --- a/quest/m0/ietf-max-age.md +++ /dev/null @@ -1,61 +0,0 @@ -# [S] moq-transport stops sending our max age as MAX_CACHE_DURATION - -## Goal - -A late joiner reads the newest catalog, and the newest group of every other -track, through any draft-18 relay. Neither the Rust nor the JS moq-transport -encoder writes `MAX_CACHE_DURATION` for a track's max age. A received -`MAX_CACHE_DURATION` still decodes into `max_age`. - -## Plan - -Decided 2026-11-10 (maintainer, `/quest-import` of -[moq-dev/moq#8465](https://github.com/moq-dev/moq/issues/8465)): -✅ never send the property (rejected: omit it only when zero; a subscriber -joining FETCH whenever a duration is declared). - -Why: our max age expires only superseded groups, while draft 18 §12.3 -expires every object, the newest included, the given time after it arrives. -moq-transport has no way to say "the newest group never expires", so any -value, not only the zero that -[moq-dev/moq#7729](https://github.com/moq-dev/moq/pull/7729) made the -default, makes a conformant relay drop a long-lived group. moqx drops the -catalog at once, so a late joiner exports 0 bytes. Omitting the property -fixes third-party subscribers too, unlike a FETCH on our side, and adds no -IETF code after the [scope freeze](/quest/m2/ietf-scope/README.md). - -Cost, accepted: across a moq-transport hop between our own relays the max -age is lost, so the downstream relay keeps superseded groups as long as its -cache pool allows. - -The encode sites are `Properties::restated` and `Properties::encode` in -`rs/moq-net/src/ietf/properties.rs` and `maxCacheDuration` in -`js/net/src/ietf/publisher.ts`. Keep a property the publisher sealed inside -Immutable Properties forwarded as it was sent: `contradicts` in the same file -compares a sealed `MAX_CACHE_DURATION` against the restated one, so once -restated carries none, that arm would refuse such a track at every ingress. -Treat the sealed value as advisory, as `DEFAULT_PUBLISHER_GROUP_ORDER` -already is, and turn `a_sealed_max_cache_duration_matches_the_track` into a -test that it is forwarded rather than compared. Test it the way the issue -reproduced it: a subscriber that joins after the catalog's only group was -written still reads it through a relay that honors §12.3, if the interop -runner can run one, and otherwise with a unit test on what gets encoded. - -m0 because #7729 is on `main` but not released, so the next cut would ship -the regression. - -Caused by: [moq-dev/moq#7729](https://github.com/moq-dev/moq/pull/7729) - -its zero default reaches moq-transport peers as an immediate expiry of every -object. - -Public API: none. Wire: moq-transport SUBSCRIBE_OK, FETCH_OK, and PUBLISH no -longer carry `MAX_CACHE_DURATION` from us; compatible with every published -draft. - -## Closes - -- [#8465](https://github.com/moq-dev/moq/issues/8465) - a late joiner gets no catalog through a draft-18 relay - -## Related - -- [moq-transport is a compatibility layer again](/quest/m2/ietf-scope/README.md) - the scope freeze this stays within diff --git a/rs/moq-net/src/ietf/properties.rs b/rs/moq-net/src/ietf/properties.rs index 6a11f9f41a5..a8519fe40e6 100644 --- a/rs/moq-net/src/ietf/properties.rs +++ b/rs/moq-net/src/ietf/properties.rs @@ -84,9 +84,13 @@ impl Properties { /// The properties a relay states for a track it serves, from the `info` it acts on. /// /// We serve the newest group first by default, matching moq-lite. + /// + /// The max age is never stated: ours expires only superseded groups, while + /// MAX_CACHE_DURATION expires every object, the newest included, so any value would + /// make a conformant relay drop a long-lived group such as a catalog. pub(crate) fn restated(info: &crate::track::Info) -> Self { Self { - max_cache_duration: info.max_age, + max_cache_duration: None, timescale: info.timescale, priority: Some(super::priority::to_wire(info.priority)), group_order: Some(GroupOrder::Descending), @@ -360,11 +364,11 @@ impl TrackProperties { return true; } seen.into_iter().any(|id| match id { - MAX_CACHE_DURATION => sealed.max_cache_duration != restated.max_cache_duration, TIMESCALE => sealed.timescale != restated.timescale, DEFAULT_PUBLISHER_PRIORITY => sealed.priority != restated.priority, - // Advisory: a relay serves its own order and forwards the publisher's preference. - DEFAULT_PUBLISHER_GROUP_ORDER => false, + // Advisory: a relay serves its own order and cache lifetime, and forwards the + // publisher's values as sent. + MAX_CACHE_DURATION | DEFAULT_PUBLISHER_GROUP_ORDER => false, DYNAMIC_GROUPS => sealed.dynamic_groups != restated.dynamic_groups, super::flate::COMPRESSION => sealed.compress != restated.compress, _ => unreachable!("only the understood properties are seen"), @@ -751,15 +755,47 @@ mod tests { unknown.refused(&info) } - /// A sealed value of a property the relay restates must be the one it acts on, since - /// egress forwards the sealed one in its place. + /// A sealed MAX_CACHE_DURATION is advisory, forwarded as sent rather than compared. #[test] - fn a_sealed_max_cache_duration_matches_the_track() { - assert!(seal(MAX_CACHE_DURATION, 7000).is_none()); - assert!(matches!( - seal(MAX_CACHE_DURATION, 7001), - Some(crate::Error::Unsupported) - )); + fn a_sealed_max_cache_duration_is_forwarded() { + for value in [0, 7000, 7001] { + assert!(seal(MAX_CACHE_DURATION, value).is_none(), "{value}"); + } + + // Egress forwards the sealed block unchanged and restates no mutable duration beside it. + let version = Version::Draft22; + let mut sealed = Vec::new(); + let w = &mut Encoder::new(&mut sealed, version.into()); + w.varint(MAX_CACHE_DURATION).unwrap(); + w.varint(7000).unwrap(); + let unknown = vec![Property { + id: IMMUTABLE, + value: Value::Bytes(sealed.into()), + }]; + let info = crate::track::Info::default().with_max_age(Duration::from_secs(7)); + let egress = Properties { + unknown: unknown.clone(), + ..Properties::restated(&info) + }; + let mut buf = Vec::new(); + egress + .encode(&mut Encoder::new(&mut buf, version.into()), version) + .unwrap(); + let decoded = decode_draft22(&buf).unwrap(); + assert_eq!(decoded.max_cache_duration, Some(Duration::from_millis(7000))); + assert_eq!(decoded.unknown, unknown); + } + + /// A track's max age never reaches the wire, since MAX_CACHE_DURATION would expire + /// its newest group too. + #[test] + fn the_max_age_is_not_restated() { + let info = crate::track::Info::default().with_max_age(Duration::from_secs(7)); + let mut buf = Vec::new(); + Properties::restated(&info) + .encode(&mut Encoder::new(&mut buf, Version::Draft22.into()), Version::Draft22) + .unwrap(); + assert_eq!(decode_draft22(&buf).unwrap().max_cache_duration, None); } #[test] diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index c02e50adb9b..fd1cf4c18d5 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -138,7 +138,8 @@ pub struct Info { /// budget [`Delay::max`] sets for a subscriber. /// /// Encoded as milliseconds in a QUIC varint, so a duration of `2^62` milliseconds - /// or more cannot be put on lite-07 or IETF wires. Lite05/06 map values at or above + /// or more cannot be put on lite-07 wires. moq-transport egress never sends it, since + /// its `MAX_CACHE_DURATION` would also expire the newest group; a received one still sets it. Lite05/06 map values at or above /// `2^53 - 1` milliseconds to no limit for older JavaScript readers. Sub-millisecond precision is truncated /// (`Duration::as_millis`) at encode time. pub max_age: Option, diff --git a/rs/moq-net/src/test_interop.rs b/rs/moq-net/src/test_interop.rs index 63ad9475c2a..44a5104e7bb 100644 --- a/rs/moq-net/src/test_interop.rs +++ b/rs/moq-net/src/test_interop.rs @@ -670,7 +670,8 @@ fn ietf_track_status_interop() { unknown: Default::default(), dynamic_groups: false, compress: false, - max_cache_duration: Some(std::time::Duration::from_millis(5000)), + // The max age is never sent as MAX_CACHE_DURATION. + max_cache_duration: None, timescale: Some(crate::Timescale::MILLI), // The JS track's priority 2, lower-first on the wire as `127 - 2`. priority: Some(125), diff --git a/rs/moq-tokio/tests/integration/broadcast.rs b/rs/moq-tokio/tests/integration/broadcast.rs index 07eaf63b1ce..a2b0ed85649 100644 --- a/rs/moq-tokio/tests/integration/broadcast.rs +++ b/rs/moq-tokio/tests/integration/broadcast.rs @@ -1692,13 +1692,7 @@ async fn max_age_test(version: &str, published: Option) -> Option) -> anyhow::R let front = consumer.request("age", None).await?; for (i, age) in AGES.into_iter().enumerate() { let track = front.track(&i.to_string())?.subscribe(None).await?; + // moq-transport never sends MAX_CACHE_DURATION: it would expire the newest group too. + let age = if version.is_lite() { age } else { None }; anyhow::ensure!( track.info().max_age == age, "{version}: track {i} age {:?}, wanted {age:?}", diff --git a/test/max-age/README.md b/test/max-age/README.md index bde5522641d..a077cbd51ab 100644 --- a/test/max-age/README.md +++ b/test/max-age/README.md @@ -3,7 +3,8 @@ `just test max-age` runs real WebSocket/QMux sessions through a Rust origin relay. Rust-to-Rust, Rust-to-JavaScript, and JavaScript-to-Rust check unlimited (an explicit `None`/`null`, sent as an omitted wire value), zero, and 30-second publisher max ages on lite-07, IETF draft 18, and IETF draft 22. The relay -has a one-second local cache ceiling; received metadata must remain unchanged. +has a one-second local cache ceiling; the max age survives lite-07 unchanged and is +dropped across IETF, whose `MAX_CACHE_DURATION` would also expire the newest group. The JavaScript client uses Bun and the source checkout. Every listener binds an OS-assigned port. The interop workflow runs both tests; normal Rust CI runs the diff --git a/test/max-age/client.ts b/test/max-age/client.ts index 90c6c27584c..9c9a026f5dd 100644 --- a/test/max-age/client.ts +++ b/test/max-age/client.ts @@ -46,10 +46,11 @@ try { let broadcast = request.active.peek(); while (!broadcast) broadcast = await request.active.changed(); assert(broadcast); - for (const [i, age] of ages.entries()) { + for (const i of ages.keys()) { const track = broadcast.track(String(i)).subscribe(); - // A track received without a max age has no publisher limit. - assert.equal((await track.info()).maxAge?.asMillis(), age?.asMillis(), `${protocol} track ${i}`); + // moq-transport never sends MAX_CACHE_DURATION (it would expire the newest group too), + // so every track arrives without a publisher limit. + assert.equal((await track.info()).maxAge?.asMillis(), undefined, `${protocol} track ${i}`); track.close(); } request.close();