Skip to content

Commit cfa1d26

Browse files
kixelatedclaude
andauthored
moq-lite-05: sync wire with drafts HEAD (SETUP stream, broadcast epoch, drop cache hint, rename ANNOUNCE) (#1847)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent e3664b8 commit cfa1d26

28 files changed

Lines changed: 1215 additions & 129 deletions

doc/concept/layer/moq-lite.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ Here's a list of currently supported ALPNs:
5151
See the Compatibility section below for more details about `moq-transport` support.
5252

5353
Once the QUIC or WebTransport connection is established, there is a minimal MoQ handshake.
54-
The `SETUP` message is primarily used to negotiate extensions, then you're off to the races!
54+
Each endpoint sends a single `SETUP` message advertising its capabilities (for example whether it can probe the available bitrate), then you're off to the races.
55+
The two `SETUP` messages are independent, so neither side waits for the other before getting started.
56+
Transports that don't carry a request URI (native QUIC, or qmux over TCP/TLS) also use `SETUP` to carry the path the client wants to reach.
5557

5658
### Announcements
5759

@@ -67,6 +69,9 @@ The [moq-relay clustering](/bin/relay/cluster) feature actually uses this to dis
6769
The peer first replies with the set of broadcasts that are currently live, then streams updates as they change.
6870
This initial set is a discrete batch: the latest draft reports how many entries to expect up front, so a freshly connected session can wait until that snapshot has fully arrived before listing what's available, rather than racing the gossip.
6971

72+
Each broadcast also carries an **epoch** identifying its instance.
73+
When the same broadcast is announced over multiple routes (or republished after going away), the epoch lets everyone converge on the newest instance instead of picking arbitrarily.
74+
7075
### Subscriptions
7176

7277
All data transfers are initiated by subscriptions.
@@ -102,8 +107,8 @@ Each Subscription consists of a few properties:
102107
- **Group Order**: The order in which groups are delivered. Defaults to descending; higher IDs are delivered first.
103108
- **Group Timeout**: The maximum duration to keep old groups in cache/transit. Defaults to 30 seconds.
104109

105-
The publisher also caps how long it retains old groups via a per-track **cache** age, announced in `SUBSCRIBE_OK` so relays re-serve with the same window.
106-
A subscriber's Group Timeout can only be smaller than this cache age, since a group can't be waited for longer than it's kept around.
110+
The publisher also keeps old groups around for a best-effort **cache** window so relays and late subscribers can still fetch them.
111+
This is a local hint rather than a guarantee carried on the wire, and a subscriber's Group Timeout is bounded by it: a group can't be waited for longer than it's actually kept around.
107112

108113
By utilizing these properties, you can choose how your application behaves during congestion.
109114
For example, consider a conference room with Alice and Bob:

js/net/src/lite/announce.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { expect, test } from "bun:test";
2+
import * as Path from "../path.ts";
3+
import { Reader, Writer } from "../stream.ts";
4+
import { AnnounceBroadcast } from "./announce.ts";
5+
import { OriginSchema } from "./origin.ts";
6+
import { Version } from "./version.ts";
7+
8+
function concat(chunks: Uint8Array[]): Uint8Array {
9+
const total = chunks.reduce((sum, c) => sum + c.byteLength, 0);
10+
const out = new Uint8Array(total);
11+
let offset = 0;
12+
for (const c of chunks) {
13+
out.set(c, offset);
14+
offset += c.byteLength;
15+
}
16+
return out;
17+
}
18+
19+
async function bytes(f: (w: Writer) => Promise<void>): Promise<Uint8Array> {
20+
const written: Uint8Array[] = [];
21+
const writer = new Writer(
22+
new WritableStream<Uint8Array>({ write: (chunk) => void written.push(new Uint8Array(chunk)) }),
23+
);
24+
await f(writer);
25+
writer.close();
26+
await writer.closed;
27+
return concat(written);
28+
}
29+
30+
async function roundTrip(msg: AnnounceBroadcast, version: Version): Promise<AnnounceBroadcast> {
31+
const reader = new Reader(undefined, await bytes((w) => msg.encode(w, version)));
32+
return AnnounceBroadcast.decode(reader, version);
33+
}
34+
35+
test("AnnounceBroadcast epoch round-trips on draft-05", async () => {
36+
const hops = [OriginSchema.parse(7n)];
37+
// 1_700_000_000_000 is ~ms since 2020 in 2023; the others probe the edges of u53.
38+
for (const epoch of [0, 1, 1_700_000_000_000, 2 ** 52]) {
39+
const active = new AnnounceBroadcast({ suffix: Path.from("room/cam"), active: true, epoch, hops });
40+
const gotActive = await roundTrip(active, Version.DRAFT_05_WIP);
41+
expect(gotActive.active).toBe(true);
42+
expect(gotActive.epoch).toBe(epoch);
43+
expect(gotActive.suffix).toBe(Path.from("room/cam"));
44+
expect(gotActive.hops).toEqual(hops);
45+
46+
const ended = new AnnounceBroadcast({ suffix: Path.from("room/cam"), active: false, epoch });
47+
const gotEnded = await roundTrip(ended, Version.DRAFT_05_WIP);
48+
expect(gotEnded.active).toBe(false);
49+
expect(gotEnded.epoch).toBe(epoch);
50+
}
51+
});
52+
53+
test("AnnounceBroadcast epoch is omitted before draft-05", async () => {
54+
// Pre-lite-05 carries no epoch on the wire, so a nonzero epoch decodes back as 0.
55+
const msg = new AnnounceBroadcast({
56+
suffix: Path.from("room/cam"),
57+
active: true,
58+
epoch: 42,
59+
hops: [OriginSchema.parse(7n)],
60+
});
61+
const got = await roundTrip(msg, Version.DRAFT_04);
62+
expect(got.epoch).toBe(0);
63+
expect(got.suffix).toBe(Path.from("room/cam"));
64+
});

js/net/src/lite/announce.ts

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,49 @@ import * as Path from "../path.ts";
22
import type { Reader, Writer } from "../stream.ts";
33
import * as Message from "./message.ts";
44
import { type Origin, OriginSchema } from "./origin.ts";
5-
import { hopsFixedWidth, Version } from "./version.ts";
5+
import { hasBroadcastEpoch, hopsFixedWidth, Version } from "./version.ts";
66

77
// Must match the MAX_HOPS in Rust's model/origin.rs. Broadcasts with longer
88
// hop chains are rejected; this keeps loop-detection bounded and rejects
99
// pathological announcements across clusters with unbounded forwarding.
1010
export const MAX_HOPS = 32;
1111

12-
export class Announce {
12+
/**
13+
* Seconds between the Unix epoch and 2020-01-01T00:00:00 UTC.
14+
*
15+
* Broadcast epochs ride the wire as milliseconds since this base (smaller than a
16+
* Unix-epoch value, and good past the year 2500 in a varint). See {@link epochNow}.
17+
*/
18+
export const EPOCH_BASE_SECONDS = 1_577_836_800;
19+
20+
/**
21+
* The current wall clock as a broadcast epoch: whole milliseconds since
22+
* 2020-01-01 UTC (the wire value). Saturates to `0` for a clock before the base.
23+
*/
24+
export function epochNow(): number {
25+
return Math.max(0, Math.floor(Date.now() - EPOCH_BASE_SECONDS * 1000));
26+
}
27+
28+
/**
29+
* ANNOUNCE_BROADCAST: sent by the publisher to advertise (or retract) a broadcast.
30+
*
31+
* Carries the broadcast path suffix, its instance {@link epoch} (lite-05+), and the
32+
* hop chain. Renamed from `Announce` in lite-05.
33+
*/
34+
export class AnnounceBroadcast {
1335
suffix: Path.Valid;
1436
active: boolean;
37+
/**
38+
* Broadcast instance epoch: milliseconds since 2020-01-01 UTC (see {@link epochNow}).
39+
* Only carried on the wire for lite-05+; `0` on older versions.
40+
*/
41+
epoch: number;
1542
hops: Origin[];
1643

17-
constructor(props: { suffix: Path.Valid; active: boolean; hops?: Origin[] }) {
44+
constructor(props: { suffix: Path.Valid; active: boolean; epoch?: number; hops?: Origin[] }) {
1845
this.suffix = props.suffix;
1946
this.active = props.active;
47+
this.epoch = props.epoch ?? 0;
2048
this.hops = props.hops ?? [];
2149
if (this.hops.length > MAX_HOPS) {
2250
throw new Error(`hop count ${this.hops.length} exceeds maximum ${MAX_HOPS}`);
@@ -27,6 +55,11 @@ export class Announce {
2755
await w.bool(this.active);
2856
await w.string(this.suffix);
2957

58+
// Lite05+: the epoch varint sits after the suffix and before the hop chain.
59+
if (hasBroadcastEpoch(version)) {
60+
await w.u53(this.epoch);
61+
}
62+
3063
switch (version) {
3164
case Version.DRAFT_01:
3265
case Version.DRAFT_02:
@@ -49,10 +82,13 @@ export class Announce {
4982
}
5083
}
5184

52-
static async #decode(r: Reader, version: Version): Promise<Announce> {
85+
static async #decode(r: Reader, version: Version): Promise<AnnounceBroadcast> {
5386
const active = await r.bool();
5487
const suffix = Path.from(await r.string());
5588

89+
// Lite05+ carries the epoch after the suffix; older versions default it to 0.
90+
const epoch = hasBroadcastEpoch(version) ? await r.u53() : 0;
91+
5692
let hops: Origin[] = [];
5793
switch (version) {
5894
case Version.DRAFT_01:
@@ -80,23 +116,27 @@ export class Announce {
80116
}
81117
}
82118

83-
return new Announce({ suffix, active, hops });
119+
return new AnnounceBroadcast({ suffix, active, epoch, hops });
84120
}
85121

86122
async encode(w: Writer, version: Version): Promise<void> {
87123
return Message.encode(w, (w) => this.#encode(w, version));
88124
}
89125

90-
static async decode(r: Reader, version: Version): Promise<Announce> {
91-
return Message.decode(r, (r) => Announce.#decode(r, version));
126+
static async decode(r: Reader, version: Version): Promise<AnnounceBroadcast> {
127+
return Message.decode(r, (r) => AnnounceBroadcast.#decode(r, version));
92128
}
93129

94-
static async decodeMaybe(r: Reader, version: Version): Promise<Announce | undefined> {
95-
return Message.decodeMaybe(r, (r) => Announce.#decode(r, version));
130+
static async decodeMaybe(r: Reader, version: Version): Promise<AnnounceBroadcast | undefined> {
131+
return Message.decodeMaybe(r, (r) => AnnounceBroadcast.#decode(r, version));
96132
}
97133
}
98134

99-
export class AnnounceInterest {
135+
/**
136+
* ANNOUNCE_REQUEST: sent by the subscriber to request ANNOUNCE_BROADCAST messages
137+
* for a path prefix. Renamed from `AnnounceInterest` in lite-05.
138+
*/
139+
export class AnnounceRequest {
100140
prefix: Path.Valid;
101141
// Hop ID of the peer asking for announces. Zero means "no exclusion".
102142
// Must be a bigint: peer origins are up to 64 bits and overflow u53.
@@ -125,7 +165,7 @@ export class AnnounceInterest {
125165
}
126166
}
127167

128-
static async #decode(r: Reader, version: Version): Promise<AnnounceInterest> {
168+
static async #decode(r: Reader, version: Version): Promise<AnnounceRequest> {
129169
const prefix = Path.from(await r.string());
130170
let excludeHop = 0n;
131171
switch (version) {
@@ -137,15 +177,15 @@ export class AnnounceInterest {
137177
excludeHop = hopsFixedWidth(version) ? await r.u64() : await r.u62();
138178
break;
139179
}
140-
return new AnnounceInterest(prefix, excludeHop);
180+
return new AnnounceRequest(prefix, excludeHop);
141181
}
142182

143183
async encode(w: Writer, version: Version): Promise<void> {
144184
return Message.encode(w, (w) => this.#encode(w, version));
145185
}
146186

147-
static async decode(r: Reader, version: Version): Promise<AnnounceInterest> {
148-
return Message.decode(r, (r) => AnnounceInterest.#decode(r, version));
187+
static async decode(r: Reader, version: Version): Promise<AnnounceRequest> {
188+
return Message.decode(r, (r) => AnnounceRequest.#decode(r, version));
149189
}
150190
}
151191

js/net/src/lite/connection.ts

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,20 @@ import { type Bandwidth, createBandwidth } from "../bandwidth.ts";
44
import type { Broadcast } from "../broadcast.ts";
55
import type { Established } from "../connection/established.ts";
66
import * as Path from "../path.ts";
7-
import { type Reader, Readers, Stream } from "../stream.ts";
7+
import { type Reader, Readers, Stream, Writer } from "../stream.ts";
88
import type * as Time from "../time.ts";
9-
import { AnnounceInterest } from "./announce.ts";
9+
import { AnnounceRequest } from "./announce.ts";
1010
import { Goaway } from "./goaway.ts";
1111
import { Group } from "./group.ts";
1212
import { type Origin, randomOrigin } from "./origin.ts";
1313
import { Publisher } from "./publisher.ts";
1414
import { SessionInfo } from "./session.ts";
15-
import { StreamId } from "./stream.ts";
15+
import { ProbeLevel, Setup } from "./setup.ts";
16+
import { DataType, StreamId } from "./stream.ts";
1617
import { Subscribe } from "./subscribe.ts";
1718
import { Subscriber } from "./subscriber.ts";
1819
import { Track as TrackMessage } from "./track.ts";
19-
import { Version, versionName } from "./version.ts";
20+
import { hasSetupStream, Version, versionName } from "./version.ts";
2021

2122
const SEND_BW_POLL_INTERVAL = 100; // ms
2223

@@ -60,6 +61,11 @@ export class Connection implements Established {
6061
* chains) and Subscriber (available for optional self-filtering on announces). */
6162
readonly origin: Origin;
6263

64+
// The peer's SETUP, recorded once its Setup stream is read (lite-05+). Streams whose
65+
// encoding depends on a negotiated capability (e.g. PROBE) wait on this. undefined
66+
// until the peer's SETUP arrives; stays undefined forever on older drafts.
67+
#peerSetup = new Signal<Setup | undefined>(undefined);
68+
6369
/**
6470
* Creates a new Connection instance.
6571
* @param url - The URL of the connection
@@ -92,7 +98,14 @@ export class Connection implements Established {
9298

9399
this.origin = randomOrigin();
94100
this.#publisher = new Publisher(this.#quic, this.#version, this.origin);
95-
this.#subscriber = new Subscriber(this.#quic, this.#version, this.origin, this.recvBandwidth, this.rtt);
101+
this.#subscriber = new Subscriber(
102+
this.#quic,
103+
this.#version,
104+
this.origin,
105+
this.recvBandwidth,
106+
this.rtt,
107+
this.#peerSetup,
108+
);
96109

97110
this.#run();
98111
}
@@ -115,6 +128,10 @@ export class Connection implements Established {
115128
async #run(): Promise<void> {
116129
const tasks: Promise<void>[] = [this.#runSession(), this.#runBidis(), this.#runUnis()];
117130

131+
if (hasSetupStream(this.#version)) {
132+
tasks.push(this.#sendSetup());
133+
}
134+
118135
if (this.sendBandwidth) {
119136
tasks.push(this.#runSendBandwidth(this.sendBandwidth));
120137
}
@@ -159,6 +176,22 @@ export class Connection implements Established {
159176
}
160177
}
161178

179+
// Open the unidirectional Setup Stream, send our single SETUP, and FIN (lite-05+).
180+
// The browser uses WebTransport, which carries the request URI, so we advertise no
181+
// path and leave routing to the URL. We advertise probe = Report (we measure and
182+
// report bitrate over the PROBE stream, but don't actively pad the connection).
183+
async #sendSetup(): Promise<void> {
184+
const writer = await Writer.open(this.#quic);
185+
try {
186+
await writer.u8(DataType.Setup);
187+
await new Setup(ProbeLevel.Report).encode(writer, this.#version);
188+
writer.close();
189+
} catch (err: unknown) {
190+
writer.reset(err);
191+
throw err;
192+
}
193+
}
194+
162195
async #runBidis() {
163196
for (;;) {
164197
const stream = await Stream.accept(this.#quic);
@@ -180,7 +213,7 @@ export class Connection implements Established {
180213
if (typ === StreamId.Session) {
181214
throw new Error("duplicate session stream");
182215
} else if (typ === StreamId.Announce) {
183-
const msg = await AnnounceInterest.decode(stream.reader, this.#version);
216+
const msg = await AnnounceRequest.decode(stream.reader, this.#version);
184217
await this.#publisher.runAnnounce(msg, stream);
185218
} else if (typ === StreamId.Subscribe) {
186219
const msg = await Subscribe.decode(stream.reader, this.#version);
@@ -217,9 +250,14 @@ export class Connection implements Established {
217250

218251
async #runUni(stream: Reader) {
219252
const typ = await stream.u8();
220-
if (typ === 0) {
253+
if (typ === DataType.Group) {
221254
const msg = await Group.decode(stream);
222255
await this.#subscriber.runGroup(msg, stream);
256+
} else if (typ === DataType.Setup) {
257+
// The peer sends exactly one SETUP, then FINs. Record it so capability-gated
258+
// streams (e.g. PROBE) can react, then drain to the FIN.
259+
const setup = await Setup.decode(stream, this.#version);
260+
this.#peerSetup.set(setup);
223261
} else {
224262
throw new Error(`unknown stream type: ${typ.toString()}`);
225263
}

js/net/src/lite/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export * from "./goaway.ts";
55
export * from "./group.ts";
66
export * from "./probe.ts";
77
export * from "./session.ts";
8+
export * from "./setup.ts";
89
export * from "./stream.ts";
910
export * from "./subscribe.ts";
1011
export * from "./track.ts";

0 commit comments

Comments
 (0)