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
3 changes: 1 addition & 2 deletions doc/bin/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -400,8 +400,7 @@ rtsp` one per RTSP session, and `import ts --program all` one per program.
A restarted process takes the name at once: viewers see the broadcast restart,
and their next subscribe reaches the new run instead of waiting for its group
numbers to catch up. Epochs cross a connection only on moq-lite 07, which is
opt-in, and on moq-transport, and order by the host's clock
there, so a host whose clock runs behind the old run's waits for it to close.
opt-in, and order by the host's clock there, so a host whose clock runs behind the old run's waits for it to close.
Other versions carry no epoch, so a restart from another session is the newest
route at an equal cost, or a better one.

Expand Down
7 changes: 3 additions & 4 deletions doc/bin/relay/cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,9 @@ publishes under a new epoch, which replaces the old broadcast instead of
resuming it; the RTMP, SRT, and WHIP ingests mint one per connection. Epochs
order by creation time, so a reconnect to a different gateway assumes the two
gateways' clocks roughly agree. Seamless failover needs a publisher that
announces an epoch and, on every link the route crosses, moq-lite 07 or
moq-transport with the [epoch extension](/draft/moq-epoch). A route
without an epoch, including every route on moq-lite 06 and older or a
moq-transport link without the extension, keeps its subscriptions until it goes or a track fails
announces an epoch and, on every link the route crosses, moq-lite 07. A route
without an epoch, including every route on moq-lite 06 and older or
moq-transport, keeps its subscriptions until it goes or a track fails
upstream, and any other route winning it is announced as a restart. Viewers
re-request on a restart, and a relay drops its copy of the old instance,
including every path beneath a prefix claim when one of them moves to another
Expand Down
2 changes: 0 additions & 2 deletions doc/bin/relay/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ allow.uid = [1001]
# The relay always offers ietf, for moq-lite-07 relay links.
auth = false # Decline MoQ Auth.
solicit = false # Decline MoQ Solicit, so peers may announce unasked.
epoch = false # Decline MoQ Epoch, so routes over moq-transport carry no epoch.
```

