Skip to content
Merged
6 changes: 4 additions & 2 deletions doc/concept/moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,10 @@ scope member. The Rust consumer is a `Stream` and the TypeScript one an async
iterable. The Rust consumer also yields one `announce::Event::Live` once the
routes live at subscribe time have all been delivered, including those a peer
session was still sending: moq-lite-05+ counts them in `ANNOUNCE_OK`,
moq-lite-01/02 send them in `ANNOUNCE_INIT`, and older or IETF sessions wait
for the stream to go quiet.
moq-lite-01/02 send them in `ANNOUNCE_INIT`, IETF draft-16+ sessions count them
in `REQUEST_OK` when both sides speak
[active-count](/draft/moq-active-count), and anything else waits for the
stream to go quiet.

Announcements are hints; requests are the authority. When a subscriber asks
for a covered path the advertiser will not serve, the advertiser refuses that
Expand Down
4 changes: 3 additions & 1 deletion doc/concept/standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ tracks waiting for a group that never arrives.
Several project drafts extend the IETF wire without breaking it, since `SETUP`
ignores unknown parameters: [cluster](/draft/moq-cluster) routing hop lists,
[solicit](/draft/moq-solicit) to make announcements opt-in,
[hidden](/draft/moq-hidden) to keep `.`-named namespaces out of discovery, and
[hidden](/draft/moq-hidden) to keep `.`-named namespaces out of discovery,
[active-count](/draft/moq-active-count) to count the `NAMESPACE` messages
before a `SUBSCRIBE_NAMESPACE` is caught up, and
[probe](/draft/moq-probe) for bandwidth estimation.
[moq-e2ee](/draft/moq-e2ee) is not a transport extension: it encrypts application
payloads so relays still forward named tracks they cannot read.
Expand Down
122 changes: 122 additions & 0 deletions drafts/draft-lcurley-moq-active-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
---
title: "MoQ Active Count Extension"
abbrev: "moq-active-count"
category: info

docname: draft-lcurley-moq-active-count-latest
submissiontype: IETF # also: "independent", "editorial", "IAB", or "IRTF"
number:
date:
v: 3
area: wit
workgroup: moq

author:
-
fullname: Luke Curley
email: kixelated@gmail.com

normative:
moqt: I-D.ietf-moq-transport

informative:

--- abstract

This document defines an extension for MoQ Transport {{moqt}} that tells a subscriber how many NAMESPACE messages answering its SUBSCRIBE_NAMESPACE come before the first change.
The subscriber then knows when it has caught up, instead of guessing from a quiet stream.

--- note_Note_to_Readers

