Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f76a9fc
quest: claim m0/request-caps
kixelated Oct 5, 2026
79c66bc
fix(net): bound lite message and fetch property lengths
kixelated Oct 5, 2026
b89297e
feat(net): cap announces and subscriptions per session, window IETF r…
kixelated Oct 5, 2026
b465cc3
test(net): cover the per-session caps and the request window
kixelated Oct 5, 2026
b2c0bd1
test(net): end-to-end request window refill and subscription cap
kixelated Oct 5, 2026
d4636ef
bench(net): request churn swept over sessions and held requests
kixelated Oct 5, 2026
7b66dd6
docs(net): document session limits; retire the request-caps quest
kixelated Oct 5, 2026
ef25972
chore(net): format, and retarget the subscriber drill mutation
kixelated Oct 5, 2026
761aca7
Merge remote-tracking branch 'origin/main' into quest/m0/request-caps
kixelated Oct 5, 2026
d8e3767
Merge remote-tracking branch 'origin/main' into quest/m0/request-caps
kixelated Oct 6, 2026
4b75965
docs(drafts): cap every lite message but FRAME at 64 KiB
kixelated Oct 6, 2026
76d27a0
fix(net): cap lite control messages at 65,535 bytes
kixelated Oct 7, 2026
a069d93
fix(net): preserve the legacy bulk announce message limit
kixelated Oct 7, 2026
336405b
chore: integrate main after Live removal
kixelated Oct 7, 2026
525b405
Merge remote-tracking branch 'origin/main' into merge-4820
kixelated Oct 7, 2026
3a3c12b
fix(net): format, document what a refused announce stream drops, unbl…
kixelated Oct 7, 2026
3afe557
feat(net)!: a peer past a session cap loses the session, and stats re…
kixelated Oct 7, 2026
a6e5d87
fix(stats): a tier change carries the cap slots a session still holds
kixelated Oct 7, 2026
f714789
Merge remote-tracking branch 'origin/main' into pr-4820
kixelated Oct 8, 2026
5589b89
docs: format the moq-net lib page
kixelated Oct 8, 2026
81cf865
fix(net): an update's own request ID counts against the window
kixelated Oct 8, 2026
849e8e5
Merge remote-tracking branch 'origin/main' into pr-4820
kixelated Oct 8, 2026
34920ce
docs(drafts): drop the hop-stamp changelog line #4969 removed
kixelated Oct 8, 2026
ec589de
Merge remote-tracking branch 'origin/main' into merge-4820
kixelated Oct 8, 2026
d9ba19c
Merge remote-tracking branch 'origin/main' into merge-4820
kixelated Oct 8, 2026
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
8 changes: 5 additions & 3 deletions doc/bin/relay/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,11 @@ and auth root, split by a **tier** label chosen by the auth server's grant or
`--cluster-tier`, which is what makes billing per customer or per region
possible. Each run, and each group returning after its linger, announces under
a fresh [epoch](/concept/moq-lite#publisher-epochs) on its route, so a restart
is a new broadcast at the same path.
[Stats](/concept/stats) describes the paths, tracks, and encodings;
read them with the [`moq-stats`](https://docs.rs/moq-stats) crate.
is a new broadcast at the same path. Each `sessions.json` row also carries
`announces_peak` and `subscriptions_peak`: the most any one session under that
root held against the per-session limits, past which a session is closed with
`TOO_MANY_REQUESTS`. [Stats](/concept/stats) describes the paths, tracks, and
encodings; read them with the [`moq-stats`](https://docs.rs/moq-stats) crate.

## \[iroh]

Expand Down
8 changes: 7 additions & 1 deletion doc/concept/stats.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,14 +129,20 @@ should prefer the canonical name and fall back to the legacy one.
### Presence

```json
{ "acme": { "sessions_started": 12, "sessions_ended": 10, "sessions": 12, "sessions_closed": 10 } }
{ "acme": { "sessions_started": 12, "sessions_ended": 10, "sessions": 12, "sessions_closed": 10, "announces_peak": 40, "subscriptions_peak": 900 } }
```

`sessions_started` and `sessions_ended` count connects and disconnects under an
auth root on the tier, whether or not any data flows. `sessions` and
`sessions_closed` are their legacy spellings. A session moved to a new tier
ends on the old one and starts on the new.

`announces_peak` and `subscriptions_peak` are the most broadcasts announced to
the relay, and subscriptions held on it, by any one session under the root.
Compare them with the relay's per-session limits: a session that goes past one
is closed with `TOO_MANY_REQUESTS`. A peak never goes down while the root has a
session, and summing nodes takes the largest.

### Counters

Every counter is a cumulative, monotonic unsigned integer. A rate is the
Expand Down
19 changes: 19 additions & 0 deletions doc/lib/rs/moq-net.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ received tracks cleanly after their delivered groups. A peer close ends
received tracks and open group readers with the session error, preserving its
close code.

### Session limits

`Client::with_limits` and `Server::with_limits` take a `session::Limits`, which
caps what the peer can make one session hold: `announces` (broadcasts, or
moq-transport namespaces) and `subscriptions`. The defaults (100,000 and
10,000) suit a relay mesh; lower them for untrusted peers. A peer that goes
past either cap loses the session, closed with `TOO_MANY_REQUESTS`. Each
auth root's stats `sessions` row reports `announces_peak` and
`subscriptions_peak`, the most any one session held, so an operator sees how
close sessions come. On moq-transport drafts 14 to 16 the limits also size the
`MAX_REQUEST_ID` window advertised in SETUP, granted back as requests close.
That window counts every request, FETCH and SUBSCRIBE\_UPDATE included, so very
low limits can starve it. A request ID past it also closes the session with
`TOO_MANY_REQUESTS`, as the draft requires.

Every length a peer declares is capped before it is buffered: 65,535 bytes for
a moq-lite control message (`ANNOUNCE_INIT` on lite 01 and 02 excepted) and
64 KiB for an object's property block.

`origin::Producer::new` returns a driver with the same `time::Driver`
interface. It calls `cache::Pool::gc(now)` after each poll and folds the next
cleanup time into its returned deadline. A standalone pool needs `gc(now)`
Expand Down
10 changes: 7 additions & 3 deletions drafts/draft-lcurley-moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,8 @@ Sent when terminating the session, via the transport's session close.
| ------- | ------------- | ----------- |
| 0x6 | KEY_VALUE_FORMATTING_ERROR | A key-value pair was malformed, or repeated more than allowed. |
| ------- | ------------- | ----------- |
| 0x7 | TOO_MANY_REQUESTS | The peer held more announcements or subscriptions than the endpoint allows. |
| ------- | ------------- | ----------- |
| 0x10 | GOAWAY_TIMEOUT | The peer did not close within the GOAWAY drain deadline. |
| ------- | ------------- | ----------- |
| 0x11 | CONTROL_MESSAGE_TIMEOUT | The peer took too long to respond to a control message. |
Expand Down Expand Up @@ -707,6 +709,7 @@ Most messages are prefixed with a variable-length integer indicating the number
This length field does not include the length of the varint length itself.

An implementation SHOULD close the connection with a PROTOCOL_VIOLATION if it receives a message with an unexpected length.
Except in FRAME, a Message Length over 65,535 bytes is unexpected: a sender MUST NOT exceed it, and a receiver MAY reject it based on the length prefix alone.
The version and extensions should be used to support new fields, not the message length.

## STREAM_TYPE {#stream_type}
Expand Down Expand Up @@ -741,7 +744,7 @@ Setup Parameter {
}
~~~

The Message Length MUST NOT exceed 65,536 bytes; a receiver MUST treat a longer SETUP as a protocol violation and MAY reject it based on the length prefix alone.
SETUP follows the [Message Length](#message-length) cap; a receiver MUST treat a longer SETUP as a protocol violation.

**Parameter Count**:
The number of Setup Parameters that follow.
Expand Down Expand Up @@ -1401,7 +1404,8 @@ The `Message Length` describes the payload size on the wire.
- The Subscribe Stream FIN now follows once every counted Group Stream has finished or been reset.
- Added the Spread Hash tie-break after the shortest path: a hash of the requested path and the route's Hop IDs, so equal-cost advertisers of one prefix share its paths.
- Added announce compression: ANNOUNCE_START gains `Path Base` and `Path Keep` to copy the head of a live advertisement's suffix, and ANNOUNCE_START and ANNOUNCE_UPDATE gain `Hop Base` and `Hop Keep` to copy the tail of a live advertisement's Hop ID list.
- Capped the SETUP Message Length at 65,536 bytes.
- Capped the Message Length of every message except FRAME at 65,535 bytes.
- Added the TOO_MANY_REQUESTS (0x7) session code, closing a session whose peer goes past the endpoint's bound on subscriptions or announcements.

## moq-lite-06

Expand Down Expand Up @@ -1576,7 +1580,7 @@ GOAWAY carries an optional New Session URI that asks the peer to reconnect elsew
Hop IDs (see [ANNOUNCE_OK](#announce-ok) and [ANNOUNCE_START](#announce-start)) expose the relay path of a broadcast, which may reveal internal topology. A relay that does not wish to disclose its position MAY use the reserved value 0 ("unknown") instead of a stable identifier, at the cost of losing loop detection through itself (see [Routing](#routing)). The Hop ID announcement filter (see [Hop Parameter](#hop-parameter)) exists for loop avoidance, not access control: a subscriber cannot verify that a publisher honored it, so it MUST NOT be relied upon to hide a broadcast from a peer that declared its Hop ID.

## Resource Exhaustion
A peer can open many streams (subscriptions, announcements, fetches), request large announce prefixes, or advertise broad routes. Implementations SHOULD bound the number of concurrent subscriptions, announce matches, and cached groups, and SHOULD rely on QUIC flow control and stream limits to backpressure a misbehaving peer (see [ANNOUNCE_REQUEST](#announce-request)). Expiration (see [Expiration](#expiration)) bounds how long stale groups consume memory and flow control. A broad route invites a request for any covered path, each of which may start work: an advertiser SHOULD bound the work it starts, withdrawing its route before it runs out, and every refusal is terminal, so a flood of requests costs the mesh one round trip each rather than a search (see [Resolution](#resolution)).
A peer can open many streams (subscriptions, announcements, fetches), request large announce prefixes, or advertise broad routes. Implementations SHOULD bound the number of concurrent subscriptions, announce matches, and cached groups; an endpoint whose peer goes past its bound on subscriptions or announcements SHOULD close the session with TOO_MANY_REQUESTS, and SHOULD rely on QUIC flow control and stream limits to backpressure a misbehaving peer (see [ANNOUNCE_REQUEST](#announce-request)). Expiration (see [Expiration](#expiration)) bounds how long stale groups consume memory and flow control. A broad route invites a request for any covered path, each of which may start work: an advertiser SHOULD bound the work it starts, withdrawing its route before it runs out, and every refusal is terminal, so a flood of requests costs the mesh one round trip each rather than a search (see [Resolution](#resolution)).

## Datagram Injection
Datagrams are routed to a subscription solely by Subscribe ID and carry no per-group authentication beyond that of the QUIC connection. On an unmodified QUIC/WebTransport connection this is sufficient, since datagrams are protected by the transport. A subscriber MUST silently drop any datagram with an unknown Subscribe ID and MUST deduplicate against groups received on streams (see [Datagrams](#datagrams)).
Expand Down
2 changes: 2 additions & 0 deletions js/net/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export const SessionCode = Object.freeze(
ProtocolViolation: 0x3 as SessionCode,
/** A key-value pair was malformed or repeated more than allowed. */
KeyValueFormatting: 0x6 as SessionCode,
/** The peer went past what the session allows: a request ID past MAX_REQUEST_ID, or too many announcements or subscriptions. */
TooManyRequests: 0x7 as SessionCode,
/** The peer did not close within the GOAWAY drain deadline. */
GoawayTimeout: 0x10 as SessionCode,
/** A control message took too long. */
Expand Down
7 changes: 5 additions & 2 deletions js/net/src/lite/announce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,14 +481,17 @@ export class AnnounceInit {
return new AnnounceInit(suffixes);
}

// The whole initial set in one message, so it scales with the publisher's broadcasts.
static readonly MAX_SIZE = 64 * 1024 * 1024;

async encode(w: Writer, version: Version): Promise<void> {
AnnounceInit.#guard(version);
return Message.encode(w, this.#encode.bind(this));
return Message.encode(w, this.#encode.bind(this), AnnounceInit.MAX_SIZE);
}

static async decode(r: Reader, version: Version): Promise<AnnounceInit> {
AnnounceInit.#guard(version);
return Message.decode(r, AnnounceInit.#decode);
return Message.decode(r, AnnounceInit.#decode, AnnounceInit.MAX_SIZE);
}
}

Expand Down
36 changes: 36 additions & 0 deletions js/net/src/lite/message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { expect, test } from "bun:test";
import { Reader, Writer } from "../stream.ts";
import { AnnounceInit } from "./announce.ts";
import { Subscribe } from "./subscribe.ts";
import { Version } from "./version.ts";

async function prefix(size: number, version: Version): Promise<Uint8Array> {
const written: Uint8Array[] = [];
const writer = new Writer(
new WritableStream<Uint8Array>({ write: (chunk) => void written.push(new Uint8Array(chunk)) }),
version,
);
await writer.u53(size);
writer.close();
await writer.closed;
return written[0] ?? new Uint8Array();
}

test("control messages past 65,535 bytes are refused at the length prefix", async () => {
const version = Version.DRAFT_05;
const wire = await prefix(65_536, version);
await expect(Subscribe.decode(new Reader(undefined, wire, version), version)).rejects.toThrow("too large");
});

test("ANNOUNCE_INIT keeps room for a large initial set", async () => {
const version = Version.DRAFT_02;
const wire = await prefix(1024 * 1024, version);
// Past the size check, so it fails waiting for a body that never arrives instead.
await expect(AnnounceInit.decode(new Reader(undefined, wire, version), version)).rejects.not.toThrow("too large");
});

test("control message length 65,535 passes the prefix check", async () => {
const version = Version.DRAFT_05;
const wire = await prefix(65_535, version);
await expect(Subscribe.decode(new Reader(undefined, wire, version), version)).rejects.not.toThrow("too large");
});
7 changes: 5 additions & 2 deletions js/net/src/lite/message.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Reader, Writer } from "../stream.ts";

/** The largest message body either side accepts, matching the Rust implementation. */
const MAX_SIZE = 64 * 1024 * 1024;
/**
* The largest message body either side accepts, matching the Rust implementation: the
* same ceiling as SETUP, checked at the length prefix before the body is buffered.
*/
const MAX_SIZE = 0xffff;

// Encodes a message with a varint size prefix, refusing a body the peer would reject.
export async function encode(writer: Writer, f: (w: Writer) => Promise<void>, max = MAX_SIZE) {
Expand Down
6 changes: 3 additions & 3 deletions js/net/src/lite/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,9 @@ test("SETUP rejects a path with malformed UTF-8", async () => {
await expect(decodeParam(PARAM_PATH, new Uint8Array([0xc3, 0x28]))).rejects.toThrow();
});

test("SETUP encode stops at the 64 KiB receive limit", async () => {
test("SETUP encode stops at the 65,535-byte receive limit", async () => {
// Count, id, and a 4-byte length varint precede the path.
const atLimit = 64 * 1024 - 6;
const atLimit = 65_535 - 6;
const msg = new Setup({ path: "a".repeat(atLimit) });
expect((await roundTrip(msg)).path).toBe(msg.path);

Expand All @@ -178,7 +178,7 @@ test("SETUP encode stops at the 64 KiB receive limit", async () => {

test("SETUP decode refuses an oversized length before reading the body", async () => {
// Only the prefix is present, so reading the body would fail with a different error.
const prefix = await bytes((w) => w.u53(64 * 1024 + 1), Version.DRAFT_05);
const prefix = await bytes((w) => w.u53(65_536), Version.DRAFT_05);
await expect(Setup.decode(new Reader(undefined, prefix, Version.DRAFT_05), Version.DRAFT_05)).rejects.toThrow(
"too large",
);
Expand Down
2 changes: 1 addition & 1 deletion js/net/src/lite/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const PARAM_HOP = 0x5n;
const MAX_PARAMS = 64;

/** SETUP only carries negotiation parameters, so it gets a far smaller cap than other messages. */
const MAX_SETUP_SIZE = 64 * 1024;
const MAX_SETUP_SIZE = 0xffff;

/**
* The probe capability an endpoint advertises in SETUP.
Expand Down
7 changes: 6 additions & 1 deletion quest/m0/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ quest, since nothing here waits on it. The [A/V clock](/quest/m1/av-clock.md)
moved to m1 in the 2026-09-30 audit: it waits on the whole jitter line and is
a published `@moq/watch` break.

Session caps: since #4820 a peer past a per-session cap loses the session, and
cluster links get the same 10,000-subscription default. The
[relay session limits](/quest/m0/relay-session-limits.md) quest gates the next
release (decided 2026-10-08), so a large mesh link can't flap on the cap.

## Required

- [Held group wakes](/quest/m0/held-group-wakes.md) - a group held across a route switch is woken when its successor's first timestamp or abort makes it stale, not at the next unrelated append
Expand All @@ -60,7 +65,7 @@ a published `@moq/watch` break.
- [End of Track Location](/quest/m0/ietf-end-of-track-location.md) - a relay forwards END_OF_TRACK at its upstream's Location, on the last group's stream, instead of re-sending it at the next group's object 0
- [End of Group status](/quest/m0/ietf-end-of-group-status.md) - an End of Group status on a stream whose header already marks the group's end is accepted, so imquic's last object per group arrives
- [Subgroup at object 0](/quest/m0/ietf-first-object-zero.md) - a draft-18 subgroup with FIRST_OBJECT clear that starts at object 0 is read whole
- [Request caps](/quest/m0/request-caps.md) - lite message sizes, IETF request IDs, and per-session announces and subscriptions are bounded
- [Relay session limits](/quest/m0/relay-session-limits.md) - moq-relay sets per-session request limits, tighter for clients than peers, and the bindings name a refused request
- [Prefix route fronts](/quest/m0/prefix-route-fronts.md) - a prefix route cannot be made to mint one front per requested path
- [TRACK stream demand](/quest/m0/track-stream-demand.md) - a lite-05+ subscribe shows the publisher one `used` edge, not a flap between TRACK and SUBSCRIBE
- [Idle fronts](/quest/m0/idle-fronts.md) - a front nobody has read for the linger ends with its per-path state, so a standing claim stops accumulating fronts and sources
Expand Down
6 changes: 1 addition & 5 deletions quest/m0/prefix-route-fronts.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,7 @@ not per viewer, so a single session can amplify it by naming paths.

- Decide the bound (per route, per session, or both) and what a refusal looks
like on each wire version; reuse the refusal shapes
[Request caps](/quest/m0/request-caps.md) settles.
that request caps (#4820) settled.
- Cover it with a test that requests many distinct covered paths and checks
the front count stays bounded, and extend the origin benchmark with a
distinct-path axis.

## Required

- [Request caps](/quest/m0/request-caps.md) - settles the refusal shapes reused here
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,19 @@ kind instead of mapping `TooManyRequests` to Unknown.

## Plan

Blocked until request caps (#4820) lands. That PR proposes generous
defaults in moq-net (100,000 announces and 10,000 subscriptions per session),
sized for relay meshes, so viewer sessions would get loose limits. Confirm
the landed API and defaults before implementing this quest. The relay knows
Request caps (#4820) set generous defaults in moq-net (100,000 announces and
10,000 subscriptions per session), sized for relay meshes, so viewer sessions
get loose limits. The relay knows
which sessions are peers, so it picks the limit per session. Propose the flag
and TOML names in the PR.

This gates the next release (decided 2026-10-08). Past a cap the session
closes with TOO_MANY_REQUESTS, so until cluster peers get higher caps (or
none), a link carrying more than 10,000 subscriptions closes and flaps on
reconnect.

Add the error kind to `rs/moq-ffi` and every wrapper per the Cross-Package
Sync table.

Public API: a relay config field and flag, and a new error kind in moq-ffi
and the bindings. Wire: none.

## Required

- [Request caps](/quest/m0/request-caps.md) - the session limits and TooManyRequests error exist on main
36 changes: 0 additions & 36 deletions quest/m0/request-caps.md

This file was deleted.

Loading
Loading