Skip to content

Commit 5a1d17c

Browse files
committed
Merge origin/dev into branch (resolve conflicts with main-merge work)
dev advanced 3 commits while this PR was open (#1845 PTS-exposing TS Export + PCR-paced SRT egress, #1847 moq-lite-05 wire sync, #1819 always-on hardware encoders). Re-merged dev and reconciled two conflicts: - nix/overlay.nix: kept this PR's cargo+sccache CI direction (dev's tip still carries the crane `moqChecks`; the cargo switch lives on main, #1821). - rs/moq-mux/src/container/ts/export.rs: combined dev's #1845 Frame-returning `Export::next` (PTS/keyframe-stamped) with this PR's generic `catalog::Catalog` trait (main's #1815 mpegts rename). Updated the moq-cli drain helper to read `frame.payload` and refreshed two stale `scte35::Ext` comments. Verified: moq-mux (279), moq-cli, moq-srt tests pass; workspace clippy -D warnings and fmt clean (excluding the crates that need libva/gstreamer system libs unavailable in this sandbox: moq-video/libmoq/moq-boy/moq-gst); JS type-checks and biome clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BQ9o9paZnRLpYRgbyjUwFV
2 parents 3fe1f9f + 87fe2ff commit 5a1d17c

49 files changed

Lines changed: 1432 additions & 273 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ lto = "fat"
108108
codegen-units = 1
109109
strip = true
110110

111-
# moq-video's `nvenc` feature needs a `dynamic-loading` mode on
111+
# moq-video's always-on NVENC backend (Linux) needs a `dynamic-loading` mode on
112112
# nvidia-video-codec-sdk so libnvidia-encode is dlopen'd at runtime instead of
113113
# linked: that lets a portable moq-cli build (and load) on machines without the
114114
# NVIDIA driver, and lets a GPU-less CI/release builder compile it. Upstream

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:

flake.nix

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,10 @@
107107
# cpal's `alsa-sys` (moq-audio `capture` feature) links libasound on
108108
# Linux via pkg-config; macOS uses CoreAudio, so no dep there.
109109
pkgs.alsa-lib
110-
# moq-video `vaapi` feature: the moq-vaapi crate (moq-dev/vaapi) vendors
111-
# the libva headers so bindgen needs no system libva, but libva is
112-
# dlopen'd at runtime, so this provides libva.so for actually running
113-
# vaapi encode in the devShell. macOS has no VAAPI.
110+
# moq-video's VAAPI backend (always-on for Linux): the moq-vaapi crate
111+
# (moq-dev/vaapi) vendors the libva headers so bindgen needs no system
112+
# libva, but libva is dlopen'd at runtime, so this provides libva.so for
113+
# actually running vaapi encode in the devShell. macOS has no VAAPI.
114114
pkgs.libva
115115
];
116116

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
}

0 commit comments

Comments
 (0)