Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions doc/concept/moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
12 changes: 7 additions & 5 deletions drafts/draft-lcurley-moq-ietf.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
kixelated marked this conversation as resolved.
Resolved
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.
Expand Down Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions js/net/src/ietf/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 3 additions & 4 deletions js/net/src/ietf/publisher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
});
3 changes: 2 additions & 1 deletion js/net/src/ietf/publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
};
}

Expand Down
5 changes: 3 additions & 2 deletions js/net/src/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down
61 changes: 0 additions & 61 deletions quest/m0/ietf-max-age.md

This file was deleted.

60 changes: 48 additions & 12 deletions rs/moq-net/src/ietf/properties.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
kixelated marked this conversation as resolved.
Resolved
timescale: info.timescale,
priority: Some(super::priority::to_wire(info.priority)),
group_order: Some(GroupOrder::Descending),
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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() {
Comment thread
kixelated marked this conversation as resolved.
Resolved
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]
Expand Down
3 changes: 2 additions & 1 deletion rs/moq-net/src/model/track.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Duration>,
Expand Down
3 changes: 2 additions & 1 deletion rs/moq-net/src/test_interop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
13 changes: 4 additions & 9 deletions rs/moq-tokio/tests/integration/broadcast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1692,22 +1692,17 @@ async fn max_age_test(version: &str, published: Option<Duration>) -> Option<Dura

#[tokio::test]
async fn broadcast_max_age() {
for version in [
"moq-lite-05",
"moq-lite-06",
"moq-lite-07-wip",
"moq-transport-18",
"moq-transport-22",
] {
for version in ["moq-lite-05", "moq-lite-06", "moq-lite-07-wip"] {
for age in [None, Some(Duration::ZERO), Some(Duration::from_secs(30))] {
assert_eq!(max_age_test(version, age).await, age, "{version}");
}
}
}

#[tokio::test]
async fn broadcast_max_age_legacy_omits_property() {
for version in ["moq-lite-01"] {
async fn broadcast_max_age_omitted() {
// moq-transport's MAX_CACHE_DURATION would expire the newest group too, so it is never sent.
for version in ["moq-lite-01", "moq-transport-18", "moq-transport-22"] {
assert_eq!(
max_age_test(version, Some(Duration::from_secs(30))).await,
None,
Expand Down
2 changes: 2 additions & 0 deletions rs/moq-tokio/tests/integration/max_age.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ async fn relay(version: moq_net::Version, javascript: Option<bool>) -> 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:?}",
Expand Down
3 changes: 2 additions & 1 deletion test/max-age/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions test/max-age/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down