This document was generated by an AI model from the implementation at [github.com/moq-dev/moq](https://github.com/moq-dev/moq) and is maintained alongside it.
Submit an [issue](https://github.com/moq-dev/moq/issues) or [PR](https://github.com/moq-dev/moq/pulls) if this spec sucks and you want to fix anything.

--- middle

# Conventions and Definitions
{::boilerplate bcp14-tagged}

A suffix is **active** on a SUBSCRIBE_NAMESPACE stream from a NAMESPACE message carrying it until a NAMESPACE_DONE message carrying it.
A subscriber is **caught up** on a SUBSCRIBE_NAMESPACE once it has read Count ({{count}}) NAMESPACE messages after REQUEST_OK, immediately on a Count of 0.


# Introduction
A publisher answers SUBSCRIBE_NAMESPACE with a NAMESPACE message for each suffix it already advertises under the subscription's prefix, then keeps the stream open to report changes.
Nothing on the wire separates the two, so a subscriber cannot tell "there are no broadcasts" from "they have not arrived yet".
The best it can do is wait for the stream to go quiet, which is slow when there is nothing to send and wrong when the network stalls partway.

This extension has the publisher put on its REQUEST_OK how many NAMESPACE messages come before the first change.


# Setup Negotiation

An endpoint declares this extension with the following Setup Option ({{moqt}} Section 10.3):

~~~
ACTIVE_COUNT Setup Option {
Option Key (vi64) = 0x40B64
Option Value (vi64) = 1
}
~~~

A receiver MUST ignore the value.
The extension is negotiated when both endpoints declare the option, and applies to every SUBSCRIBE_NAMESPACE on the session in both directions.
An endpoint MUST NOT declare the option on a version of {{moqt}} without the NAMESPACE message.


# Counting {#count}

When the extension is negotiated, a publisher MUST include the following parameter in the REQUEST_OK answering a SUBSCRIBE_NAMESPACE:

~~~
ACTIVE_COUNT Parameter {
Type (vi64) = 0x40B66
Value (vi64) = Count
}
~~~

The publisher MUST send exactly Count NAMESPACE messages immediately after REQUEST_OK, before any other message on the stream, making active each suffix it advertises to the subscriber when it answers, one message per suffix; later changes follow as usual.
A suffix the publisher does not advertise to this subscriber, such as one whose path loops back through it, is neither sent nor counted.

A subscriber counts every NAMESPACE message toward Count, including any it discards on receipt.

A subscriber MUST close the session with a PROTOCOL_VIOLATION if the REQUEST_OK answering a SUBSCRIBE_NAMESPACE omits the parameter when the extension is negotiated, or carries it when it is not.
An endpoint MUST close the session with a PROTOCOL_VIOLATION if a REQUEST_OK answering any other request carries the parameter.


# Security Considerations

The count reveals no more than the NAMESPACE messages that follow it.
A publisher that promises more than it sends can only delay the subscriber's notion of having caught up, which it could equally do by withholding NAMESPACE messages.


# IANA Considerations

This document requests the following registrations.
High, distinctive values are requested to avoid the low ranges reserved by {{moqt}} and to minimize collisions with provisional registrations by other extensions.

## MOQT Setup Options

This document requests one registration in the "MOQT Setup Options" registry ({{moqt}} Section 15.4), whose policy is Specification Required.

| Value | Name | Reference |
|:--------|:----------------|:--------------|
| 0x40B64 | ACTIVE_COUNT | This Document |

## MOQT Message Parameters

This document requests one registration in the "MOQT Message Parameters" registry ({{moqt}} Section 15.7).

| Value | Name | Carried In | Reference |
|:--------|:----------------|:-----------|:--------------|
| 0x40B66 | ACTIVE_COUNT | REQUEST_OK | This Document |

Both values are even, so each is a bare varint.


--- back

# Acknowledgments
{:numbered="false"}

This document was drafted with the assistance of Claude, an AI assistant by Anthropic.
2 changes: 1 addition & 1 deletion quest/m1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ transport, benchmark tooling); worktrees isolate commits, not semantics.
- [Close codes](/quest/m1/close-codes.md) - a client sees the peer's application close code over WebSocket and raw QUIC, like WebTransport
- [JS caught up](/quest/m1/js-announce-caught-up.md) - @moq/net's announce consumer says when the initial set has landed, like Rust
- [Bindings caught up](/quest/m1/announce-live-bindings.md) - moq-ffi, moq-c, and every wrapper yield the same flat announce event, `Live` included
- [IETF announce count](/quest/m1/ietf-announce-count.md) - an opt-in moq-transport extension carries the replay count, so IETF announce consumers go live without a timer
- [JS active count](/quest/m1/js-active-count.md) - @moq/net speaks MoQ Active Count, so its IETF announce consumers go live without a timer

- [Publish delay](/quest/m1/publish-delay.md) - js/publish encoders advertise `delay` behind the earliest rendition, like moq-mux
- [Data jitter](/quest/m1/data-jitter.md) - JSON and binary tracks with a capture time advertise a detected `delay` and `jitter`
Expand Down
20 changes: 0 additions & 20 deletions quest/m1/ietf-announce-count.md

This file was deleted.

27 changes: 27 additions & 0 deletions quest/m1/js-active-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# [S] @moq/net counts an IETF namespace subscription until it is caught up

## Goal

`@moq/net` speaks the MoQ Active Count extension
(`drafts/draft-lcurley-moq-active-count.md`) on draft-16+, as `rs/moq-net`
does: it declares the ACTIVE_COUNT option, its publisher puts on the REQUEST_OK
answering SUBSCRIBE_NAMESPACE how many NAMESPACE messages come before the
subscription is caught up, and its subscriber lands that stream's source on
the count instead of the quiet timer.

## Plan

- Mirror `rs/moq-net/src/ietf/active_count.rs` in `js/net/src/ietf/`, like
`hidden.ts`: declare on draft-16+ only, and read the peer's declaration.
- Publisher: count what it advertises before writing REQUEST_OK, then send
exactly those NAMESPACE messages first.
- Subscriber: a missing count when negotiated, one when not, or one on any
other REQUEST_OK is a protocol violation, as in Rust.
- Extend the IETF cases of the JS caught-up tests to show the counted versions
land without waiting out the quiet gap.

Public API: none. Wire: the extension, already specified.

## Required

- [JS caught up](/quest/m1/js-announce-caught-up.md) - the per-source guard the count lands
2 changes: 2 additions & 0 deletions rs/moq-net/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ impl Client {
}
ietf::solicit::into_setup(&mut parameters, ietf_encoding);
ietf::hidden::into_setup(&mut parameters, ietf_encoding);
ietf::active_count::into_setup(&mut parameters, ietf_encoding);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Implement Active Count in js/net in this change

When a Rust endpoint connects to @moq/net, this declaration cannot become bilateral because the TypeScript setup and REQUEST_OK handling remain unchanged, so Rust-JS sessions still use the quiet-timer path instead of Active Count. The newly added quest/m1/js-active-count.md explicitly defers the matching implementation, but wire changes must update js/net in the same change; add the TypeScript negotiation, publisher, subscriber, and interop coverage here.

AGENTS.md reference: rs/moq-net/AGENTS.md:L5-L5

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring on purpose. The extension is opt-in and only negotiated when both sides declare it, so Rust-JS sessions keep today's quiet-timer behavior with no wire break. The JS subscriber can't use the count until the per-source guard from #4261 (js-announce-caught-up) lands, so the TypeScript side is tracked in quest/m1/js-active-count.md rather than half-implemented here.

(Written by Claude Opus 5.5)

let parameters = parameters.encode_bytes(ietf_encoding)?;

let client = setup::Client {
Expand Down Expand Up @@ -393,6 +394,7 @@ impl Client {
let peer_declared = ietf::peer::Peer {
solicit: ietf::solicit::from_setup(&parameters, v)?,
hidden: ietf::hidden::from_setup(&parameters, v),
active_count: ietf::active_count::from_setup(&parameters, v),
..Default::default()
};

Expand Down
52 changes: 52 additions & 0 deletions rs/moq-net/src/ietf/active_count.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//! The MoQ Active Count extension (draft-lcurley-moq-active-count-00).
//!
//! A publisher answers SUBSCRIBE_NAMESPACE with a NAMESPACE for every suffix it already
//! advertises, then reports changes on the same stream. Nothing in moq-transport says
//! where the first part ends, so a subscriber can only guess from the stream going
//! quiet. When both endpoints declare the ACTIVE_COUNT option, the publisher puts on its
//! REQUEST_OK how many NAMESPACE messages come before the subscription is caught up.

use super::Version;

/// ACTIVE_COUNT Setup Option: the sender counts the NAMESPACE messages before a
/// subscription is caught up, and reads the peer's count. Even, so the value is a bare varint.
pub const ACTIVE_COUNT: u64 = 0x40B64;

/// ACTIVE_COUNT REQUEST_OK parameter: how many NAMESPACE messages immediately follow
/// before the subscription is caught up. Only answers SUBSCRIBE_NAMESPACE.
/// Even, so the value is a bare varint.
pub const ACTIVE_COUNT_PARAM: u64 = 0x40B66;

/// Whether this version answers SUBSCRIBE_NAMESPACE with NAMESPACE messages. Draft-14/15
/// answer with PUBLISH_NAMESPACE requests instead, which the stream cannot count.
fn supported(version: Version) -> bool {
!matches!(version, Version::Draft14 | Version::Draft15)
}

/// Whether the peer declared the option. We always declare it, so this alone decides
/// whether the extension is negotiated.
pub fn from_setup(params: &super::Parameters, version: Version) -> bool {
supported(version) && params.get_varint(super::ParameterVarInt::ActiveCount).is_some()
}

/// Declare that we count and read the peer's count.
pub fn into_setup(params: &mut super::Parameters, version: Version) {
if supported(version) {
params.set_varint(super::ParameterVarInt::ActiveCount, 1);
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn only_versions_with_namespace_negotiate() {
for version in [Version::Draft14, Version::Draft15, Version::Draft16, Version::Draft22] {
let mut params = super::super::Parameters::default();
assert!(!from_setup(&params, version));
into_setup(&mut params, version);
assert_eq!(from_setup(&params, version), supported(version), "{version:?}");
}
}
}
1 change: 1 addition & 0 deletions rs/moq-net/src/ietf/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

#[macro_use]
mod parameters;
pub mod active_count;
mod adapter;
pub mod cluster;
mod control;
Expand Down
2 changes: 2 additions & 0 deletions rs/moq-net/src/ietf/parameters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ pub enum ParameterVarInt {
Solicit = super::solicit::SOLICIT,
/// HIDDEN, from the MoQ Hidden extension.
Hidden = super::hidden::HIDDEN,
/// ACTIVE_COUNT, from the MoQ Active Count extension.
ActiveCount = super::active_count::ACTIVE_COUNT,
#[num_enum(catch_all)]
Unknown(u64),
}
Expand Down
6 changes: 6 additions & 0 deletions rs/moq-net/src/ietf/peer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ pub(crate) struct Peer {
/// MoQ Hidden: whether the peer understands the HIDDEN parameter on
/// SUBSCRIBE_NAMESPACE, so we may send it.
pub hidden: bool,

/// MoQ Active Count: whether the REQUEST_OK answering SUBSCRIBE_NAMESPACE counts the
/// NAMESPACE messages before the subscription is caught up, in both directions.
pub active_count: bool,
}

/// Shared slot for [`Peer`], filled when the peer's SETUP is read.
Expand Down Expand Up @@ -78,6 +82,7 @@ mod tests {
},
solicit: None,
hidden: false,
active_count: false,
};

let slot = PeerSetup::default();
Expand All @@ -89,6 +94,7 @@ mod tests {
},
solicit: Some(true),
hidden: true,
active_count: true,
});

assert_eq!(slot.get().await, first);
Expand Down
Loading
Loading