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
29 changes: 23 additions & 6 deletions doc/bin/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ zero-based `frame` and padded standard base64.

`<track>` is the literal track name. `/fetch` splits its path on the last `/`,
so the two agree only for names without one. Fetch only dials `--connect`, and
refuses any listener, cluster, auth, or `--hop` flag. It gives up after 30
refuses any listener, cluster, auth, or `--epoch` flag. It gives up after 30
seconds, as `/fetch` does, and exits non-zero when the broadcast or group is not
found (before writing anything), the relay refuses, or the deadline passes.

Expand Down Expand Up @@ -390,11 +390,28 @@ moq --connect https://relay.example.com/anon \

## Redundant publishers

Two publishers of the same broadcast name are interchangeable sources:
relays hold both routes and fail over between them mid-group. They must
produce identical tracks with aligned groups. A restarted encoder is the same
broadcast too, so one whose groups restart from 0 must publish under a new
name, or viewers wait for its sequence to catch up.
Each run announces a fresh epoch, so a restarted publisher replaces the old
broadcast instead of resuming into it. Two publishers that pass the same
`--epoch` (a lowercase, hyphenated UUIDv7, such as util-linux `uuidgen -7`
prints) are interchangeable sources: relays hold both routes and fail over
between them mid-group. They must produce identical tracks with aligned groups,
which no importer guarantees yet (see
[#4352](https://github.com/moq-dev/moq/issues/4352) and
[#4354](https://github.com/moq-dev/moq/issues/4354)). A member restarted with
the same epoch rejoins as the same content, so if its groups restart from 0,
viewers wait for its sequence to catch up.

```bash
EPOCH=$(uuidgen -7)
# On each of the two hosts:
moq --connect https://relay.example.com/anon --broadcast event.hang --epoch "$EPOCH" import ts < feed.ts
```

`--epoch` applies to the sources announced once per run. The RTMP, SRT, and
WHIP ingests and `import ts --program all` announce their own, so they refuse it.

`--epoch` replaces the removed `--hop`. `MOQ_HOP` is no longer read, so drop it
from any deployment and pass `MOQ_EPOCH` instead.

## Cluster

Expand Down
6 changes: 0 additions & 6 deletions doc/bin/relay/cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,6 @@ A route whose original publisher (its first hop) changes is updated in place on
both wire protocols, so the broadcast never briefly vanishes downstream, and
subscriptions in flight carry on through it.

A publisher whose protocol names no hop (moq-transport without the cluster
extension, moq-lite 01 through 03, or a peer that sends 0) gets a random first
hop from the relay it connects to, fresh for each connection, followed by a 0.
Its reconnect is therefore a new first hop downstream, a reprice on the same
connection stays in place, and the 0 keeps it ranked as anonymous.

## Topology

List the peers each relay dials. That's the whole topology: a relay dials only
Expand Down
3 changes: 1 addition & 2 deletions doc/concept/moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,7 @@ through, which is how forwarding loops are caught, and a cost, which is how a
subscriber picks among several routes to the same broadcast. A route may also
carry a [publisher epoch](#publisher-epochs), which says which routes serve the
same bytes. A hop of 0 is
the anonymous mark and travels the chain unchanged; when it is the first hop, a
relay puts a random ID, fresh per connection, in front of it. A route that passed through an
the anonymous mark and travels the chain unchanged. A route that passed through an
anonymous hop at any depth ranks below every fully identified route, whatever
the costs say; among anonymous routes, cost keeps ordering.

Expand Down
12 changes: 11 additions & 1 deletion doc/setup/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ These land with the next breaking release, not the 2026-09-23 train.
`producer.demand().used` and `.unused()`, as are `Group.Producer`'s, and
`Allocator.reserve` takes `producer.demand()`, replacing the
`Bandwidth.Demand` interface.
- **`moq --hop` is removed; `--epoch` replaces it.** A redundant pair shares an
epoch, a UUIDv7 such as `uuidgen -7` prints, instead of a Hop ID: pass the
same `--epoch` (or `MOQ_EPOCH`) to both. Unlike a rename, `--hop` is now an
unknown flag and `MOQ_HOP` is silently ignored, so remove it from any
deployment: a pair still keyed on `MOQ_HOP` mints an epoch per process, and
whenever either member starts or restarts, its new epoch replaces the other's
broadcast and restarts its viewers. `--cluster-id` no longer falls
back to `--hop`, so a node that pinned its Hop ID with `--hop` passes
`--cluster-id`. Relays no longer put a random hop in front of a route that
names no publisher; it keeps its 0.
- **The `"auto"` delay is measured, not derived from RTT** (#4162). It is sized
from how late frames arrive (see [audio jitter](/concept/audio-jitter)) in
`@moq/watch` and `moq play`, which now defaults `--delay` to `auto` instead of
Expand Down Expand Up @@ -185,7 +195,7 @@ variables follow the flag (`MOQ_SERVER_BIND` is `MOQ_LISTEN`).
| `--cluster-linger` | removed; a broadcast closes when its last publisher is lost |
| `--cluster-connect host:port` | a full URL, `https://host/?jwt=TOKEN` |
| `--cluster-mesh`, TOML `mesh` | removed; list every peer with `--cluster-connect` or `--cluster-connect-api` |
| `moq --origin`, `--name`, `--latency-max` | `--hop`, `--broadcast`, `--max-age` |
| `moq --origin`, `--name`, `--latency-max` | `--hop` (removed for `--epoch` after [Unreleased](#unreleased)), `--broadcast`, `--max-age` |
| `moq publish`, `moq subscribe` | `moq import`, `moq export` |
| `moq token`, the `moq-token` binary | `moq auth` |

Expand Down
16 changes: 3 additions & 13 deletions drafts/draft-lcurley-moq-cluster.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@ It stands for an endpoint that did not negotiate this extension, and an endpoint
Since any number of endpoints can be 0, it identifies nothing:

- **Loop detection**: 0 in a HOP_PATH is never a loop. A receiver whose own Hop ID is 0 cannot detect loops through itself and MUST NOT discard an advertisement merely because the path contains 0.
- **Unknown publisher**: an advertisement whose first entry is 0 has an unknown publisher, which a relay names by putting a stamp of its own in front ({{stamping}}).
- **Filtering**: a peer that declared 0 gave the receiver nothing to filter that session on. The receiver MAY assign an ID of its own ({{assigned}}) as local selection state and MUST NOT write it into HOP_PATH.

Duplicate *non-zero* Hop IDs in one HOP_PATH are a loop; duplicate zeros are not.
Expand All @@ -120,22 +119,14 @@ It uses that ID as local selection state: as what it filters that session on, in

The ID is the receiver's own, not the peer's, and MUST NOT be forwarded.
An advertisement that arrives with its own HOP_PATH already names the sender there, as 0 if withheld.
An unnamed publisher is stamped instead ({{stamping}}), which is not an assigned identity.
A receiver writes 0 for an upstream that sent no HOP_PATH ({{bridging}}).

An assigned ID MUST NOT be shared between peers not known to be the same endpoint.
Sharing one suppresses each one's advertisements to the other, so two unrelated peers would starve each other of routes.

A peer the receiver authenticated, or dialed and therefore chose, SHOULD get one stable ID, so its reconnects and redundant sessions are filtered as one peer; a fresh ID per connection would make one peer look like several.
An anonymous accepted session cannot be correlated with anything, so it SHOULD get a distinct ID per session: not an identity, but enough to keep routes learned from it from being advertised back to it, which is the loop 0 cannot prevent.

## Stamping an Unknown Publisher {#stamping}
A relay that records an advertisement whose first HOP_PATH entry is 0 MUST insert a random non-zero Hop ID in front of it, picked once for the session the advertisement arrived on.
One that arrived with no HOP_PATH ({{bridging}}) becomes that stamp followed by 0.

A fresh stamp per session keeps unnamed publishers on different sessions apart, while an update on the same session keeps the stamp and applies in place.
The 0 behind the stamp keeps the path ranked below fully identified ones ({{selection}}), since the unnamed hop may hide any depth.
A stamp names a session, not a peer: it MUST NOT be reused across sessions or taken as the peer's identity.


# Namespace Advertisements {#namespace}
HOP_PATH and ROUTE_COST are Key-Value-Pair parameters ({{moqt}} Section 2.5).
Expand Down Expand Up @@ -198,15 +189,15 @@ Unknown, out-of-budget, and saturated routes are outside the guarantee: a receiv

# Relay Behavior
A relay forwarding an advertisement MUST append its own Hop ID to the HOP_PATH it received, so its ID is always the last entry.
A received 0 is forwarded unchanged, behind a stamp when it is the first entry ({{stamping}}).
A received 0 is forwarded unchanged.

A relay MUST discard an advertisement whose HOP_PATH already contains its own non-zero Hop ID: forwarding it would extend a loop, and subscribing through it would route the relay back to itself.
This check catches loops of any length and is the only loop defense required.
A conforming sender never sends one ({{selection}}), so a receiver MAY close the session with a PROTOCOL_VIOLATION instead; discarding is what keeps the mesh working when one member does not conform.

## Bridging {#bridging}
An upstream that did not negotiate the extension sends no HOP_PATH.
The relay creates one holding its stamp for that session followed by 0 ({{stamping}}), then appends its own Hop ID.
The relay creates one with a single 0 entry for that upstream ({{zero}}), then appends its own Hop ID.
The identity a receiver assigned that upstream ({{assigned}}) is local selection state and MUST NOT appear in HOP_PATH.

## Accumulating Cost {#accumulating}
Expand Down Expand Up @@ -324,7 +315,6 @@ The Key-Value-Pair parity is load-bearing: HOP_PATH is odd, so its value is a le
- Defined request resolution against the longest covering prefix; every refusal is terminal, including between several publishers of one namespace, and capacity is signaled only by withdrawing or re-pricing the advertisement.
- A relay does not advertise a namespace because it resolved it; the publisher advertises the concrete namespace once producing.
- A change of original publisher is an ordinary update instead of a withdrawal and a new advertisement.
- A relay puts a random Hop ID, picked per session, in front of an advertisement whose first HOP_PATH entry is 0, and writes that stamp followed by 0 for one with no HOP_PATH.

## moq-cluster-01
- Assigned identities are local selection state and MUST NOT be forwarded.
Expand Down
11 changes: 3 additions & 8 deletions drafts/draft-lcurley-moq-lite.md
Original file line number Diff line number Diff line change
Expand Up @@ -411,9 +411,6 @@ This is the only loop defense moq-lite requires, and it catches loops of any len
A conforming sender never sends one (see below), so a receiver MAY instead close the session with a protocol violation; discarding is what keeps a mesh working when one member does not conform.
A Hop ID of 0 means unknown and never matches anything; withholding an ID trades loop detection for privacy.
A receiver MAY assign an identity of its own to a peer that declared 0, as local selection state for filtering that session; it MUST NOT forward that identity as a Hop ID.
A relay that records an announcement whose reconstructed path starts with 0 MUST insert a random non-zero Hop ID in front of it, picked once for the session it arrived on; an empty path becomes that stamp followed by 0.
Nothing on the wire says whether an unnamed publisher that reconnects is the same one, so its reconnect reads downstream as a new first hop, while an ANNOUNCE_UPDATE on the same session keeps the stamp.
The 0 behind the stamp keeps the path ranked below fully identified ones.

A publisher MUST NOT advertise a path whose entries contain the Hop ID the subscriber declared in its SETUP (see [Hop Parameter](#hop-parameter)).
The receiver can only discard it, and acting on it would form a loop, so sending one is never useful.
Expand Down Expand Up @@ -822,7 +819,7 @@ Both endpoints send it and the two values need not match: the parameter prices t
A declared cost is an assertion, not an instruction: a receiver MAY charge a locally configured value instead, so a peer cannot reprice its neighbours by declaring itself cheap.

### Hop Parameter {#hop-parameter}
The Hop Parameter declares the sender's Hop ID: the identity it stamps onto announcements it forwards.
The Hop Parameter declares the sender's Hop ID: the identity that names it in the paths of announcements it forwards.
The Parameter Value is a variable-length integer; a value of 0 carries no identity and is equivalent to omitting the parameter.

Declaring it at setup gives the receiver the peer's identity before any other stream arrives, so route selection applies the same exclusion to the peer's subscriptions as to its announcements (see [Routing](#routing)), even on a session that never opens an Announce Stream.
Expand Down Expand Up @@ -935,10 +932,9 @@ A receiver MUST close the stream with a PROTOCOL_VIOLATION if the Hop Count does
A unique identifier for each relay in the path from the origin publisher, ordered from origin to the upstream of the responding publisher.
The responding publisher's own Hop ID is NOT included in the resolved list; it is carried once in ANNOUNCE_OK, so the total path length is the resolved list's length plus 1 (`Hop Count + Hop Keep + 1`).
When forwarding an announcement received from an upstream peer, a relay MUST append the upstream peer's ANNOUNCE_OK `Hop ID` to the resolved list, since that ID is no longer implicit downstream.
The first entry of the reconstructed path identifies the endpoint that originated the route.
A Hop ID value of 0 means the hop is unknown: either it was never assigned or a relay deliberately withholds it (see [Routing](#routing)).
A received 0 is forwarded unchanged, behind a stamp when it is the first entry (see [Routing](#routing)).
When bridging an announcement from an upstream that sent no hop list, a relay writes its stamp followed by 0 for that hop.
A received 0 is forwarded unchanged.
When bridging an announcement from an upstream that sent no hop list, a relay writes 0 for that hop.
An identity a receiver assigned that upstream is local selection state and MUST NOT be forwarded as a Hop ID.

A receiver MUST close the session with a PROTOCOL_VIOLATION if a non-zero Hop ID appears twice in the resolved list.
Expand Down Expand Up @@ -1395,7 +1391,6 @@ The `Message Length` describes the payload size on the wire.
- 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.
- A relay puts a random Hop ID, picked per session, in front of an announcement whose reconstructed path starts with 0, and writes that stamp followed by 0 for an empty path.

## moq-lite-06

Expand Down
14 changes: 0 additions & 14 deletions js/net/src/hop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,20 +67,6 @@ export function randomHop(): Hop {
return HopSchema.parse(raw === 0n ? 1n : raw);
}

/**
* Name an unknown original publisher: put `stamp`, the receiving connection's own random id,
* in front of a chain that starts with 0, and turn an empty chain into `[stamp, 0]`. A
* publisher that reconnects then reads downstream as a new first hop, while the 0 after the
* stamp keeps the route ranked as anonymous. `undefined` if the chain is full or already holds
* `stamp`. Mirrors `Hops::stamp` in rs/moq-net.
*/
export function stampHops(hops: readonly Hop[], stamp: Hop): Hop[] | undefined {
if (hops.length === 0) return [stamp, UNKNOWN_HOP];
if (hops[0] !== UNKNOWN_HOP) return [...hops];
if (hops.length >= MAX_HOPS || hops.includes(stamp)) return undefined;
return [stamp, ...hops];
}

/**
* What pulling content via a route costs, in two magnitudes accumulated together
* and compared in that order: lower {@link Cost.warm} wins, and {@link Cost.cold}
Expand Down
35 changes: 13 additions & 22 deletions js/net/src/ietf/subscriber.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,30 +116,21 @@ test("an unsolicited announcement lands", async () => {
});

/**
* A session without the Cluster extension names no publisher, so each connection stamps its
* own random Hop ID in front of a 0: a publisher that reconnects reads as a new one, and the 0
* keeps it ranked below identified routes.
* A session without the Cluster extension names no publisher, so its advertisement carries
* only the anonymous mark: identity is the epoch's job, and nothing goes in front of the 0.
*/
test("an advertisement with no path is stamped per connection", async () => {
const stamp = async () => {
const pair = createMockTransportPair(ALPN.DRAFT_19);
const subscriber = new Subscriber({ session: new NativeSession(pair.server, VERSION, true) });
const announced = subscriber.announced();
expect(await nextStream(pair.client)).toBeDefined();

const stream = await Stream.open(pair.server, { version: VERSION });
void subscriber.runPublishNamespace(
new PublishNamespace({ requestId: 0n, trackNamespace: Path.from("legacy") }),
stream,
);
const hops = (await announced.next())?.route.hops ?? [];
expect(hops).toHaveLength(2);
expect(hops[0]).not.toBe(UNKNOWN_HOP);
expect(hops[1]).toBe(UNKNOWN_HOP);
return hops[0];
};
test("an advertisement with no path is anonymous", async () => {
const pair = createMockTransportPair(ALPN.DRAFT_19);
const subscriber = new Subscriber({ session: new NativeSession(pair.server, VERSION, true) });
const announced = subscriber.announced();
expect(await nextStream(pair.client)).toBeDefined();

expect(await stamp()).not.toBe(await stamp());
const stream = await Stream.open(pair.server, { version: VERSION });
void subscriber.runPublishNamespace(
new PublishNamespace({ requestId: 0n, trackNamespace: Path.from("legacy") }),
stream,
);
expect((await announced.next())?.route.hops).toEqual([UNKNOWN_HOP]);
});

/**
Expand Down
18 changes: 4 additions & 14 deletions js/net/src/ietf/subscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
sessionCause,
} from "../error.ts";
import * as netGroup from "../group.ts";
import { Cost, type Route, randomHop, routesEqual, stampHops, UNKNOWN_HOP } from "../hop.ts";
import { Cost, type Route, routesEqual, UNKNOWN_HOP } from "../hop.ts";
import { hiddenBelow, hooks, scopeCaptures, scopeHead, scopeOverlaps } from "../internal.ts";
import * as Path from "../path.ts";
import type { Cursor, Reader, Stream } from "../stream.ts";
Expand Down Expand Up @@ -130,10 +130,6 @@ export class Subscriber {
// Dedup consumed broadcasts per path: repeat consume() calls share one subscription.
#consumes = new BroadcastCache();

// A random Hop ID of this connection's own, written as the first hop of any path that
// names no publisher, so a publisher that reconnects reads as a new one.
#stamp = randomHop();

// Paths with a legacy PUBLISH_NAMESPACE request in flight, reserved synchronously.
// The count below is only taken once the OK is written, and two requests that both
// got past the duplicate check before either attached would both take one.
Expand Down Expand Up @@ -197,16 +193,10 @@ export class Subscriber {
return advert !== undefined && this.#cluster !== undefined && Cluster.loops(advert, this.#cluster.self);
}

/**
* The route an advertisement carries; one without a path is free. A path that names no
* publisher, or none at all, gets this connection's stamp in front of a 0.
*/
/** The route an advertisement carries; one without a path is anonymous and free. */
#route(advert: Cluster.Advert | undefined): Route {
if (advert === undefined) return { hops: [this.#stamp, UNKNOWN_HOP], cost: Cost.zero };
// A full chain, or a stamp colliding with an entry (a 1-in-2^53 draw), keeps the
// path as sent.
const hops = stampHops(advert.hops, this.#stamp) ?? [...advert.hops];
return { hops, cost: { warm: advert.cost, cold: advert.cost } };
if (advert === undefined) return { hops: [UNKNOWN_HOP], cost: Cost.zero };
return { hops: advert.hops, cost: { warm: advert.cost, cold: advert.cost } };
}

/**
Expand Down
Loading
Loading