`timeout` bounds the whole handshake, transport through MoQ SETUP and auth
Expand Down Expand Up @@ -328,7 +327,6 @@ goaway.redirect = "same-host" # How far to trust a draining peer's redire
goaway.handover = "10s" # Cap on how long the drained upstream keeps serving.
extensions.auth = false # Decline MoQ Auth on outbound moq-transport sessions.
extensions.solicit = false # Decline MoQ Solicit.
extensions.epoch = false # Decline MoQ Epoch. All default to on, as in [listen.extensions].
```

A draining upstream may name a replacement URI. `same-host` follows it only
Expand Down
12 changes: 5 additions & 7 deletions doc/concept/identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ path, track name, and group sequence, and a subscriber that loses its route
resumes by asking for the first frame it lacks. Both are only safe while every
source of a name serves the same bytes. This page is the contract publishers,
players, and relays share to keep that true across restarts, failover, and work
started on demand. Wire details live in the [moq-lite](/draft/moq-lite) and
[epoch](/draft/moq-epoch) drafts.
started on demand. Wire details live in the [moq-lite](/draft/moq-lite) draft.

## Identity is a path and an epoch

Expand Down Expand Up @@ -52,11 +51,10 @@ the ingest gateways do it for you; see [publisher runs](/bin/cli#publisher-runs)
## Routes with and without an epoch

An epoch reaches readers only over hops that carry it: moq-lite 07
(`moq-lite-07-wip`, opt-in) and moq-transport with the
[epoch extension](/draft/moq-epoch), which is on by default. Older moq-lite
versions and moq-transport without the extension drop it. The
[moq-lite](/draft/moq-lite) and [epoch](/draft/moq-epoch) drafts say how each
version signals another instance taking the path.
(`moq-lite-07-wip`, opt-in). Older moq-lite versions and moq-transport drop
it, so use moq-lite 07 on every hop where continuity matters. The
[moq-lite draft](/draft/moq-lite) says how each version signals another
instance taking the path.

A relay forwards an epoch unchanged and never invents one. Without one, no
route can prove it serves the same bytes as another, so any other route
Expand Down
12 changes: 3 additions & 9 deletions doc/concept/moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,8 @@ authorization and hidden names work exactly as without one.
The epoch and the restart and takeover messages travel on moq-lite 07, which is
opt-in. Older versions carry a takeover as an ordinary announcement, so the
first subscription through a claim is reset once the output's route arrives.
moq-transport carries the epoch when both sides offer the [epoch
extension](/draft/moq-epoch), on by default. A restart onto another epoch is
advertised before the old one is withdrawn, so the path is never absent, and one
that gains or loses an epoch is an end then a start. Older moq-lite versions
and a side that declines the extension carry no
epoch. Without an epoch, any other route winning is a restart, even a reconnect
over the same path, since nothing says it serves the same bytes. A peer that
moq-transport and older moq-lite versions carry no epoch. Without an epoch,
any other route winning is a restart, even a reconnect over the same path, since nothing says it serves the same bytes. A peer that
ignores the end and start may keep cached objects of the old run under the same
names, so an end and a start is not a cache flush.

Expand All @@ -172,8 +167,7 @@ compared. A subscription uses the preferred link that has a route and moves to t
next when that route goes; a publisher pushes the same broadcast to every CDN.

The move resumes without a seam only between routes with the same epoch, which
needs moq-lite 07, or moq-transport with the epoch extension,
on both links. A CDN link that carries no epoch makes moving onto it, or back
needs moq-lite 07 on both links. A CDN link that carries no epoch makes moving onto it, or back
off it, a restart, and the player requests the path again. Both links must
also speak the same protocol and draft: a copy's track info records the
moq-transport draft it crossed, and a copy whose track info differs is refused,
Expand Down
8 changes: 4 additions & 4 deletions doc/concept/standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,15 @@ ignores unknown parameters: [cluster](/draft/moq-cluster) routing hop lists,
[hidden](/draft/moq-hidden) to keep `.`-named namespaces out of discovery,
[auth](/draft/moq-auth) to tell each peer what it may publish and subscribe to,
[active-count](/draft/moq-active-count) to count the `NAMESPACE` messages
before a `SUBSCRIBE_NAMESPACE` is synced,
[epoch](/draft/moq-epoch) to carry [publisher epochs](/concept/moq-lite#publisher-epochs)
without changing a path, and
before a `SUBSCRIBE_NAMESPACE` is synced, and
[probe](/draft/moq-probe) for bandwidth estimation. Rust sessions request PROBE
only while an owned `recv_bandwidth()` consumer is held. Read estimates through
its `peek()` or `changed()` methods; session statistics do not request PROBE.
Both libraries offer solicit, auth, and epoch unless a side's
Both libraries offer solicit and auth unless a side's
`extensions` setting declines one, which connects it as a peer that does not
speak that extension. The pooled JavaScript `Connection` has no such setting yet.
moq-transport carries no [publisher epochs](/concept/moq-lite#publisher-epochs);
the [epoch draft](/draft/moq-epoch) remains an unimplemented proposal.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
[ietf](/draft/moq-ietf) goes the other way: it extends moq-lite-07 to carry this
object state between relays. moq-lite-07 is still a work in progress
(`moq-lite-07-wip`), negotiated only when both sides enable it, so a peer that
Expand Down
5 changes: 2 additions & 3 deletions doc/concept/stats.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,8 @@ The broadcast stays announced for the producer's life. Each announcement
carries a fresh [publisher epoch](/concept/moq-lite#publisher-epochs) on its
route, so a restarted node is a new broadcast at the same path: it replaces the
old one, and no relay serves it groups cached under the old epoch. A reader
treats each epoch as its own set of counters. The epoch rides moq-lite 07
announcements, and moq-transport with the
[epoch extension](/draft/moq-epoch); over anything else a reader sees the same
treats each epoch as its own set of counters. The epoch rides only moq-lite 07
announcements; over moq-transport and older moq-lite versions a reader sees the same
change as an end and a start at the path.
An aggregating reader pins each node's subscription to its epoch, and when one
publisher is reached through both kinds of link (with and without an epoch), it
Expand Down
4 changes: 1 addition & 3 deletions doc/setup/redundancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,7 @@ promise. See [identity and recovery](/concept/identity) for the whole contract.
both. Give both publishers permission to announce the same full path.
2. Publish the same broadcast name under the same auth path. Each publisher
keeps its own epoch; `moq` creates one automatically.
3. Use epoch-capable connections on every hop: moq-lite 07, which is opt-in,
or moq-transport with the [epoch extension](/draft/moq-epoch)
enabled. Keep the publisher hosts' clocks synchronized, since epochs order
3. Use moq-lite 07, which is opt-in, on every hop to carry epochs. Keep the publisher hosts' clocks synchronized, since epochs order
by their UUIDv7 creation time.

For example, two HLS importers can publish `event.hang` through different
Expand Down
5 changes: 2 additions & 3 deletions doc/setup/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,8 @@ These land with the next breaking release.
publishers together.
- **Announce restarts.** moq-lite 07 adds `ANNOUNCE_RESTART` (0x3): another
publisher instance now serves the prefix. lite-06's `ANNOUNCE_RESTART` is
named `ANNOUNCE_UPDATE`, as it always meant. moq-transport with the epoch
extension advertise another epoch before withdrawing the old
one. Elsewhere, a restart is still an end then a start.
named `ANNOUNCE_UPDATE`, as it always meant. moq-transport carries no epoch;
a restart is still an end then a start.
- **Publisher epochs ride the route** (#4942). moq-lite 07 carries the epoch
after the path in `ANNOUNCE_START`, `TRACK`, `SUBSCRIBE`, and `FETCH`.
Routes without an epoch no longer resume across routes, so default lite-06
Expand Down
6 changes: 3 additions & 3 deletions js/e2ee/src/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ async function active(origin: Origin.Producer, path: Path.Valid): Promise<Broadc
const transports = [
["moq-lite-07", "moq-lite-07-wip", true, true],
["moq-lite-05", "moq-lite-05", false, true],
["moq-transport-18", "moqt-18", true, true],
["moq-transport-18", "moqt-18", false, true],
] as const;

for (const [name, protocol, routed, timed] of transports) {
Expand All @@ -74,8 +74,8 @@ for (const [name, protocol, routed, timed] of transports) {

const remote = await active(subscriber, path);
// The epoch rides the route where the wire carries it; elsewhere the application supplies it.
if (routed) expect(remote.epoch).toBe(generation.epoch);
const epoch = routed && remote.epoch ? remote.epoch : generation.epoch;
expect(remote.epoch).toBe(routed ? generation.epoch : undefined);
const epoch = remote.epoch ?? generation.epoch;
const consumer = await new Protected.Consumer(remote, cred.consume(epoch)).subscribe("video", {
maxDelay: Milli(60_000),
});
Expand Down
1 change: 0 additions & 1 deletion js/net/api/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,6 @@ export namespace Connection {
}
export interface Extensions {
auth?: boolean;
epoch?: boolean;
solicit?: boolean;
}
export interface Goaway {
Expand Down
15 changes: 0 additions & 15 deletions js/net/src/connection/extensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,21 +49,6 @@ test("a declined extension stays out of SETUP", async () => {
expect(none.dialed.solicit).toBe(true);
});

test("MoQ Epoch is negotiated only when both sides offer it", async () => {
const all = await handshake({}, {});
expect(all.dialed.epoch).toBe(true);
expect(all.accepted.epoch).toBe(true);

for (const [client, server] of [
[{ epoch: false }, {}],
[{}, { epoch: false }],
]) {
const declined = await handshake(client, server);
expect(declined.dialed.epoch).toBe(false);
expect(declined.accepted.epoch).toBe(false);
}
});

test("an AUTH to a side that declined it closes the session", async () => {
// Control: offered by both, the connection credential is granted.
const offering = await acceptWith({});
Expand Down
4 changes: 0 additions & 4 deletions js/net/src/connection/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ export interface Extensions {
auth?: boolean;
/** The MoQ Solicit extension: the peer answers our SUBSCRIBE_NAMESPACE rather than announcing unasked. */
solicit?: boolean;
/** The MoQ Epoch extension: announcements and requests carry the publisher's epoch, as moq-lite does (draft-18+). */
epoch?: boolean;
}

/** Every extension resolved to whether it is offered. */
Expand All @@ -22,13 +20,11 @@ export function offered(extensions?: Extensions): Offered {
return {
auth: extensions?.auth ?? true,
solicit: extensions?.solicit ?? true,
epoch: extensions?.epoch ?? true,
};
}

/** Write the options for the offered extensions into a SETUP. */
export function intoSetup(params: Ietf.SetupOptions, extensions: Offered) {
if (extensions.solicit) Ietf.solicitIntoSetup(params);
if (extensions.auth) Ietf.Auth.intoSetup(params);
if (extensions.epoch) Ietf.Epoch.intoSetup(params);
}
10 changes: 3 additions & 7 deletions js/net/src/connection/handshake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ export const DEFAULT_TIMEOUT = Milli(30_000);
* Returns the control stream, the uni streams that arrived before it (see
* {@link receiveSetup}), plus what the peer's SETUP declared: whether it requires
* solicitation, which decides whether we announce namespaces unprompted (see the MoQ Solicit
* extension), its Hop ID (see the MoQ Cluster extension), and whether MoQ Auth and MoQ Epoch
* are negotiated, plus the implementation it advertised. We declare our Hop ID on every
* session, and Solicit, Auth, and Epoch unless `extensions` declines them: we send SUBSCRIBE_NAMESPACE for each prefix we want, so an unsolicited advertisement
* extension), its Hop ID (see the MoQ Cluster extension), and whether MoQ Auth
* is negotiated, plus the implementation it advertised. We declare our Hop ID on every
* session, and Solicit and Auth unless `extensions` declines them: we send SUBSCRIBE_NAMESPACE for each prefix we want, so an unsolicited advertisement
* can tell us nothing we won't have asked for, and a peer that knows our Hop ID can withhold
* the advertisements that already flowed through us.
*/
Expand All @@ -39,7 +39,6 @@ export async function exchangeSetup(
activeCount: boolean;
cluster: Ietf.Cluster.Hops;
auth: boolean;
epoch: boolean;
peerImplementation: string | undefined;
}> {
const params = new Ietf.SetupOptions();
Expand Down Expand Up @@ -82,7 +81,6 @@ export async function exchangeSetup(
cluster: { self, peer: received.cluster },
// Negotiated only when both sides offer it.
auth: received.auth && extensions.auth,
epoch: received.epoch && extensions.epoch,
peerImplementation: received.implementation,
};
}
Expand Down Expand Up @@ -114,7 +112,6 @@ async function receiveSetup(
activeCount: boolean;
cluster: Hop | undefined;
auth: boolean;
epoch: boolean;
implementation: string | undefined;
}> {
const uniReader = transport.incomingUnidirectionalStreams.getReader() as ReadableStreamDefaultReader<
Expand Down Expand Up @@ -171,7 +168,6 @@ async function receiveSetup(
activeCount: Ietf.activeCountFromSetup(setup.parameters),
cluster: Ietf.Cluster.fromSetup(setup.parameters),
auth: Ietf.Auth.fromSetup(setup.parameters) === true,
epoch: Ietf.Epoch.fromSetup(setup.parameters),
implementation: Ietf.implementationFromSetup(setup.parameters),
};
}
Expand Down
8 changes: 0 additions & 8 deletions js/net/src/ietf/aliases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,6 @@ test("sharing an alias across subscriptions to one track is not fatal", () => {
expect(() => aliases.set(7n, {}, { broadcast: "cam", name: "video" })).toThrow(SharedTrackAlias);
});

// Another publisher instance is other content under the same name (MoQ Epoch).
test("one alias for two epochs of a track is a collision", () => {
const aliases = new TrackAliases<object>();
aliases.set(7n, {}, { broadcast: "cam", name: "video", epoch: "a" });

expect(() => aliases.set(7n, {}, { broadcast: "cam", name: "video", epoch: "b" })).toThrow(DuplicateTrackAlias);
});

// Metadata keyed by an alias (the track's timescale) must only be torn down by whoever
// still owns the binding, so retirement reports ownership rather than returning void.
test("retire reports whether the caller still owned the alias", () => {
Expand Down
7 changes: 3 additions & 4 deletions js/net/src/ietf/aliases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,13 @@ type Resolver<T> = PromiseWithResolvers<T>["resolve"];
*
* Kept as two components rather than one joined string: a track name may contain the same
* separator a broadcast path uses, so `a` + `b/c` and `a/b` + `c` would otherwise compare
* equal and turn a genuine collision into apparent sharing. The publisher instance a
* subscription names (MoQ Epoch) is part of it: another instance is other content.
* equal and turn a genuine collision into apparent sharing.
* @internal
*/
export type TrackIdentity = { broadcast: string; name: string; epoch?: string };
export type TrackIdentity = { broadcast: string; name: string };

function sameTrack(a: TrackIdentity, b: TrackIdentity): boolean {
return a.broadcast === b.broadcast && a.name === b.name && a.epoch === b.epoch;
return a.broadcast === b.broadcast && a.name === b.name;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion js/net/src/ietf/cluster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { type IetfVersion, Version } from "./version.ts";
const VERSION = Version.DRAFT_18;

/** A session that negotiated MoQ Cluster alone. */
const CLUSTER = { cluster: true, epoch: false };
const CLUSTER = { cluster: true };

function origin(id: bigint): Hop {
return HopSchema.parse(id);
Expand Down
10 changes: 0 additions & 10 deletions js/net/src/ietf/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,6 @@ export class Connection implements Established {
// The Hop IDs this session declared; see {@link Cluster}.
#cluster?: Cluster.Hops;

// Whether both SETUPs offered MoQ Epoch, so advertisements and requests may carry EPOCH.
#epoch: boolean;

// The peer's GOAWAY, read off its SETUP stream.
#goaway = new Once<Drain>();

Expand Down Expand Up @@ -130,7 +127,6 @@ export class Connection implements Established {
cluster,
auth = false,
handle,
epoch = false,
early = [],
extensions,
peerImplementation,
Expand Down Expand Up @@ -163,8 +159,6 @@ export class Connection implements Established {
auth?: boolean;
/** The auth handle to build the session on, or undefined for one of its own. */
handle?: Auth.Handle;
/** Whether MoQ Epoch is negotiated: offered by both SETUPs. */
epoch?: boolean;
/** Uni streams that arrived before the peer's SETUP, type unread. */
early?: Reader[];
/** What our own SETUP offered; every extension when omitted. */
Expand Down Expand Up @@ -214,21 +208,18 @@ export class Connection implements Established {
hidden,
activeCount,
cluster,
epoch,
grant: this.#auth.scope,
auth: this.#auth,
ready: this.#auth.setupAnswered(),
});
this.#solicit = solicit;
this.#declaredSolicit = extensions?.solicit ?? true;
this.#cluster = cluster;
this.#epoch = epoch;
this.#subscriber = new Subscriber({
maxAge,
session: this.#session,
quic,
cluster,
epoch,
hidden,
activeCount,
goaway: this.#goaway,
Expand Down Expand Up @@ -390,7 +381,6 @@ export class Connection implements Established {
case BigInt(PublishNamespace.id): {
const decoded = PublishNamespace.decode(stream.reader, this.#session.version, {
cluster: Cluster.negotiated(this.#cluster),
epoch: this.#epoch,
});
const msg = await this.#request(stream, "publish_namespace", decoded);
if (!msg) break;
Expand Down
Loading