From b9e4b76ea52b9658b7717375e57a318b31ee48b4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 29 Oct 2026 07:01:00 -0700 Subject: [PATCH 01/41] chore(quest): claim quest/m1/ietf-epoch-cut.md Quest-Claim: a5c32d05d9bb8b26 From ef868a0fc798a69df54ca60657534dfa938c695d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 29 Oct 2026 07:13:32 -0700 Subject: [PATCH 02/41] feat(net)!: remove epochs from moq-transport Co-Authored-By: GPT-6.1 Sol --- doc/bin/cli.md | 3 +- doc/bin/relay/cluster.md | 7 +- doc/bin/relay/config.md | 2 - doc/concept/moq-lite.md | 12 +- doc/concept/standard.md | 7 +- doc/concept/stats.md | 5 +- doc/setup/redundancy.md | 4 +- doc/setup/upgrade.md | 5 +- js/net/api/index.api.md | 1 - js/net/src/connection/extensions.test.ts | 15 - js/net/src/connection/extensions.ts | 4 - js/net/src/connection/handshake.ts | 10 +- js/net/src/ietf/aliases.test.ts | 8 - js/net/src/ietf/aliases.ts | 7 +- js/net/src/ietf/cluster.test.ts | 2 +- js/net/src/ietf/connection.ts | 10 - js/net/src/ietf/epoch.test.ts | 198 -------- js/net/src/ietf/epoch.ts | 28 -- js/net/src/ietf/fetch.ts | 14 - js/net/src/ietf/index.ts | 1 - js/net/src/ietf/parameters.ts | 44 +- js/net/src/ietf/publish_namespace.ts | 24 +- js/net/src/ietf/publisher.test.ts | 215 +-------- js/net/src/ietf/publisher.ts | 83 +--- js/net/src/ietf/setup_options.test.ts | 1 - js/net/src/ietf/setup_options.ts | 2 - js/net/src/ietf/subscribe.ts | 12 - js/net/src/ietf/subscribe_namespace.ts | 7 +- js/net/src/ietf/subscriber.test.ts | 207 +------- js/net/src/ietf/subscriber.ts | 77 +-- js/net/src/ietf/track.ts | 11 +- rs/moq-net/src/fuzz.rs | 3 - rs/moq-net/src/ietf/epoch.rs | 97 ---- rs/moq-net/src/ietf/fetch.rs | 26 +- rs/moq-net/src/ietf/mod.rs | 3 - rs/moq-net/src/ietf/parameters.rs | 2 - rs/moq-net/src/ietf/peer.rs | 41 +- rs/moq-net/src/ietf/publish_namespace.rs | 18 +- rs/moq-net/src/ietf/publisher.rs | 447 ++---------------- rs/moq-net/src/ietf/publisher/tracks.rs | 4 +- rs/moq-net/src/ietf/session.rs | 20 +- rs/moq-net/src/ietf/subscribe.rs | 19 - rs/moq-net/src/ietf/subscribe_namespace.rs | 97 +--- rs/moq-net/src/ietf/subscriber.rs | 288 +---------- rs/moq-net/src/ietf/subscriber/tracks.rs | 4 - rs/moq-net/src/ietf/track.rs | 8 - rs/moq-net/src/setup.rs | 6 +- rs/moq-net/src/test_interop.rs | 2 - .../tests/integration/catalog_acquisition.rs | 7 +- rs/moq-net/tests/integration/extensions.rs | 71 --- rs/moq-net/tests/integration/multi_cdn.rs | 35 +- rs/moq-net/tests/integration/replacement.rs | 14 +- rs/moq-net/tests/integration/route_change.rs | 7 +- test/interop/clients/js-native/control.ts | 2 +- test/interop/clients/js-native/goaway.ts | 17 +- test/interop/clients/js-native/server.ts | 2 +- test/interop/clients/js/media.ts | 6 +- test/interop/clients/rust/goaway-client.rs | 10 +- test/interop/clients/rust/goaway.rs | 6 +- 59 files changed, 201 insertions(+), 2087 deletions(-) delete mode 100644 js/net/src/ietf/epoch.test.ts delete mode 100644 js/net/src/ietf/epoch.ts delete mode 100644 rs/moq-net/src/ietf/epoch.rs diff --git a/doc/bin/cli.md b/doc/bin/cli.md index b1d716afe07..3a4f9fd7938 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -387,8 +387,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. diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md index 80a9fdcd264..02e1d69d22e 100644 --- a/doc/bin/relay/cluster.md +++ b/doc/bin/relay/cluster.md @@ -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 diff --git a/doc/bin/relay/config.md b/doc/bin/relay/config.md index da3e71ef842..7552b28f7a9 100644 --- a/doc/bin/relay/config.md +++ b/doc/bin/relay/config.md @@ -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 @@ -317,7 +316,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 diff --git a/doc/concept/moq-lite.md b/doc/concept/moq-lite.md index ccdd5ed442c..a8a3a15d9b6 100644 --- a/doc/concept/moq-lite.md +++ b/doc/concept/moq-lite.md @@ -136,13 +136,8 @@ authorization and hidden names work exactly as without one. name instead of stalling viewers on the old sequence. A relay drops its copy of the old instance, so a new subscriber never sees its cached groups. -The epoch and the restart message travel on moq-lite 07, which is opt-in. -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 +The epoch and the restart message travel only on moq-lite 07, which is opt-in. +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. @@ -163,8 +158,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, diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 670612e71b6..4c3819b1b71 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -47,14 +47,15 @@ ignores unknown parameters: [cluster](/draft/moq-cluster) routing hop lists, [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 +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. [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 diff --git a/doc/concept/stats.md b/doc/concept/stats.md index bd1c35b8373..84e292433f7 100644 --- a/doc/concept/stats.md +++ b/doc/concept/stats.md @@ -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 diff --git a/doc/setup/redundancy.md b/doc/setup/redundancy.md index f4e1840480e..4f6495e37f3 100644 --- a/doc/setup/redundancy.md +++ b/doc/setup/redundancy.md @@ -29,9 +29,7 @@ promise. 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 diff --git a/doc/setup/upgrade.md b/doc/setup/upgrade.md index a8f81385c52..0803c70701a 100644 --- a/doc/setup/upgrade.md +++ b/doc/setup/upgrade.md @@ -68,9 +68,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 diff --git a/js/net/api/index.api.md b/js/net/api/index.api.md index d4986d25a6f..f9b9d65a984 100644 --- a/js/net/api/index.api.md +++ b/js/net/api/index.api.md @@ -165,7 +165,6 @@ export namespace Connection { } export interface Extensions { auth?: boolean; - epoch?: boolean; solicit?: boolean; } export interface Goaway { diff --git a/js/net/src/connection/extensions.test.ts b/js/net/src/connection/extensions.test.ts index 24ac0d1c476..8679a511e16 100644 --- a/js/net/src/connection/extensions.test.ts +++ b/js/net/src/connection/extensions.test.ts @@ -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({}); diff --git a/js/net/src/connection/extensions.ts b/js/net/src/connection/extensions.ts index 142887d0d26..f144ac09b47 100644 --- a/js/net/src/connection/extensions.ts +++ b/js/net/src/connection/extensions.ts @@ -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. */ @@ -22,7 +20,6 @@ export function offered(extensions?: Extensions): Offered { return { auth: extensions?.auth ?? true, solicit: extensions?.solicit ?? true, - epoch: extensions?.epoch ?? true, }; } @@ -30,5 +27,4 @@ export function offered(extensions?: Extensions): Offered { 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); } diff --git a/js/net/src/connection/handshake.ts b/js/net/src/connection/handshake.ts index e7b9171a303..e7c2332542a 100644 --- a/js/net/src/connection/handshake.ts +++ b/js/net/src/connection/handshake.ts @@ -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. */ @@ -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(); @@ -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, }; } @@ -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< @@ -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), }; } diff --git a/js/net/src/ietf/aliases.test.ts b/js/net/src/ietf/aliases.test.ts index 55f221a4637..ec0eebad6a5 100644 --- a/js/net/src/ietf/aliases.test.ts +++ b/js/net/src/ietf/aliases.test.ts @@ -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(); - 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", () => { diff --git a/js/net/src/ietf/aliases.ts b/js/net/src/ietf/aliases.ts index 2301771d8ee..4f87c2a51dd 100644 --- a/js/net/src/ietf/aliases.ts +++ b/js/net/src/ietf/aliases.ts @@ -22,14 +22,13 @@ type Resolver = PromiseWithResolvers["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; } /** diff --git a/js/net/src/ietf/cluster.test.ts b/js/net/src/ietf/cluster.test.ts index 355f7bfb99a..82159734ac9 100644 --- a/js/net/src/ietf/cluster.test.ts +++ b/js/net/src/ietf/cluster.test.ts @@ -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); diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index 98bf82d7a4b..6abcc90d6fc 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -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(); @@ -130,7 +127,6 @@ export class Connection implements Established { cluster, auth = false, handle, - epoch = false, early = [], extensions, peerImplementation, @@ -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. */ @@ -214,7 +208,6 @@ export class Connection implements Established { hidden, activeCount, cluster, - epoch, grant: this.#auth.scope, auth: this.#auth, ready: this.#auth.setupAnswered(), @@ -222,13 +215,11 @@ export class Connection implements Established { 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, @@ -391,7 +382,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; diff --git a/js/net/src/ietf/epoch.test.ts b/js/net/src/ietf/epoch.test.ts deleted file mode 100644 index 82e2523ed44..00000000000 --- a/js/net/src/ietf/epoch.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { expect, test } from "bun:test"; -import * as Epoch from "../epoch.ts"; -import { ProtocolViolation } from "../error.ts"; -import { HopSchema } from "../hop.ts"; -import * as Path from "../path.ts"; -import { Reader, Writer } from "../stream.ts"; -import type * as Cluster from "./cluster.ts"; -import * as EpochExt from "./epoch.ts"; -import { Fetch } from "./fetch.ts"; -import * as Message from "./message.ts"; -import { Parameters } from "./parameters.ts"; -import { type AdvertParams, PublishNamespace } from "./publish_namespace.ts"; -import { SetupOptions } from "./setup_options.ts"; -import { Subscribe } from "./subscribe.ts"; -import { SubscribeNamespaceEntry } from "./subscribe_namespace.ts"; -import { TrackStatusRequest } from "./track.ts"; -import { type IetfVersion, Version } from "./version.ts"; - -const VERSION = Version.DRAFT_18; -const EPOCH_PARAM = 0x40b69n; -const EPOCH = Epoch.mint(); - -const ALL = [Version.DRAFT_18, Version.DRAFT_22] as const; - -async function encode( - message: { encode(w: Writer, v: IetfVersion): Promise }, - version: IetfVersion = VERSION, -): Promise { - const chunks: Uint8Array[] = []; - const writer = new Writer( - new WritableStream({ - write(chunk) { - chunks.push(new Uint8Array(chunk)); - }, - }), - version, - ); - await message.encode(writer, version); - writer.close(); - await writer.closed; - - const out = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.byteLength, 0)); - let offset = 0; - for (const chunk of chunks) { - out.set(chunk, offset); - offset += chunk.byteLength; - } - return out; -} - -function reader(bytes: Uint8Array, version: IetfVersion = VERSION): Reader { - return new Reader(undefined, bytes, version); -} - -/** A parameter block carrying `value` under the EPOCH key, valid or not. */ -function epochParam(value: Uint8Array): Parameters { - const params = new Parameters(); - params.bytes.set(EPOCH_PARAM, value); - return params; -} - -test("Epoch: SETUP declares it", async () => { - for (const version of ALL) { - const params = new SetupOptions(); - expect(EpochExt.fromSetup(params)).toBe(false); - EpochExt.intoSetup(params); - const decoded = await SetupOptions.decode(reader(await encode(params, version), version)); - expect(EpochExt.fromSetup(decoded)).toBe(true); - } -}); - -test("Epoch: the parameter is the UUID's 16 bytes, length-prefixed", async () => { - const params = new Parameters(); - params.epoch = EPOCH; - expect(Array.from(await encode(params))).toEqual([ - 0x01, // parameter count - 0xc4, - 0x0b, - 0x69, // EPOCH (0x40b69), delta encoded from 0 - 0x10, // value length - ...Epoch.toBytes(EPOCH), - ]); - - const decoded = await Parameters.decode(reader(await encode(params)), VERSION, "subscribe"); - expect(decoded.epoch).toBe(EPOCH); -}); - -test("Epoch: anything but a UUIDv7's 16 bytes is a protocol violation", async () => { - const v4 = Epoch.toBytes(EPOCH); - v4[6] = (v4[6] & 0x0f) | 0x40; - - for (const value of [new Uint8Array(), Epoch.toBytes(EPOCH).slice(0, 15), v4]) { - const encoded = await encode(epochParam(value)); - await expect(Parameters.decode(reader(encoded), VERSION, "subscribe")).rejects.toThrow(ProtocolViolation); - } -}); - -test("Epoch: a REQUEST_UPDATE never carries one", async () => { - const encoded = await encode(epochParam(Epoch.toBytes(EPOCH))); - for (const message of ["request-update", "subscribe-update"] as const) { - await expect(Parameters.decode(reader(encoded), VERSION, message)).rejects.toThrow(ProtocolViolation); - } -}); - -test("Epoch: SUBSCRIBE and TRACK_STATUS carry it", async () => { - const trackNamespace = Path.from("alice"); - for (const version of ALL) { - const expected = EPOCH; - - const subscribe = new Subscribe({ - requestId: 0n, - trackNamespace, - trackName: "video", - subscriberPriority: 0, - epoch: EPOCH, - }); - expect((await Subscribe.decode(reader(await encode(subscribe, version), version), version)).epoch).toBe( - expected, - ); - - const status = new TrackStatusRequest({ requestId: 0n, trackNamespace, trackName: "video", epoch: EPOCH }); - expect((await TrackStatusRequest.decode(reader(await encode(status, version), version), version)).epoch).toBe( - expected, - ); - } -}); - -test("Epoch: a joining FETCH that names one is malformed", async () => { - const joining = (params: Parameters) => ({ - encode: (w: Writer, version: IetfVersion) => - Message.encode(w, async (mw) => { - await mw.u62(0n); // request id - await mw.u53(0x2); // relative joining - await mw.u62(0n); // joining request id - await mw.u62(0n); // group offset - await params.encode(mw, version); - }), - }); - - // Control: the same FETCH without it decodes. - expect((await Fetch.decode(reader(await encode(joining(new Parameters()))), VERSION)).epoch).toBeUndefined(); - - const named = new Parameters(); - named.epoch = EPOCH; - await expect(Fetch.decode(reader(await encode(joining(named))), VERSION)).rejects.toThrow( - "EPOCH is not allowed on a joining FETCH", - ); -}); - -test("Epoch: PUBLISH_NAMESPACE carries it only once negotiated", async () => { - const encoded = await encode( - new PublishNamespace({ requestId: 1n, trackNamespace: Path.from("alice"), params: { epoch: EPOCH } }), - ); - - const decoded = await PublishNamespace.decode(reader(encoded), VERSION, { cluster: false, epoch: true }); - expect(decoded.params).toEqual({ cluster: undefined, epoch: EPOCH }); - - // A session that did not negotiate it was never sent one. - await expect(PublishNamespace.decode(reader(encoded), VERSION)).rejects.toThrow(ProtocolViolation); - - // An advertisement may lack one: the route had no epoch to carry. - const bare = await encode(new PublishNamespace({ requestId: 1n, trackNamespace: Path.from("alice") })); - expect( - (await PublishNamespace.decode(reader(bare), VERSION, { cluster: false, epoch: true })).params.epoch, - ).toBeUndefined(); -}); - -test.each([ - ["neither", false, false], - ["cluster only", true, false], - ["epoch only", false, true], - ["both", true, true], -] as const)("Epoch: NAMESPACE framing with %s negotiated", async (_name, cluster, epoch) => { - const negotiated = { cluster, epoch }; - const advert: Cluster.Advert = { hops: [HopSchema.parse(7n)], cost: 0n }; - const params: AdvertParams = { cluster: cluster ? advert : undefined, epoch: epoch ? EPOCH : undefined }; - const extended = cluster || epoch; - - const entry = new SubscribeNamespaceEntry({ suffix: Path.from("alice"), params: extended ? params : undefined }); - const encoded = await encode(entry); - const decoded = await SubscribeNamespaceEntry.decode(reader(encoded), VERSION, negotiated); - expect(decoded.suffix).toBe(Path.from("alice")); - expect(decoded.params).toEqual(extended ? params : undefined); - - // The block is present iff either extension is, so the base form is strictly shorter. - const base = await encode(new SubscribeNamespaceEntry({ suffix: Path.from("alice") })); - expect(base.length < encoded.length).toBe(extended); -}); - -test("Epoch: a NAMESPACE carrying one without MoQ Epoch is a protocol violation", async () => { - const advert: Cluster.Advert = { hops: [HopSchema.parse(7n)], cost: 0n }; - const encoded = await encode( - new SubscribeNamespaceEntry({ suffix: Path.from("alice"), params: { cluster: advert, epoch: EPOCH } }), - ); - await expect( - SubscribeNamespaceEntry.decode(reader(encoded), VERSION, { cluster: true, epoch: false }), - ).rejects.toThrow(ProtocolViolation); -}); diff --git a/js/net/src/ietf/epoch.ts b/js/net/src/ietf/epoch.ts deleted file mode 100644 index 022dc3f0793..00000000000 --- a/js/net/src/ietf/epoch.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { SetupOption, type SetupOptions } from "./setup_options.ts"; - -/** - * The MoQ Epoch extension (draft-lcurley-moq-epoch-00). - * - * moq-transport names content by namespace alone, so a publisher that restarts at the same - * namespace looks like the same broadcast to every relay. This extension carries the - * publisher instance, the epoch moq-lite 07 puts on its routes and requests, as an EPOCH - * parameter: on each advertisement (PUBLISH_NAMESPACE and NAMESPACE), and on each request - * that resolves one (SUBSCRIBE, FETCH, and TRACK_STATUS). A namespace and an EPOCH name one - * broadcast; a request carrying one is refused rather than served by another instance. - * - * Negotiated on draft-18+ only, where SETUP is a Key-Value-Pair block, and only when both - * endpoints declare the option. - * - * @module - * @internal - */ - -/** Whether the peer declared the option. @internal */ -export function fromSetup(params: SetupOptions): boolean { - return params.getVarint(SetupOption.Epoch) !== undefined; -} - -/** Declare that we understand the EPOCH parameter. @internal */ -export function intoSetup(params: SetupOptions) { - params.setVarint(SetupOption.Epoch, 1n); -} diff --git a/js/net/src/ietf/fetch.ts b/js/net/src/ietf/fetch.ts index fe88878009d..9a074f28fd8 100644 --- a/js/net/src/ietf/fetch.ts +++ b/js/net/src/ietf/fetch.ts @@ -1,5 +1,4 @@ import type { Milli } from "@moq/clock"; -import type * as Epoch from "../epoch.ts"; import type * as Path from "../path.ts"; import { type Reader, Writer } from "../stream.ts"; import type { Order } from "../track.ts"; @@ -102,9 +101,6 @@ export class Fetch { /** Whether the requester wants Track Properties in FETCH_OK (INCLUDE_PROPERTIES, draft-22). */ propertiesWanted: boolean; - /** The publisher instance the requester expects (EPOCH, MoQ Epoch), never on a joining fetch. */ - epoch?: Epoch.Valid; - constructor({ requestId, type, @@ -113,7 +109,6 @@ export class Fetch { rangeFilters = false, fillTimeout, propertiesWanted = true, - epoch, }: { requestId: bigint; type: FetchType; @@ -122,7 +117,6 @@ export class Fetch { rangeFilters?: boolean; fillTimeout?: Milli; propertiesWanted?: boolean; - epoch?: Epoch.Valid; }) { this.requestId = requestId; this.type = type; @@ -131,7 +125,6 @@ export class Fetch { this.rangeFilters = rangeFilters; this.fillTimeout = fillTimeout; this.propertiesWanted = propertiesWanted; - this.epoch = epoch; } async #encode(w: Writer, version: IetfVersion): Promise { @@ -141,7 +134,6 @@ export class Fetch { params.subscriberPriority = this.subscriberPriority; if (this.groupOrder !== undefined) params.groupOrder = this.groupOrder === "ascending" ? GroupOrder.Ascending : GroupOrder.Descending; - params.epoch = this.epoch; params.fillTimeout = this.fillTimeout; const type = this.type; @@ -224,11 +216,6 @@ export class Fetch { if (params.trackPropertyFilter) { throw new Error("TRACK_PROPERTY_FILTER is not allowed on FETCH"); } - // A joining FETCH is served by its subscription's instance, so naming one is malformed. - const epoch = params.epoch; - if (epoch !== undefined && (type.kind === "relativeJoining" || type.kind === "absoluteJoining")) { - throw new Error("EPOCH is not allowed on a joining FETCH"); - } // An absent LOCATION_FILTER fetches the whole track. if (type.kind === "filtered") type.filter = params.subscriptionFilter ?? { kind: "unfiltered" }; @@ -243,7 +230,6 @@ export class Fetch { fillTimeout: params.fillTimeout, // Defaults to 1, so an absent parameter means the requester wants them. propertiesWanted: params.includeProperties ?? true, - epoch, }); } } diff --git a/js/net/src/ietf/index.ts b/js/net/src/ietf/index.ts index 2d4208134ff..975d8ccf413 100644 --- a/js/net/src/ietf/index.ts +++ b/js/net/src/ietf/index.ts @@ -3,7 +3,6 @@ export * from "./active_count.ts"; export * as Auth from "./auth_setup.ts"; export * as Cluster from "./cluster.ts"; -export * as Epoch from "./epoch.ts"; export * from "./hidden.ts"; export * from "./max_age.ts"; export * from "./setup.ts"; diff --git a/js/net/src/ietf/parameters.ts b/js/net/src/ietf/parameters.ts index 1e08070f457..41a54318081 100644 --- a/js/net/src/ietf/parameters.ts +++ b/js/net/src/ietf/parameters.ts @@ -1,6 +1,5 @@ import { Milli } from "@moq/clock"; -import * as Epoch from "../epoch.ts"; -import { ProtocolViolation, reason } from "../error.ts"; +import { ProtocolViolation } from "../error.ts"; import type * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; import * as Filter from "./filter.ts"; @@ -44,8 +43,6 @@ const MSG_PARAM_FILL_PARAMETERS = 0x23n; const MSG_PARAM_HOP_PATH = 0x40b57n; /// ACTIVE_COUNT, from the MoQ Active Count extension. const MSG_PARAM_ACTIVE_COUNT = 0x40b66n; -/// EPOCH, from the MoQ Epoch extension: a UUIDv7's 16 bytes. See `epoch.ts`. -const MSG_PARAM_EPOCH = 0x40b69n; /** Which control message the parameter block belongs to. Omitted keeps the unfiltered decode. */ type ControlMessage = @@ -82,7 +79,6 @@ function allows(message: ControlMessage, version: IetfVersion, id: bigint): bool const fill = id === MSG_PARAM_FILL_PARAMETERS && from22; const include = id === MSG_PARAM_INCLUDE_PROPERTIES && from22; const newGroup = id === MSG_PARAM_NEW_GROUP_REQUEST; - const epoch = id === MSG_PARAM_EPOCH; switch (message) { case "subscribe-tracks": @@ -123,8 +119,7 @@ function allows(message: ControlMessage, version: IetfVersion, id: bigint): bool fill || range || newGroup || - include || - epoch + include ); case "subscribe-ok": return expires || largest; @@ -160,24 +155,15 @@ function allows(message: ControlMessage, version: IetfVersion, id: bigint): bool (order && from22) ); case "publish-namespace": - return auth || id === MSG_PARAM_HOP_PATH || id === MSG_PARAM_ROUTE_COST || epoch; + return auth || id === MSG_PARAM_HOP_PATH || id === MSG_PARAM_ROUTE_COST; case "fetch": - return ( - auth || - id === MSG_PARAM_FILL_TIMEOUT || - priority || - (filter && from22) || - order || - range || - include || - epoch - ); + return auth || id === MSG_PARAM_FILL_TIMEOUT || priority || (filter && from22) || order || range || include; case "fetch-ok": return false; case "request-ok": return expires || largest || id === MSG_PARAM_ACTIVE_COUNT; case "namespace": - return id === MSG_PARAM_HOP_PATH || id === MSG_PARAM_ROUTE_COST || epoch; + return id === MSG_PARAM_HOP_PATH || id === MSG_PARAM_ROUTE_COST; } } @@ -227,7 +213,6 @@ function getMessageParamKind(id: bigint): MessageParamKind { case MSG_PARAM_AUTHORIZATION_TOKEN: case MSG_PARAM_FILL_PARAMETERS: case MSG_PARAM_HOP_PATH: - case MSG_PARAM_EPOCH: return "bytes"; default: if (MSG_PARAM_REPEATABLE.includes(id)) return "bytes"; @@ -448,17 +433,6 @@ export class Parameters { this.vars.set(MSG_PARAM_ROUTE_COST, v); } - /** EPOCH (MoQ Epoch): the publisher instance an advertisement or request names. */ - get epoch(): Epoch.Valid | undefined { - const value = this.bytes.get(MSG_PARAM_EPOCH); - return value === undefined ? undefined : Epoch.fromBytes(value); - } - - set epoch(v: Epoch.Valid | undefined) { - if (v === undefined) this.bytes.delete(MSG_PARAM_EPOCH); - else this.bytes.set(MSG_PARAM_EPOCH, Epoch.toBytes(v)); - } - /** Encode the block. */ async encode(w: Writer, version: IetfVersion) { const filter = this.#filter; @@ -604,14 +578,6 @@ export class Parameters { case "bytes": { const size = await r.u53(); const value = await r.read(size); - // Anything but a UUIDv7's 16 bytes is malformed, so refuse it here rather than on first read. - if (id === MSG_PARAM_EPOCH) { - try { - Epoch.fromBytes(value); - } catch (err) { - throw new ProtocolViolation(`malformed EPOCH: ${reason(err)}`, { cause: err }); - } - } params.bytes.set(id, value); break; } diff --git a/js/net/src/ietf/publish_namespace.ts b/js/net/src/ietf/publish_namespace.ts index 8702598e82b..bdd5df85a18 100644 --- a/js/net/src/ietf/publish_namespace.ts +++ b/js/net/src/ietf/publish_namespace.ts @@ -1,4 +1,3 @@ -import type * as Epoch from "../epoch.ts"; import { ProtocolViolation, reason, SessionError, StreamError } from "../error.ts"; import type * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; @@ -16,31 +15,26 @@ import type { IetfVersion } from "./version.ts"; export interface Negotiated { /** MoQ Cluster: every advertisement carries HOP_PATH, and may carry ROUTE_COST. */ cluster: boolean; - /** MoQ Epoch: an advertisement may carry EPOCH. */ - epoch: boolean; } -/** A session that negotiated neither advertisement extension. */ -export const NEGOTIATED_NONE: Negotiated = { cluster: false, epoch: false }; +/** A session without the advertisement extension. */ +export const NEGOTIATED_NONE: Negotiated = { cluster: false }; /** Whether NAMESPACE takes the extended form, with a Parameters field. */ export function extended(negotiated: Negotiated): boolean { - return negotiated.cluster || negotiated.epoch; + return negotiated.cluster; } /** The extension parameters one advertisement carries. */ export interface AdvertParams { /** The MoQ Cluster parameters (see {@link Cluster}), set exactly on a session that negotiated it. */ cluster?: Cluster.Advert; - /** The MoQ Epoch parameter: the publisher instance the route serves, if it names one. */ - epoch?: Epoch.Valid; } /** Write an advertisement's extension parameters into a fresh block. */ export function advertIntoParams(advert: AdvertParams): Parameters { const params = new Parameters(); if (advert.cluster) Cluster.intoParams(advert.cluster, params); - params.epoch = advert.epoch; return params; } @@ -48,9 +42,8 @@ export function advertIntoParams(advert: AdvertParams): Parameters { * Read an advertisement's Parameters field, checked against the list of the message that * carries it (PUBLISH_NAMESPACE also allows a token), expecting what the session negotiated. * - * Every way this fails is one to close the session over: a block that does not parse, a - * negotiated cluster session that omits HOP_PATH, or an EPOCH on a session that did not - * negotiate MoQ Epoch. A request token's own refusal keeps its type. + * A malformed block or a negotiated cluster session omitting HOP_PATH closes the + * session. A request token's own refusal keeps its type. */ export async function decodeAdvert( r: Reader, @@ -67,10 +60,7 @@ export async function decodeAdvert( throw new ProtocolViolation(reason(err), { cause: err }); } - const epoch = params.epoch; - // An unknown parameter to a session that never offered the extension. - if (epoch !== undefined && !negotiated.epoch) throw new ProtocolViolation("EPOCH without MoQ Epoch"); - return { cluster: negotiated.cluster ? Cluster.fromParams(params) : undefined, epoch }; + return { cluster: negotiated.cluster ? Cluster.fromParams(params) : undefined }; } export class PublishNamespace { @@ -191,8 +181,6 @@ export class PublishNamespaceUpdate { static async #decode(r: Reader, version: IetfVersion): Promise { const requestId = await r.u62(); const params = await Parameters.decode(r, version, "publish-namespace"); - // An epoch is fixed for the life of an advertisement, so no update carries one. - if (params.epoch !== undefined) throw new ProtocolViolation("EPOCH on a PUBLISH_NAMESPACE update"); return new PublishNamespaceUpdate({ requestId, update: Cluster.updateFromParams(params) }); } } diff --git a/js/net/src/ietf/publisher.test.ts b/js/net/src/ietf/publisher.test.ts index 631b0e12262..e17863b0d49 100644 --- a/js/net/src/ietf/publisher.test.ts +++ b/js/net/src/ietf/publisher.test.ts @@ -43,7 +43,7 @@ function publish(origin: OriginProducer, path: Path.Valid) { const VERSION = Version.DRAFT_18; /** A session that negotiated MoQ Cluster alone. */ -const CLUSTER = { cluster: true, epoch: false }; +const CLUSTER = { cluster: true }; /** How long to wait for a stream before calling it absent, which is how a regression reports. */ const STREAM_WAIT = 1000; @@ -163,7 +163,6 @@ function publisher( grant, activeCount, auth, - epoch, }: { requiresSolicitation?: boolean; session?: Session; @@ -171,7 +170,6 @@ function publisher( grant?: Signal; activeCount?: boolean; auth?: Tokens; - epoch?: boolean; } = {}, ): { pub: Publisher; origin: OriginProducer } { const origin = new OriginProducer(); @@ -186,7 +184,6 @@ function publisher( grant, activeCount, auth, - epoch, }), origin, }; @@ -1435,214 +1432,6 @@ test("an advertisement carries our hop id once the peer declared one", async () } }); -/** A session that negotiated MoQ Epoch alone. */ -const EPOCH = { cluster: false, epoch: true }; - -/** - * An advertisement names the publisher instance once the peer declared MoQ Epoch. A peer - * that did not has not read the parameter, so sending it would be a protocol violation. - */ -test("an advertisement carries its epoch only once negotiated", async () => { - for (const negotiated of [true, false]) { - const pair = createMockTransportPair(ALPN.DRAFT_18); - const { pub, origin } = publisher(pair.server, { epoch: negotiated }); - const epoch = Epoch.mint(); - origin.createBroadcast(Path.from("mine")).announce({ epoch }); - void pub.runPublishNamespaces(); - - const stream = await nextStream(pair.client); - if (!stream) throw new Error("no PUBLISH_NAMESPACE for the broadcast"); - expect(await stream.reader.u53()).toBe(PublishNamespace.id); - const msg = await PublishNamespace.decode(stream.reader, VERSION, EPOCH); - expect(msg.params.epoch).toBe(negotiated ? epoch : undefined); - await acceptPublishNamespace(stream); - - origin.close(); - } -}); - -/** - * A new instance at a path is other content, but the namespace never goes absent: the new - * epoch is advertised first, and the old one withdrawn only once the peer accepted it, - * since the two streams are unordered. A peer without MoQ Epoch cannot tell the two apart, - * so it still sees the old one withdrawn first. - */ -test.each([true, false])( - "an epoch change advertises the new epoch before withdrawing the old (negotiated: %p)", - async (negotiated) => { - using clock = manual(); - const pair = createMockTransportPair(ALPN.DRAFT_18); - const { pub, origin } = publisher(pair.server, { epoch: negotiated }); - const streams = accepted(pair.client); - try { - const broadcast = origin.createBroadcast(Path.from("mine")); - const first = Epoch.mint(); - broadcast.announce({ epoch: first }); - void pub.runPublishNamespaces(); - - const old = await take(clock, streams); - expect(await old.reader.u53()).toBe(PublishNamespace.id); - expect((await PublishNamespace.decode(old.reader, VERSION, EPOCH)).params.epoch).toBe( - negotiated ? first : undefined, - ); - await acceptPublishNamespace(old); - - const second = Epoch.mint(); - broadcast.announce({ ...broadcast.route, epoch: second }); - const withdrawn = watch(old.reader.done()); - - const next = await take(clock, streams); - expect(await next.reader.u53()).toBe(PublishNamespace.id); - expect((await PublishNamespace.decode(next.reader, VERSION, EPOCH)).params.epoch).toBe( - negotiated ? second : undefined, - ); - await clock.advance(Nano.zero); - expect(withdrawn.state).toBe(negotiated ? "pending" : "rejected"); - - await acceptPublishNamespace(next); - expect(await old.reader.done().catch(error)).toMatchObject({ code: StreamCode.Cancel }); - } finally { - origin.close(); - } - }, -); - -/** On a SUBSCRIBE_NAMESPACE stream, another epoch is one NAMESPACE and no NAMESPACE_DONE. */ -test("a solicited namespace takes another epoch as one NAMESPACE and no NAMESPACE_DONE", async () => { - using clock = manual(); - const pair = createMockTransportPair(ALPN.DRAFT_18); - const { pub, origin } = publisher(pair.server, { requiresSolicitation: true, epoch: true }); - try { - const broadcast = origin.createBroadcast(Path.from("mine")); - const first = Epoch.mint(); - broadcast.announce({ epoch: first }); - - const subscription = await Stream.open(pair.client, { version: VERSION }); - const stream = await Stream.accept(pair.server, VERSION); - if (!stream) throw new Error("the subscription stream was never accepted"); - void pub.runSubscribeNamespace(new SubscribeNamespace({ requestId: 0n, namespace: Path.empty() }), stream); - const entry = async () => { - expect(await subscription.reader.u53()).toBe(SubscribeNamespaceEntry.id); - return await SubscribeNamespaceEntry.decode(subscription.reader, VERSION, EPOCH); - }; - - expect(await subscription.reader.u53()).toBe(RequestOk.id); - await RequestOk.decode(subscription.reader, VERSION); - expect(await entry()).toMatchObject({ suffix: Path.from("mine"), params: { epoch: first } }); - - const second = Epoch.mint(); - broadcast.announce({ ...broadcast.route, epoch: second }); - expect(await entry()).toMatchObject({ suffix: Path.from("mine"), params: { epoch: second } }); - - const more = watch(subscription.reader.u53()); - await clock.advance(Nano.zero); - expect(more.state).toBe("pending"); - subscription.close(); - } finally { - origin.close(); - } -}); - -/** A request that names an instance is served by that one alone, or refused as gone. */ -test("a request pinned to another epoch is refused DOES_NOT_EXIST", async () => { - const pair = createMockTransportPair(ALPN.DRAFT_18); - const session = new NativeSession(pair.server, VERSION, true); - const { pub, origin } = publisher(pair.server, { session, epoch: true }); - const epoch = Epoch.mint(); - const broadcastPublication = origin.createBroadcast(Path.from("room")); - const broadcast = broadcastPublication.broadcast; - broadcastPublication.announce({ epoch }); - broadcast.createTrack("video"); - origin.createBroadcast(Path.from("epochless")).announce(); - - const status = async (namespace: string, pinned: Epoch.Valid | undefined) => { - const { stream, wire } = requestStream(VERSION); - const request = new TrackStatusRequest({ - requestId: 7n, - trackNamespace: Path.from(namespace), - trackName: "video", - epoch: pinned, - }); - await pub.runTrackStatusRequest(request, stream); - return wire(); - }; - - // Control: the named instance, or none at all, is served. - expect(await (await status("room", epoch)).u53()).toBe(TrackStatusOk.id); - expect(await (await status("room", undefined)).u53()).toBe(TrackStatusOk.id); - - const missing = toRequestCode("does_not_exist", "track_status", VERSION); - expect(await refusalCode(await status("room", Epoch.mint()), VERSION)).toBe(missing); - expect(await refusalCode(await status("epochless", epoch), VERSION)).toBe(missing); - - // An instance never comes back, so asking to wait for a gone one changes nothing. - for (const rendezvous of [false, true]) { - for (const [namespace, pinned] of [ - ["room", Epoch.mint()], - ["epochless", epoch], - ] as const) { - const subscribe = requestStream(VERSION); - await pub.runSubscribe( - new Subscribe({ - requestId: 7n, - trackNamespace: Path.from(namespace), - trackName: "video", - subscriberPriority: 0, - rendezvous, - epoch: pinned, - }), - subscribe.stream, - ); - const r = subscribe.wire(); - expect(await r.u53()).toBe(RequestError.id); - expect((await RequestError.decode(r, VERSION)).errorCode).toBe( - toRequestCode("does_not_exist", "subscribe", VERSION), - ); - } - } - - origin.close(); -}); - -/** Only a session that negotiated MoQ Epoch may name an instance. */ -test("EPOCH on a request without MoQ Epoch is a protocol violation", async () => { - const pair = createMockTransportPair(ALPN.DRAFT_18); - const { pub, origin } = publisher(pair.server); - const epoch = Epoch.mint(); - const trackNamespace = Path.from("room"); - - await expect( - pub.runSubscribe( - new Subscribe({ requestId: 7n, trackNamespace, trackName: "video", subscriberPriority: 0, epoch }), - requestStream(VERSION).stream, - ), - ).rejects.toThrow(ProtocolViolation); - await expect( - pub.runTrackStatusRequest( - new TrackStatusRequest({ requestId: 7n, trackNamespace, trackName: "video", epoch }), - requestStream(VERSION).stream, - ), - ).rejects.toThrow(ProtocolViolation); - await expect( - pub.runFetch( - new Fetch({ - requestId: 7n, - type: { - kind: "standalone", - trackNamespace, - trackName: "video", - start: { group: 0n, object: 0n }, - end: { group: 0n, object: 0n }, - }, - epoch, - }), - requestStream(VERSION).stream, - ), - ).rejects.toThrow(ProtocolViolation); - - origin.close(); -}); - /** Advance time in steps until `done` holds, failing once `limit` has passed without it. */ async function advanceUntil(clock: Manual, done: () => boolean, limit: number, step = 50) { for (let elapsed = 0; !done(); elapsed += step) { @@ -2256,7 +2045,7 @@ test.each([ const stream = await nextStream(pair.client); if (!stream) throw new Error("no PUBLISH_NAMESPACE for the broadcast"); expect(await stream.reader.u53()).toBe(PublishNamespace.id); - const msg = await PublishNamespace.decode(stream.reader, VERSION, { cluster: peer !== undefined, epoch: false }); + const msg = await PublishNamespace.decode(stream.reader, VERSION, { cluster: peer !== undefined }); expect(msg.trackNamespace).toBe(Path.from("mine")); await acceptPublishNamespace(stream); diff --git a/js/net/src/ietf/publisher.ts b/js/net/src/ietf/publisher.ts index c55c8169860..ecf29adaf9f 100644 --- a/js/net/src/ietf/publisher.ts +++ b/js/net/src/ietf/publisher.ts @@ -198,13 +198,6 @@ interface Target { /** Advertise a namespace the peer does not hold. */ advertise(key: Path.Valid, next: Advertised): Promise; - /** - * Replace the instance behind a namespace the peer holds with another (MoQ Epoch): the - * new advertisement goes out before the old is withdrawn, so the peer never sees the - * namespace absent. - */ - replace(key: Path.Valid, next: Advertised): Promise; - /** Reprice a namespace the peer holds: the same broadcast at a new hop chain or cost. */ update(key: Path.Valid, next: Advertised, reprice: Reprice): Promise; @@ -379,10 +372,6 @@ export class Publisher { // back came from us. `undefined` when nothing negotiated it. #advert?: Cluster.Advert; - // Whether both SETUPs offered MoQ Epoch: every advertisement names its route's epoch, and - // a request may name the one it expects. - #epoch: boolean; - // Our grant (MoQ Auth): only what it lets us publish is advertised and served, and a // shrink withdraws what it no longer covers. Undefined until the peer answers, which // allows everything. @@ -410,7 +399,6 @@ export class Publisher { hidden = false, activeCount = false, cluster, - epoch = false, grant = new Signal(undefined), auth, ready = Promise.resolve(), @@ -429,8 +417,6 @@ export class Publisher { activeCount?: boolean; /** The Hop IDs the SETUP exchange settled (MoQ Cluster). */ cluster?: Cluster.Hops; - /** Whether both SETUPs offered MoQ Epoch. */ - epoch?: boolean; /** The union of our tokens' grants (MoQ Auth), which bounds what we publish. */ grant?: Getter; /** This session's tokens, which a route names for its request to carry. */ @@ -451,7 +437,6 @@ export class Publisher { this.#hidden = hidden; this.#activeCount = activeCount; this.#advert = Cluster.advertise(cluster); - this.#epoch = epoch; this.#datagramWriter = DatagramStream.datagramWriter(quic); } @@ -478,7 +463,6 @@ export class Publisher { } async #runSubscribe(msg: Subscribe, request: RequestStream) { - this.#pinned(msg.epoch); // Draft-22 replaced joining FETCH with fills. const joinable = !Filter.isDraft22(this.#session.version); if (joinable) this.#setJoin(msg.requestId, "pending"); @@ -530,9 +514,7 @@ export class Publisher { // Legal requests we can't honor are refused one at a time: serving a Range Filter // unfiltered would deliver objects it excluded. - // An instance never comes back, so a request pinned to a gone one is missing, not - // worth waiting for. - const rendezvous = msg.rendezvous && msg.epoch === undefined; + const rendezvous = msg.rendezvous; if (msg.rangeFilters) { refusal = { condition: "invalid_filter", reasonPhrase: "range filters not supported" }; @@ -542,7 +524,7 @@ export class Publisher { refusal = { condition: "unauthorized", reasonPhrase: "not granted" }; } else { try { - broadcast = await this.#resolve(name, left.signal, msg.epoch); + broadcast = await this.#resolve(name, left.signal); } catch (err: unknown) { // Nobody is left to refuse. if (left.signal.aborted) { @@ -1598,16 +1580,14 @@ export class Publisher { // replacement, so repricing one is just sending it again, a new original // publisher included, as Rust does. const namespace = async (suffix: Path.Valid, snap: Advertised): Promise => { - // Either extension puts the Parameters field on NAMESPACE, even an empty one. - const params = this.#advert !== undefined || this.#epoch ? this.#params(snap.route) : undefined; + // MoQ Cluster puts the Parameters field on NAMESPACE. + const params = this.#advert !== undefined ? this.#params(snap.route) : undefined; const entry = new SubscribeNamespaceEntry({ suffix, params }); await Message.write(request.writer, SubscribeNamespaceEntry.id, entry, version); return "held"; }; const target: Target = { advertise: namespace, - // A repeat under another epoch replaces the instance in place. - replace: namespace, update: namespace, held: () => undefined, carry: () => undefined, @@ -1726,7 +1706,6 @@ export class Publisher { const requests: Requests = new Map(); const target: Target = { advertise: (path, snap) => this.#advertise(path, requests, snap.route), - replace: (path, snap) => this.#replace(path, requests, snap.route), update: (path, _snap, reprice) => this.#update(path, requests, reprice), withdraw: (path) => this.#withdraw(path, requests), held: (path) => requests.get(path)?.auth, @@ -1830,9 +1809,8 @@ export class Publisher { * Bring the peer's view in line with `updated`, one namespace at a time, and report * whether anything it should hold is still missing and worth coming back for. * - * Nothing the peer cannot see is sent, a reprice updates the namespace in place, another - * epoch replaces it in place (MoQ Epoch), and anything else is withdrawn first so a - * republish reads as withdraw-then-advertise. + * Nothing the peer cannot see is sent, a reprice updates the namespace in place, and + * a changed instance is withdrawn before it is advertised again. * `ns` records what the peer holds rather than what we attempted: a declined * advertisement or update stays out of it, so the next turn offers it fresh instead * of believing it is already up. @@ -1856,7 +1834,7 @@ export class Publisher { const held = new Map(ns.active); for (const [key, snap] of ns.active) { const next = updated.get(key); - if (this.#change(key, snap, next, target) !== "restart" || this.#replaces(snap, next)) continue; + if (this.#change(key, snap, next, target) !== "restart") continue; await target.withdraw(key); held.delete(key); } @@ -1869,9 +1847,6 @@ export class Publisher { let answer: Answer; if (prev !== undefined && diff !== "restart") { answer = await target.update(key, snap, diff); - } else if (prev !== undefined) { - // Still held through a restart: another epoch replacing it. - answer = await target.replace(key, snap); } else { // A fresh offer waits for what a refusal asked for, whatever brought us back. if (!this.#offerable(key, ns.refused)) continue; @@ -1923,16 +1898,6 @@ export class Publisher { return { ...(diff === "same" ? {} : diff), token: to }; } - /** - * Whether `next` replaces the instance the peer holds in `held` with another, both named - * by an epoch on the wire (MoQ Epoch). Gaining or losing one is withdrawn and advertised - * afresh, and so is any instance change on a peer that cannot tell them apart. - */ - #replaces(held: Advertised, next: Advertised | undefined): boolean { - const [from, to] = [held.route.epoch, next?.route.epoch]; - return this.#epoch && from !== undefined && to !== undefined && from !== to; - } - /** `auth` when a request of this session can carry it. */ #carry(auth: Credential | undefined): Credential | undefined { return this.#auth?.carried(auth) === undefined ? undefined : auth; @@ -1973,11 +1938,10 @@ export class Publisher { } /** - * The extension parameters an advertisement of `route` carries. The epoch is forwarded - * as the route names it, and never invented for one without. + * The extension parameters an advertisement of `route` carries. */ #params(route: Route): AdvertParams { - return { cluster: clusterFor(this.#advert, route), epoch: this.#epoch ? route.epoch : undefined }; + return { cluster: clusterFor(this.#advert, route) }; } /** @@ -2165,19 +2129,6 @@ export class Publisher { return answer; } - /** - * Replace the PUBLISH_NAMESPACE request for `path` with one for another instance. The two - * streams are unordered, so the old one is withdrawn only once the peer answered the new - * one, refused or not: it serves another instance either way. - */ - async #replace(path: Path.Valid, requests: Requests, route: Route): Promise { - const old = requests.get(path); - requests.delete(path); - const answer = await this.#advertise(path, requests, route); - if (old) await this.#end(old); - return answer; - } - async #withdraw(path: Path.Valid, requests: Requests) { const request = requests.get(path); if (!request) return; @@ -2208,7 +2159,6 @@ export class Publisher { } async #runTrackStatusRequest(msg: TrackStatusRequest, request: RequestStream) { - this.#pinned(msg.epoch); if (Namespace.reserved(msg.trackNamespace, this.#session.version)) { await refuseReserved(request); return; @@ -2247,7 +2197,7 @@ export class Publisher { try { let answer: TrackStatusOk; try { - const broadcast = await this.#resolve(name, hold.signal, msg.epoch); + const broadcast = await this.#resolve(name, hold.signal); const info = await wireOf(broadcast).resolveTrackInfo(msg.trackName, hold.signal); const largest = wireOf(broadcast).largest(msg.trackName); answer = new TrackStatusOk({ @@ -2910,7 +2860,6 @@ export class Publisher { } async #runFetch(msg: Fetch, request: RequestStream) { - this.#pinned(msg.epoch); // The FILL_TIMEOUT counts from the request's arrival, whatever it waits on. const deadline = fillDeadline(msg.fillTimeout); const version = this.#session.version; @@ -3054,7 +3003,7 @@ export class Publisher { return { condition: "invalid_range", reasonPhrase: "empty range" }; } - const broadcast = await this.#resolve(named.trackNamespace, signal, msg.epoch); + const broadcast = await this.#resolve(named.trackNamespace, signal); const priority = fromWire(msg.subscriberPriority); const cache = broadcast.track(named.trackName).subscribe({ priority, maxDelay: Milli(Varint.MAX_U53) }); let answer: FetchAnswer | undefined; @@ -3367,22 +3316,14 @@ export class Publisher { /** * Resolve the broadcast a request names for serving: a local publish, or else the * origin's routes and dynamic handlers. Rejects with the abort once `signal` aborts. - * - * A request naming an `epoch` is served only by that instance: one now serving another - * epoch, or none, refuses it as gone rather than answering with other content. */ - async #resolve(name: Path.Valid, signal: AbortSignal, epoch: Epoch.Valid | undefined): Promise { + async #resolve(name: Path.Valid, signal: AbortSignal, epoch?: Epoch.Valid): Promise { const broadcast = this.#publish && (wireOf(this.#publish).local(name, epoch) ?? (await wireOf(this.#publish).demand(name, epoch, signal))); if (!broadcast) throw new NotFound(`broadcast ${name}`); return broadcast; } - - /** Break a session whose peer names an epoch it was never told about: MoQ Epoch was not negotiated. */ - #pinned(epoch: Epoch.Valid | undefined) { - if (epoch !== undefined && !this.#epoch) throw new ProtocolViolation("EPOCH without MoQ Epoch"); - } } /** A `{group, object}` position, in the Location Filter's own units. */ diff --git a/js/net/src/ietf/setup_options.test.ts b/js/net/src/ietf/setup_options.test.ts index afc7a672d53..5d6fb233a01 100644 --- a/js/net/src/ietf/setup_options.test.ts +++ b/js/net/src/ietf/setup_options.test.ts @@ -18,7 +18,6 @@ test("setup option keys match the registries", () => { MaxAge: 0x915c4n, Auth: 0x40b70n, ActiveCount: 0x40b64n, - Epoch: 0x40b68n, }); }); diff --git a/js/net/src/ietf/setup_options.ts b/js/net/src/ietf/setup_options.ts index b984b67b0de..4fe1a61f2f4 100644 --- a/js/net/src/ietf/setup_options.ts +++ b/js/net/src/ietf/setup_options.ts @@ -24,8 +24,6 @@ export const SetupOption = { Auth: 0x40b70n, /** ACTIVE_COUNT, from the MoQ Active Count extension. See `active_count.ts`. */ ActiveCount: 0x40b64n, - /** EPOCH, from the MoQ Epoch extension. See `epoch.ts`. */ - Epoch: 0x40b68n, } as const; // Unknown SETUP options may repeat, including GREASE (draft-22 section 9.1). diff --git a/js/net/src/ietf/subscribe.ts b/js/net/src/ietf/subscribe.ts index 1668402d816..1b0160fb000 100644 --- a/js/net/src/ietf/subscribe.ts +++ b/js/net/src/ietf/subscribe.ts @@ -1,4 +1,3 @@ -import type * as Epoch from "../epoch.ts"; import type * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; import type { Order } from "../track.ts"; @@ -87,12 +86,6 @@ export class Subscribe { /** Whether RENDEZVOUS_TIMEOUT requested a wait; never encoded. */ rendezvous: boolean; - /** - * The publisher instance the subscriber expects (EPOCH, MoQ Epoch). Only a - * session that negotiated the extension names one; the session refuses one otherwise. - */ - epoch?: Epoch.Valid; - constructor({ requestId, trackNamespace, @@ -106,7 +99,6 @@ export class Subscribe { forward, rangeFilters, rendezvous, - epoch, }: { requestId: bigint; trackNamespace: Path.Valid; @@ -120,7 +112,6 @@ export class Subscribe { forward?: boolean; rangeFilters?: boolean; rendezvous?: boolean; - epoch?: Epoch.Valid; }) { this.requestId = requestId; this.trackNamespace = trackNamespace; @@ -134,7 +125,6 @@ export class Subscribe { this.forward = forward ?? true; this.rangeFilters = rangeFilters ?? false; this.rendezvous = rendezvous ?? false; - this.epoch = epoch; } async #encode(w: Writer, version: IetfVersion): Promise { @@ -160,7 +150,6 @@ export class Subscribe { params.includeProperties = false; } } - params.epoch = this.epoch; await params.encode(w, version); } @@ -215,7 +204,6 @@ export class Subscribe { forward: params.forward ?? true, rangeFilters: params.rangeFilters, rendezvous: (params.vars.get(0x04n) ?? 0n) > 0n, - epoch: params.epoch, }); } } diff --git a/js/net/src/ietf/subscribe_namespace.ts b/js/net/src/ietf/subscribe_namespace.ts index dc893a54aea..df8d0a08c77 100644 --- a/js/net/src/ietf/subscribe_namespace.ts +++ b/js/net/src/ietf/subscribe_namespace.ts @@ -67,10 +67,9 @@ export const SUBSCRIBE_TRACKS_ID = 0x51; /** * NAMESPACE message (0x08), sent on the SUBSCRIBE_NAMESPACE bidi stream. * - * The base message carries only the suffix. A session that negotiated the MoQ Cluster or - * MoQ Epoch extension uses the extended form instead, which appends one Parameters field - * for both, so the advertisement can carry its HOP_PATH and ROUTE_COST (see `cluster.ts`) - * and its EPOCH (see `epoch.ts`). + * The base message carries only the suffix. A session that negotiated the MoQ Cluster + * extension uses the extended form instead, which appends one Parameters field + * so the advertisement can carry its HOP_PATH and ROUTE_COST (see `cluster.ts`). */ export class SubscribeNamespaceEntry { static id = 0x08; diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index 10347101841..7a3d34af559 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -2,7 +2,6 @@ import { expect, jest, onTestFinished, spyOn, test } from "bun:test"; import { Manual, Milli, Nano } from "@moq/clock"; import { Once } from "@moq/signals"; import type * as announce from "../announced.ts"; -import * as Epoch from "../epoch.ts"; import { ProtocolViolation, StreamCode, Stream as StreamError } from "../error.ts"; import { type Hop, HopSchema, UNKNOWN_HOP } from "../hop.ts"; import { hooks } from "../internal.ts"; @@ -127,7 +126,7 @@ test("an unsolicited announcement lands", async () => { /** * 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. + * only the anonymous mark, and nothing goes in front of the 0. */ test("an advertisement with no path is anonymous", async () => { const pair = createMockTransportPair(ALPN.DRAFT_18); @@ -1732,207 +1731,3 @@ for (const negotiated of [false, true]) { peer.close(); }); } -/** Advertise `path` inline under `epoch`, on a session that negotiated MoQ Epoch alone. */ -async function inlineEpoch(stream: Stream, path: Path.Valid, epoch: Epoch.Valid): Promise { - await stream.writer.u53(SubscribeNamespaceEntry.id); - await new SubscribeNamespaceEntry({ suffix: path, params: { epoch } }).encode(stream.writer, VERSION); -} - -/** Open a PUBLISH_NAMESPACE for `path` under `epoch`, returning the handler and the peer's end. */ -async function publishEpoch( - pair: ReturnType, - subscriber: Subscriber, - path: Path.Valid, - epoch: Epoch.Valid, -): Promise<{ handler: Promise; peer: Stream }> { - const stream = await Stream.open(pair.server, { version: VERSION }); - const handler = subscriber.runPublishNamespace( - new PublishNamespace({ requestId: 0n, trackNamespace: path, params: { epoch } }), - stream, - ); - const peer = await nextStream(pair.client); - if (!peer) throw new Error("no PUBLISH_NAMESPACE stream"); - expect(await peer.reader.u53()).toBe(RequestOk.id); - await RequestOk.decode(peer.reader, VERSION); - return { handler, peer }; -} - -/** The route names the advertised instance, and so does every SUBSCRIBE sent for it. */ -test("an advertised epoch reaches the route and the SUBSCRIBE", async () => { - const pair = createMockTransportPair(ALPN.DRAFT_18); - const subscriber = new Subscriber({ session: new NativeSession(pair.server, VERSION, true), epoch: true }); - const announced = subscriber.announced(); - await acceptSubscribeNamespace(pair.client); - - const epoch = Epoch.mint(); - await publishEpoch(pair, subscriber, Path.from("room"), epoch); - expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "start", route: { epoch } }); - - const track = subscriber.consume(Path.from("room")).track("video").subscribe(); - onTestFinished(() => track.close()); - const peer = await nextStream(pair.client); - if (!peer) throw new Error("no SUBSCRIBE stream"); - expect(await peer.reader.u53()).toBe(Subscribe.id); - expect((await Subscribe.decode(peer.reader, VERSION)).epoch).toBe(epoch); - announced.close(); -}); - -test("an inline NAMESPACE route carries its epoch", async () => { - const pair = createMockTransportPair(ALPN.DRAFT_18); - const subscriber = new Subscriber({ session: new NativeSession(pair.server, VERSION, true), epoch: true }); - const announced = subscriber.announced(); - const subscription = await acceptSubscribeNamespace(pair.client); - - const epoch = Epoch.mint(); - await inlineEpoch(subscription, Path.from("room"), epoch); - expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "start", route: { epoch } }); - announced.close(); -}); - -/** - * A NAMESPACE repeated under another epoch replaces the instance in place: the path - * restarts and is never absent, and a fresh reader hears the new instance standing. - */ -test("a NAMESPACE repeated under another epoch restarts the path in place", async () => { - const pair = createMockTransportPair(ALPN.DRAFT_18); - const subscriber = new Subscriber({ session: new NativeSession(pair.server, VERSION, true), epoch: true }); - const announced = subscriber.announced(); - const subscription = await acceptSubscribeNamespace(pair.client); - - const path = Path.from("room"); - const first = Epoch.mint(); - await inlineEpoch(subscription, path, first); - expect(await announced.next()).toMatchObject({ prefix: path, kind: "start", route: { epoch: first } }); - - const second = Epoch.mint(); - await inlineEpoch(subscription, path, second); - expect(await announced.next()).toMatchObject({ prefix: path, kind: "restart", route: { epoch: second } }); - - const fresh = subscriber.announced(); - expect(await fresh.next()).toMatchObject({ prefix: path, kind: "start", route: { epoch: second } }); - fresh.close(); - announced.close(); -}); - -/** - * A namespace advertised both inline and by a later PUBLISH_NAMESPACE takes another epoch - * from either: an inline repeat under the new epoch is the newest advertisement, not an - * update to the older of the two. - */ -test("a NAMESPACE repeat under another epoch outranks a later PUBLISH_NAMESPACE", async () => { - const pair = createMockTransportPair(ALPN.DRAFT_18); - const subscriber = new Subscriber({ session: new NativeSession(pair.server, VERSION, true), epoch: true }); - const announced = subscriber.announced(); - const subscription = await acceptSubscribeNamespace(pair.client); - - const path = Path.from("room"); - const first = Epoch.mint(); - await inlineEpoch(subscription, path, first); - expect(await announced.next()).toMatchObject({ prefix: path, kind: "start", route: { epoch: first } }); - await publishEpoch(pair, subscriber, path, first); - - const second = Epoch.mint(); - await inlineEpoch(subscription, path, second); - expect(await announced.next()).toMatchObject({ prefix: path, kind: "restart", route: { epoch: second } }); - announced.close(); -}); - -/** - * A NAMESPACE that loops back through us detaches its route, but the advertisement stays - * live on the wire until its NAMESPACE_DONE: a repeat that restores a clean path while - * losing its epoch is still the violation it would be without the loop. - */ -test("a reflected NAMESPACE keeps its epoch live", async () => { - const pair = createMockTransportPair(ALPN.DRAFT_18); - const session = new NativeSession(pair.server, VERSION, true); - const subscriber = new Subscriber({ session, quic: pair.server, cluster: { self: SELF, peer: PEER }, epoch: true }); - const warn = spyOn(console, "warn").mockImplementation(() => void 0); - onTestFinished(() => warn.mockRestore()); - const announced = subscriber.announced(); - const subscription = await acceptSubscribeNamespace(pair.client); - - const epoch = Epoch.mint(); - const send = async (hops: Hop[], named?: Epoch.Valid) => { - await subscription.writer.u53(SubscribeNamespaceEntry.id); - await new SubscribeNamespaceEntry({ - suffix: Path.from("room"), - params: { cluster: { hops, cost: 0n }, epoch: named }, - }).encode(subscription.writer, VERSION); - }; - await send([PEER], epoch); - expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "start" }); - await send([SELF, PEER], epoch); - expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "end" }); - - await send([PEER]); - await violationClosed(pair); -}); - -/** - * Gaining or losing an epoch is not one instance replacing another, so a repeat that does - * either means the peer skipped the NAMESPACE_DONE. - */ -test.each(["gains", "loses"] as const)("a NAMESPACE repeat that %s an epoch closes the session", async (change) => { - const pair = createMockTransportPair(ALPN.DRAFT_18); - const session = new NativeSession(pair.server, VERSION, true); - const subscriber = new Subscriber({ session, quic: pair.server, epoch: true }); - const warn = spyOn(console, "warn").mockImplementation(() => void 0); - onTestFinished(() => warn.mockRestore()); - const announced = subscriber.announced(); - const subscription = await acceptSubscribeNamespace(pair.client); - - const epoch = Epoch.mint(); - const send = async (path: Path.Valid, named?: Epoch.Valid) => { - await subscription.writer.u53(SubscribeNamespaceEntry.id); - await new SubscribeNamespaceEntry({ suffix: path, params: { epoch: named } }).encode( - subscription.writer, - VERSION, - ); - }; - const [before, after] = change === "gains" ? [undefined, epoch] : [epoch, undefined]; - await send(Path.from("room"), before); - expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "start" }); - - // Control: a repeat that keeps the epoch, or its absence, is not a violation. - await send(Path.from("room"), before); - await send(Path.from("sentinel"), before); - expect(await announced.next()).toMatchObject({ prefix: Path.from("sentinel"), kind: "start" }); - - await send(Path.from("room"), after); - await violationClosed(pair); -}); - -/** - * Two PUBLISH_NAMESPACE requests travel on independent streams, so a restarted publisher's - * new advertisement can land before its old one's withdrawal. The newest stands throughout. - */ -test("a stale instance's withdrawal arriving last keeps the newer epoch", async () => { - const pair = createMockTransportPair(ALPN.DRAFT_18); - const subscriber = new Subscriber({ session: new NativeSession(pair.server, VERSION, true), epoch: true }); - const announced = subscriber.announced(); - await acceptSubscribeNamespace(pair.client); - - const path = Path.from("room"); - const first = Epoch.mint(); - const old = await publishEpoch(pair, subscriber, path, first); - expect(await announced.next()).toMatchObject({ prefix: path, kind: "start", route: { epoch: first } }); - - const second = Epoch.mint(); - await publishEpoch(pair, subscriber, path, second); - expect(await announced.next()).toMatchObject({ prefix: path, kind: "restart", route: { epoch: second } }); - - old.peer.close(); - await old.handler; - - const next = await Promise.race([ - announced.next(), - new Promise<"nothing">((resolve) => setTimeout(() => resolve("nothing"), 250)), - ]); - expect(next).toBe("nothing"); - - // A fresh reader hears the newer instance as the one standing. - const fresh = subscriber.announced(); - expect(await fresh.next()).toMatchObject({ prefix: path, kind: "start", route: { epoch: second } }); - fresh.close(); - announced.close(); -}); diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 827954c3496..65ca2882234 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -7,7 +7,6 @@ import * as broadcast from "../broadcast.ts"; import type { Drain } from "../connection/goaway.ts"; import { BroadcastCache } from "../consume.ts"; import * as DatagramStream from "../datagram_stream.ts"; -import type * as Epoch from "../epoch.ts"; import { closeError, error, @@ -137,9 +136,6 @@ export class Subscriber { // to us contains. #cluster?: Cluster.Hops; - // Whether both SETUPs offered MoQ Epoch, so advertisements may name their instance. - #epoch: boolean; - // Publisher-chosen aliases used by incoming group streams. #aliases = new TrackAliases(); @@ -158,7 +154,7 @@ export class Subscriber { // two messages about one source, which the MoQ Solicit draft requires us to tolerate. // Tracking each is what keeps the second from duplicating the announce and the first // to end from retracting what the other still holds. A publisher replacing its instance - // (MoQ Epoch) withdraws one advertisement and makes another, on separate streams that + // withdraws one advertisement and makes another, on separate streams that // can arrive in either order, so the newest stands until it goes and an update to an // older one changes nothing. Each live wire advertisement holds an announce slot, // even while its route loops back through us and is absent from this derived map. @@ -200,7 +196,6 @@ export class Subscriber { session, quic, cluster, - epoch = false, hidden = false, maxAge = false, activeCount = false, @@ -215,8 +210,6 @@ export class Subscriber { quic?: WebTransport; /** The Hop IDs the SETUP exchange settled (MoQ Cluster). */ cluster?: Cluster.Hops; - /** Whether both SETUPs offered MoQ Epoch. */ - epoch?: boolean; /** Whether the peer understands the HIDDEN parameter (MoQ Hidden). */ hidden?: boolean; /** Whether the peer accepts subscriber max age. */ @@ -235,7 +228,6 @@ export class Subscriber { this.#quic = quic; this.#announceSlots = new Slots(limits.announces, { quic, reason: "too many announcements" }); this.#cluster = cluster; - this.#epoch = epoch; this.#hidden = hidden; this.#maxAge = maxAge; this.#activeCount = activeCount; @@ -287,12 +279,10 @@ export class Subscriber { } /** - * The route an advertisement carries; one without a path is anonymous and free. Its - * epoch is taken as given, and absent when the advertisement names none. + * The epochless route an advertisement carries; one without a path is anonymous and free. */ - #route({ cluster, epoch }: AdvertParams): Route { - const route = cluster === undefined ? { hops: [UNKNOWN_HOP], cost: Cost.zero } : { ...cluster }; - return epoch === undefined ? route : { ...route, epoch }; + #route({ cluster }: AdvertParams): Route { + return cluster === undefined ? { hops: [UNKNOWN_HOP], cost: Cost.zero } : { ...cluster }; } /** @@ -366,7 +356,7 @@ export class Subscriber { this.#announced.set(path, entry); this.#consumes.announce(path, entry, route); - console.debug(`announced: broadcast=${path} active=true epoch=${route.epoch}`); + console.debug(`announced: broadcast=${path} active=true`); for (const [consumer, filter] of this.#announcedConsumers) { // A consumer that closed stays registered until its request is off the wire. if (!sees(filter, path) || consumer.closed.peek() !== undefined) continue; @@ -381,12 +371,6 @@ export class Subscriber { const existing = this.#announced.get(path); const advert = existing?.adverts.find((advert) => advert.id === id); if (existing === undefined || advert === undefined) return; - // A repeat under another epoch is a newer advertisement, so it becomes the newest; - // one of the same instance stays where it is. - if (advert.route.epoch !== route.epoch) { - existing.adverts.splice(existing.adverts.indexOf(advert), 1); - existing.adverts.push(advert); - } advert.route = route; this.#settleAnnounce(path, existing); } @@ -394,24 +378,21 @@ export class Subscriber { /** * Route a path by its newest live advertisement. A no-op when that changed nothing. * Otherwise consumers hear `update` for new hops or cost, so a forwarder can reprice - * without retracting and the shared consume stays, or `restart` for another publisher - * instance (MoQ Epoch), so the next consume subscribes afresh. + * without retracting, and the shared consume stays. */ #settleAnnounce(path: Path.Valid, existing: Announced) { const newest = existing.adverts.at(-1); if (newest === undefined) return; const route = this.#priced(newest.route); if (routesEqual(existing.route, route)) return; - const restart = existing.route.epoch !== route.epoch; existing.route = route; - this.#consumes.announce(path, existing, route, restart); - console.debug(`announced: broadcast=${path} ${restart ? `restart=true epoch=${route.epoch}` : "rerouted"}`); - const kind = restart ? "restart" : "update"; + this.#consumes.announce(path, existing, route, false); + console.debug(`announced: broadcast=${path} rerouted`); for (const [consumer, filter] of this.#announcedConsumers) { // A consumer that closed stays registered until its request is off the wire. if (!sees(filter, path) || consumer.closed.peek() !== undefined) continue; const scope = filter.scope; - consumer.append({ prefix: path, captures: scopeCaptures(scope, path), kind, route }); + consumer.append({ prefix: path, captures: scopeCaptures(scope, path), kind: "update", route }); } } @@ -463,11 +444,11 @@ export class Subscriber { ): Promise { const version = this.#session.version; - // Suffixes live on this stream, with the advertisement each holds and the epoch it named, + // Suffixes live on this stream, with the advertisement each holds, // so a repeat is recognized as an update to it rather than a second one, which would leak. // An advertisement stays live on the wire until its NAMESPACE_DONE, even while its // route is detached (`id` unset) for looping back through us. - const live = new SlotMap(this.#announceSlots); + const live = new SlotMap(this.#announceSlots); // Set once `release` has given back everything this stream held. The read loop is not // awaited when the local consumer closes first, and the stream keeps delivering what @@ -591,7 +572,6 @@ export class Subscriber { if (msgType === SubscribeNamespaceEntry.id) { const entry = await SubscribeNamespaceEntry.decode(framed.reader, version, { cluster: Cluster.negotiated(this.#cluster), - epoch: this.#epoch, }); // Counted even when discarded below. if (initial > 0) initial -= 1; @@ -601,15 +581,9 @@ export class Subscriber { const params = entry.params ?? {}; const held = live.get(path); - // A repeat under another epoch replaces the instance in place, but one - // that gains or loses an epoch skipped the NAMESPACE_DONE it needs. - if (held !== undefined && (held.epoch === undefined) !== (params.epoch === undefined)) { - throw new ProtocolViolation(`NAMESPACE repeated gaining or losing an epoch: ${path}`); - } - // The wire still holds a reflected advertisement until DONE, so charge // it before deciding whether its route can be used. Repeats keep the slot. - if (held === undefined) live.set(path, { epoch: params.epoch }); + if (held === undefined) live.set(path, {}); // A repeat updates the advertisement in place, so one that now // loops back through us has taken a route we can't subscribe @@ -617,19 +591,16 @@ export class Subscriber { if (this.#reflected(params.cluster)) { console.debug(`dropping reflected namespace: broadcast=${path}`); if (held?.id !== undefined) this.#detachAnnounce(path, held.id); - live.set(path, { epoch: params.epoch }); + live.set(path, {}); continue; } - // A repeat replaces the advertisement in place: HOP_PATH / ROUTE_COST - // can change without a NAMESPACE_DONE, and another epoch restarts the - // path without it ever going absent. Only the first is a new path. + // A repeat updates HOP_PATH and ROUTE_COST in place. Only the first is a new path. const route = this.#route(params); if (held?.id !== undefined) { - held.epoch = params.epoch; this.#updateAnnounce(path, held.id, route); } else { - live.set(path, { id: this.#attachAnnounce(path, route), epoch: params.epoch }); + live.set(path, { id: this.#attachAnnounce(path, route) }); } } else if (msgType === SubscribeNamespaceEntryDone.id) { const entry = await SubscribeNamespaceEntryDone.decode(framed.reader, version); @@ -696,9 +667,7 @@ export class Subscriber { for (;;) { const request = await wireOf(consumer).requested(); if (!request) break; - // Every request names the instance the consume resolved, so the peer refuses - // rather than answers from another. - void this.#runSubscribe(path, request, instance.validity?.signal, instance.route.epoch); + void this.#runSubscribe(path, request, instance.validity?.signal); } })(); return this.#consumes.insert(path, instance, consumer); @@ -733,7 +702,7 @@ export class Subscriber { ]); } - async #runSubscribe(broadcast: Path.Valid, request: track.Request, valid?: AbortSignal, epoch?: Epoch.Valid) { + async #runSubscribe(broadcast: Path.Valid, request: track.Request, valid?: AbortSignal) { const refused = unauthorized(broadcast); try { for (;;) { @@ -779,7 +748,7 @@ export class Subscriber { // and any registration if setup eventually finishes. const state: SubscribeSetupState = {}; const deadline = new Deadline(); - const setup = this.#openSubscribe(state, broadcast, request, requestId, deadline, epoch); + const setup = this.#openSubscribe(state, broadcast, request, requestId, deadline); // The publisher can be serving before it answers, so waiting only on the response // would miss the local side going away and leave it serving a track nobody reads. @@ -1091,7 +1060,6 @@ export class Subscriber { request: track.Request, requestId: bigint, deadline: Deadline, - epoch: Epoch.Valid | undefined, ): Promise<{ stream: RequestStream; subscription: Subscription; @@ -1128,7 +1096,6 @@ export class Subscriber { // object, and that mid-group stream is dropped, degrading the join to the next // group boundary. filter: joinFilter(version), - epoch, }); await Message.write(state.stream.writer, Subscribe.id, msg, version); state.sent = true; @@ -1195,7 +1162,7 @@ export class Subscriber { staged.commit(); try { - this.#aliases.set(ok.trackAlias, subscription, { broadcast, name: request.name, epoch }); + this.#aliases.set(ok.trackAlias, subscription, { broadcast, name: request.name }); const timescale = ok.properties.timescale; if (timescale !== undefined) { this.#timescales.set(ok.trackAlias, timescale); @@ -1254,8 +1221,6 @@ export class Subscriber { // stream ends. this.#announceSlots.acquire(); let attached: number | undefined; - // Fixed for the advertisement's life: no REQUEST_UPDATE carries one. - const epoch = msg.params.epoch; try { // Send OK first. This must complete before notifying consumers, @@ -1328,9 +1293,9 @@ export class Subscriber { } } else if (attached === undefined) { // Re-attach: a clean path replaced the reflected one we detached from. - attached = this.#attachAnnounce(path, this.#route({ cluster: held, epoch })); + attached = this.#attachAnnounce(path, this.#route({ cluster: held })); } else { - this.#updateAnnounce(path, attached, this.#route({ cluster: held, epoch })); + this.#updateAnnounce(path, attached, this.#route({ cluster: held })); } // Nothing here can fail to apply, so every update is acknowledged. A leaf diff --git a/js/net/src/ietf/track.ts b/js/net/src/ietf/track.ts index 938c6cf9cdb..80522b04f32 100644 --- a/js/net/src/ietf/track.ts +++ b/js/net/src/ietf/track.ts @@ -1,4 +1,3 @@ -import type * as Epoch from "../epoch.ts"; import type * as Path from "../path.ts"; import type { Reader, Writer } from "../stream.ts"; import * as Filter from "./filter.ts"; @@ -18,27 +17,22 @@ export class TrackStatusRequest { trackName: string; /** Whether the requester wants Track Properties on the answer (INCLUDE_PROPERTIES, draft-22). */ propertiesWanted: boolean; - /** The publisher instance the requester expects (EPOCH, MoQ Epoch). */ - epoch?: Epoch.Valid; constructor({ requestId, trackNamespace, trackName, propertiesWanted = true, - epoch, }: { requestId: bigint; trackNamespace: Path.Valid; trackName: string; propertiesWanted?: boolean; - epoch?: Epoch.Valid; }) { this.requestId = requestId; this.trackNamespace = trackNamespace; this.trackName = trackName; this.propertiesWanted = propertiesWanted; - this.epoch = epoch; } async #encode(w: Writer, version: IetfVersion): Promise { @@ -49,7 +43,6 @@ export class TrackStatusRequest { // INCLUDE_PROPERTIES as unknown, which is a protocol violation. const params = new Parameters(); if (!this.propertiesWanted && Filter.isDraft22(version)) params.includeProperties = false; - params.epoch = this.epoch; await params.encode(w, version); } @@ -63,8 +56,8 @@ export class TrackStatusRequest { * subscription for it to configure. */ static async decode(r: Reader, version: IetfVersion): Promise { - const { requestId, trackNamespace, trackName, propertiesWanted, epoch } = await Subscribe.decode(r, version); - return new TrackStatusRequest({ requestId, trackNamespace, trackName, propertiesWanted, epoch }); + const { requestId, trackNamespace, trackName, propertiesWanted } = await Subscribe.decode(r, version); + return new TrackStatusRequest({ requestId, trackNamespace, trackName, propertiesWanted }); } } diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index 86018d5e628..fdc6ba30bfe 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -859,7 +859,6 @@ impl Default for Messages { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, }, ietf_ok: ietf::SubscribeOk { track_alias: 5, @@ -1011,7 +1010,6 @@ pub fn encode_subscribe(broadcast: &Path, track: &str, ietf: bool, out: &mut Vec authorization_token: None, new_group: None, max_age: None, - epoch: None, } .encode(w, BENCH_IETF) .unwrap(), @@ -1336,7 +1334,6 @@ pub fn seeds() -> Vec { range_filters: false, fill_timeout: false, properties_wanted: true, - epoch: None, }; fetch.encode_bytes(*version).ok() }) else { diff --git a/rs/moq-net/src/ietf/epoch.rs b/rs/moq-net/src/ietf/epoch.rs deleted file mode 100644 index 34fbe717a93..00000000000 --- a/rs/moq-net/src/ietf/epoch.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! The MoQ Epoch extension (draft-lcurley-moq-epoch-00). -//! -//! moq-transport names content by namespace alone, so a publisher that restarts at the -//! same namespace looks like the same broadcast to every relay. This extension carries -//! the publisher instance, the [`crate::Epoch`] moq-lite 07 puts on its routes and -//! requests, as an EPOCH parameter: on each advertisement (PUBLISH_NAMESPACE and -//! NAMESPACE), and on each request that resolves one (SUBSCRIBE, FETCH, and -//! TRACK_STATUS). A namespace and an EPOCH name one broadcast; a request carrying one -//! is refused rather than served by another instance. -//! -//! Negotiated only when both endpoints declare the option. - -use crate::Epoch; -use crate::coding::{DecodeError, Decoder, EncodeError, Encoder}; - -use super::{Param, Version}; - -/// EPOCH Setup Option: the sender understands the EPOCH parameter. Even, so the value -/// is a bare varint. -pub const EPOCH: u64 = 0x40B68; - -/// EPOCH message parameter: the publisher instance, as a UUIDv7's 16 bytes. Odd, so the -/// value is length-prefixed. -pub const EPOCH_PARAM: u64 = 0x40B69; - -/// Whether the peer declared the option. -pub fn from_setup(params: &super::Parameters) -> bool { - params.get_varint(super::ParameterVarInt::Epoch).is_some() -} - -/// Declare that we understand the EPOCH parameter. -pub fn into_setup(params: &mut super::Parameters) { - params.set_varint(super::ParameterVarInt::Epoch, 1); -} - -impl Param for Epoch { - fn param_encode(&self, w: &mut Encoder<'_>, _version: Version) -> Result<(), EncodeError> { - w.bytes(&self.to_bytes()) - } - - /// Anything but a UUIDv7's 16 bytes is malformed. - fn param_decode(r: &mut Decoder<'_>, _version: Version) -> Result { - Epoch::from_bytes(r.bytes()?).map_err(|_| DecodeError::InvalidValue) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const VERSION: Version = Version::Draft18; - - #[test] - fn code_point_parity() { - // The parity is load-bearing: odd values are length-prefixed, even ones bare varints. - assert_eq!(EPOCH % 2, 0); - assert_eq!(EPOCH_PARAM % 2, 1); - } - - #[test] - fn declared_option_negotiates() { - let mut params = super::super::Parameters::default(); - assert!(!from_setup(¶ms)); - into_setup(&mut params); - assert!(from_setup(¶ms)); - } - - #[test] - fn round_trips_and_refuses_anything_but_a_uuidv7() { - let epoch = Epoch::mint(); - let mut buf = Vec::new(); - epoch - .param_encode(&mut Encoder::new(&mut buf, VERSION.into()), VERSION) - .unwrap(); - assert_eq!(buf.len(), 17, "a length byte, then the 16 bytes"); - let mut bytes = bytes::Bytes::from(buf); - let decoded = crate::coding::decode_buf(&mut bytes, VERSION, Epoch::param_decode).unwrap(); - assert_eq!(decoded, epoch); - - for value in [ - Vec::new(), - epoch.to_bytes()[..15].to_vec(), - uuid::Builder::from_random_bytes([7; 16]) - .into_uuid() - .as_bytes() - .to_vec(), - ] { - let mut buf = Vec::new(); - Encoder::new(&mut buf, VERSION.into()).bytes(&value).unwrap(); - let mut bytes = bytes::Bytes::from(buf); - assert!( - crate::coding::decode_buf(&mut bytes, VERSION, Epoch::param_decode).is_err(), - "{value:?}" - ); - } - } -} diff --git a/rs/moq-net/src/ietf/fetch.rs b/rs/moq-net/src/ietf/fetch.rs index 136dd2df9f1..190afe5a670 100644 --- a/rs/moq-net/src/ietf/fetch.rs +++ b/rs/moq-net/src/ietf/fetch.rs @@ -10,7 +10,6 @@ use crate::{ }; use super::Message; -use super::epoch; use super::Version; @@ -135,9 +134,6 @@ pub struct Fetch<'a> { /// Whether the requester wants Track Properties in FETCH_OK (INCLUDE_PROPERTIES, /// draft-22). Always `true` before draft-22, which has no way to opt out. pub properties_wanted: bool, - /// The publisher instance the requester expects (MoQ Epoch). Only on a - /// request that names its track: a joining FETCH takes its subscription's. - pub epoch: Option, } impl Fetch<'_> { @@ -187,7 +183,6 @@ impl Fetch<'_> { range_filters: self.range_filters, fill_timeout: self.fill_timeout, properties_wanted: self.properties_wanted, - epoch: self.epoch, } } } @@ -205,11 +200,9 @@ impl<'a> Message<'a> for Fetch<'a> { match version { Version::Draft18 => { self.fetch_type.encode(w, version)?; - let epoch = self.epoch.clone(); encode_params!(w, version, 0x20 => self.subscriber_priority, 0x22 => group_order, - epoch::EPOCH_PARAM => epoch, ); } _ => { @@ -230,7 +223,6 @@ impl<'a> Message<'a> for Fetch<'a> { 0x21 => *filter, 0x22 => group_order, 0x35 => include_properties, - epoch::EPOCH_PARAM => self.epoch.clone(), ); } } @@ -241,7 +233,7 @@ impl<'a> Message<'a> for Fetch<'a> { let request_id = RequestId::decode(buf, version)?; // The token decodes but grants nothing: the session's grant is what authorizes the request. - let (fetch_type, subscriber_priority, group_order, range_filters, fill_timeout, properties_wanted, epoch) = + let (fetch_type, subscriber_priority, group_order, range_filters, fill_timeout, properties_wanted) = match version { Version::Draft18 => { let fetch_type = FetchType::decode(buf, version)?; @@ -250,7 +242,6 @@ impl<'a> Message<'a> for Fetch<'a> { 0x0A => fill_timeout: Option, 0x20 => subscriber_priority: Option, 0x22 => group_order: Option, - epoch::EPOCH_PARAM => epoch: Option, ); super::token::single(authorization_token)?; @@ -261,7 +252,6 @@ impl<'a> Message<'a> for Fetch<'a> { false, fill_timeout.is_some(), true, - epoch, ) } // Draft-22 names the track up front and moves the range into LOCATION_FILTER. @@ -279,7 +269,6 @@ impl<'a> Message<'a> for Fetch<'a> { 0x27 => priority_filter: Vec, 0x28 => object_property_filter: Vec, 0x35 => include_properties: Option, - epoch::EPOCH_PARAM => epoch: Option, ); super::token::single(authorization_token)?; let range_filters = [ @@ -305,20 +294,10 @@ impl<'a> Message<'a> for Fetch<'a> { fill_timeout.is_some(), // Defaults to 1, so an absent parameter means the requester wants them. include_properties.unwrap_or(true), - epoch, ) } }; - // A joining FETCH is served by its subscription's instance; naming one is malformed. - if epoch.is_some() - && matches!( - fetch_type, - FetchType::RelativeJoining { .. } | FetchType::AbsoluteJoining { .. } - ) { - return Err(DecodeError::InvalidValue); - } - Ok(Self { request_id, subscriber_priority: subscriber_priority.unwrap_or(super::priority::DEFAULT_WIRE), @@ -328,7 +307,6 @@ impl<'a> Message<'a> for Fetch<'a> { range_filters, fill_timeout, properties_wanted, - epoch, }) } } @@ -627,7 +605,6 @@ mod tests { range_filters: false, fill_timeout: false, properties_wanted: true, - epoch: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -735,7 +712,6 @@ mod tests { range_filters: false, fill_timeout: false, properties_wanted: true, - epoch: None, }; #[rustfmt::skip] diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index dd187e8f0bb..a194131d424 100644 --- a/rs/moq-net/src/ietf/mod.rs +++ b/rs/moq-net/src/ietf/mod.rs @@ -21,7 +21,6 @@ pub mod cluster; #[cfg(feature = "async")] mod control; mod datagram; -pub(crate) mod epoch; pub(crate) mod error; mod fetch; mod filter; @@ -98,7 +97,6 @@ mod tests { ("HIDDEN", hidden::HIDDEN, 0x40B5C), ("AUTH", auth::AUTH, 0x40B70), ("ACTIVE_COUNT", active_count::ACTIVE_COUNT, 0x40B64), - ("EPOCH", epoch::EPOCH, 0x40B68), ] { assert_eq!(key, spec, "{name}"); } @@ -112,7 +110,6 @@ mod tests { ("ROUTE_COST", cluster::ROUTE_COST, 0x40B58), ("HIDDEN", hidden::HIDDEN_PARAM, 0x40B5E), ("ACTIVE_COUNT", active_count::ACTIVE_COUNT_PARAM, 0x40B66), - ("EPOCH", epoch::EPOCH_PARAM, 0x40B69), ] { assert_eq!(key, spec, "{name}"); } diff --git a/rs/moq-net/src/ietf/parameters.rs b/rs/moq-net/src/ietf/parameters.rs index e84544c4739..1d6531bc429 100644 --- a/rs/moq-net/src/ietf/parameters.rs +++ b/rs/moq-net/src/ietf/parameters.rs @@ -31,8 +31,6 @@ pub enum ParameterVarInt { ActiveCount = super::active_count::ACTIVE_COUNT, /// Subscriber max age, from the MoQ Timestamp extension. MaxAge = super::max_age::OPTION, - /// EPOCH, from the MoQ Epoch extension. - Epoch = super::epoch::EPOCH, /// DEFLATE-compressed tracks, from the MoQ Flate extension. Flate = super::flate::OPTION, #[num_enum(catch_all)] diff --git a/rs/moq-net/src/ietf/peer.rs b/rs/moq-net/src/ietf/peer.rs index 415dc86825c..70bd6103560 100644 --- a/rs/moq-net/src/ietf/peer.rs +++ b/rs/moq-net/src/ietf/peer.rs @@ -30,10 +30,6 @@ pub(crate) struct Peer { /// NAMESPACE messages before the subscription is synced, in both directions. pub active_count: bool, - /// MoQ Epoch: whether both sides offered it, so advertisements and requests carry the - /// publisher's epoch in both directions. - pub epoch: bool, - /// Whether this peer offered DEFLATE-compressed tracks. pub flate: bool, } @@ -47,8 +43,6 @@ pub(crate) struct Peer { #[derive(Clone, Default)] pub(crate) struct PeerSetup { slot: kio::Shared>, - /// What we offered, which an extension negotiated by both sides needs as well. - declined_epoch: bool, /// Where the peer's advertised implementation is published for the session handle. implementation: crate::setup::PeerImplementation, /// The session's auth link to tell once the peer's SETUP accepts the connection. @@ -64,16 +58,6 @@ impl PeerSetup { setup } - /// A slot for a session that offered `offered`. - pub fn new(offered: crate::setup::Extensions) -> Self { - Self { - slot: Default::default(), - declined_epoch: !offered.epoch, - implementation: Default::default(), - accept: None, - } - } - /// Tell `link` once the peer's SETUP arrives that it accepted the connection credential. pub fn accepting(mut self, link: crate::auth::Link) -> Self { link.await_setup(); @@ -99,8 +83,7 @@ impl PeerSetup { /// lifetime while subscription serving re-reads it, so letting a later SETUP /// overwrite the identity would advertise under one exclusion and serve under /// another, which is how a routing loop gets back in. - pub fn set(&self, mut peer: Peer) { - peer.epoch &= !self.declined_epoch; + pub fn set(&self, peer: Peer) { let mut slot = self.slot.lock(); if slot.is_some() { return; @@ -152,7 +135,6 @@ mod tests { max_age: false, auth: false, active_count: false, - epoch: false, flate: false, }; @@ -168,30 +150,9 @@ mod tests { max_age: false, auth: true, active_count: true, - epoch: true, flate: true, }); assert_eq!(slot.get().await, first); } - - /// MoQ Epoch needs both sides: a peer declaring it to a side that declined leaves it off. - #[moq_clock::test] - async fn epoch_needs_our_offer_too() { - let declared = Peer { - epoch: true, - ..Default::default() - }; - - let offered = PeerSetup::new(crate::setup::Extensions::default()); - offered.set(declared); - assert!(offered.get().await.epoch); - - let declined = PeerSetup::new(crate::setup::Extensions { - epoch: false, - ..Default::default() - }); - declined.set(declared); - assert!(!declined.get().await.epoch); - } } diff --git a/rs/moq-net/src/ietf/publish_namespace.rs b/rs/moq-net/src/ietf/publish_namespace.rs index 8dfa0ade274..48ca693f26e 100644 --- a/rs/moq-net/src/ietf/publish_namespace.rs +++ b/rs/moq-net/src/ietf/publish_namespace.rs @@ -1,11 +1,11 @@ //! IETF moq-transport publish namespace messages -use crate::{Epoch, Path, coding::*, ietf::RequestId}; +use super::cluster; +use crate::{Path, coding::*, ietf::RequestId}; use super::Message; use super::namespace::{decode_namespace, encode_namespace}; use super::token::RequestToken; -use super::{cluster, epoch}; use super::Version; @@ -15,14 +15,12 @@ use super::Version; pub struct Negotiated { /// MoQ Cluster: every advertisement carries HOP_PATH, and may carry ROUTE_COST. pub cluster: bool, - /// MoQ Epoch: an advertisement may carry EPOCH. - pub epoch: bool, } impl Negotiated { /// Whether NAMESPACE takes the extended form, with a Parameters field. pub fn extended(&self) -> bool { - self.cluster || self.epoch + self.cluster } } @@ -32,10 +30,6 @@ pub struct AdvertParams { /// The MoQ Cluster parameters (see [`cluster`]). `Some` exactly on a session that /// negotiated the extension. pub cluster: Option, - - /// The MoQ Epoch parameter: the publisher instance the route serves, if it names one. - /// Only on a session that negotiated the extension. - pub epoch: Option, } /// PublishNamespace message (0x06) @@ -69,11 +63,9 @@ impl PublishNamespace { 0x03 => authorization_token: Vec, cluster::HOP_PATH => hops: Option, cluster::ROUTE_COST => cost: Option, - epoch::EPOCH_PARAM => epoch: Option where negotiated.epoch, ); let params = AdvertParams { cluster: cluster_params(negotiated.cluster, hops, cost)?, - epoch, }; // Last, so a malformed field closes the session before a token refusal. let authorization_token = super::token::single(authorization_token)?; @@ -104,7 +96,6 @@ impl<'a> Message<'a> for PublishNamespace { 0x03 => self.authorization_token.clone(), cluster::HOP_PATH => hops, cluster::ROUTE_COST => cost, - epoch::EPOCH_PARAM => self.params.epoch.clone(), ); Ok(()) } @@ -218,7 +209,6 @@ pub(super) fn encode_advert_params( encode_params!(w, version, cluster::HOP_PATH => hops, cluster::ROUTE_COST => cost, - epoch::EPOCH_PARAM => params.epoch.clone(), ); Ok(()) } @@ -232,12 +222,10 @@ pub(super) fn decode_advert_params( decode_params!(r, version, cluster::HOP_PATH => hops: Option, cluster::ROUTE_COST => cost: Option, - epoch::EPOCH_PARAM => epoch: Option where negotiated.epoch, ); Ok(AdvertParams { cluster: cluster_params(negotiated.cluster, hops, cost)?, - epoch, }) } diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 62e6c8aaada..68e8e3f030d 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -539,9 +539,7 @@ enum Advert { /// Nothing: every route loops through the peer or us, or none is announced. #[default] None, - /// The namespace, with the parameters of the extensions the peer negotiated: the path - /// it traversed and that path's accumulated cost (MoQ Cluster), and the instance it - /// serves (MoQ Epoch). Empty when the peer negotiated neither. + /// The namespace, with its hop path and accumulated cost when MoQ Cluster is negotiated. Some(ietf::AdvertParams), } @@ -658,9 +656,6 @@ struct Namespaces { epoch: u64, /// Whether this discovery cursor includes hidden namespaces. hidden: bool, - /// Whether the peer negotiated MoQ Epoch, so an advertisement names its route's - /// instance. - epochs: bool, } impl Namespaces { @@ -677,7 +672,6 @@ impl Namespaces { gate: None, epoch: 0, hidden: false, - epochs: false, } } @@ -685,7 +679,6 @@ impl Namespaces { fn negotiated(&self) -> ietf::Negotiated { ietf::Negotiated { cluster: self.peer.negotiated(), - epoch: self.epochs, } } @@ -982,7 +975,6 @@ impl Publisher { &self, stream: &mut Stream, path: &crate::Path, - epoch: Option, ) -> Result, Error> { let Some(origin) = self .until_withdrawn(stream, |waiter| self.poll_serving_origin(waiter)) @@ -991,7 +983,7 @@ impl Publisher { return Ok(None); }; // The immediate lookup: answering a peer never starts discovery of its own. - let requesting = origin.lookup(path.clone(), epoch.as_ref()); + let requesting = origin.lookup(path.clone(), None); self.until_withdrawn(stream, |waiter| requesting.poll_ok(waiter)) .await .transpose() @@ -1029,42 +1021,32 @@ impl Publisher { /// /// The origin's announce cursor already filters routes through the peer /// (control-plane split horizon); this only shapes what the wire can carry. - /// `epochs` says the peer negotiated MoQ Epoch, so the route's instance travels too. - fn select(&self, route: &crate::origin::Route, peer: &cluster::Peer, epochs: bool) -> Advert { + fn select(&self, route: &crate::origin::Route, peer: &cluster::Peer) -> Advert { // A route that already passed through us is a reflection. The origin // filters these on receive, so this is defensive. if self.self_origin != crate::Hop::UNKNOWN && route.hops.contains(&self.self_origin) { return Advert::None; } - // Forwarded unchanged, and never invented for a route that arrived without one. - let epoch = route.epoch.clone().filter(|_| epochs); if !peer.negotiated() { - return Advert::Some(ietf::AdvertParams { cluster: None, epoch }); + return Advert::Some(ietf::AdvertParams { cluster: None }); } let cost = route.cost.value(); // Our own Hop ID is always the last entry, so the peer reconstructs the full // path. A chain with no room left is a loop in all but name. match cluster::Advert::forward(&route.hops, cost, self.self_origin) { - Ok(advert) => Advert::Some(ietf::AdvertParams { - cluster: Some(advert), - epoch, - }), + Ok(advert) => Advert::Some(ietf::AdvertParams { cluster: Some(advert) }), Err(_) => Advert::None, } } /// Handle an incoming bidi stream dispatched by the session. - /// - /// `epochs` says both sides negotiated MoQ Epoch: a request naming an instance on a - /// session that did not breaks the session. pub fn handle_stream( &self, id: u64, body: ietf::Body, stream: Stream, - epochs: bool, ) -> Result, Error> { let this = self.clone(); let mut data = body.decoder(this.version); @@ -1088,9 +1070,6 @@ impl Publisher { if !data.is_empty() { return Err(Error::WrongSize); } - if msg.epoch.is_some() && !epochs { - return Err(Error::ProtocolViolation); - } tracing::debug!(message = ?msg, "received subscribe"); let gate = crate::auth::Gate::new( this.auth.clone(), @@ -1120,9 +1099,6 @@ impl Publisher { if !data.is_empty() { return Err(Error::WrongSize); } - if msg.epoch.is_some() && !epochs { - return Err(Error::ProtocolViolation); - } tracing::debug!(message = ?msg, "received fetch"); let gate = this.fetch_gate(&msg); // Owned, since the task outlives the body it was read from. @@ -1207,9 +1183,6 @@ impl Publisher { if !data.is_empty() { return Err(Error::WrongSize); } - if msg.epoch.is_some() && !epochs { - return Err(Error::ProtocolViolation); - } tracing::debug!(message = ?msg, "received track_status"); let gate = crate::auth::Gate::new( this.auth.clone(), @@ -1393,9 +1366,7 @@ impl Publisher { // releasing what it holds upstream, and only then is the peer told. `acked` holds the // group stream count once SUBSCRIBE_OK is buffered, which decides how. let mut acked: Option> = None; - // An instance never comes back, so a request pinned to a gone one is missing, not - // worth waiting for. - let rendezvous = msg.rendezvous && msg.epoch.is_none(); + let rendezvous = msg.rendezvous; let subscribed = { let serve = async { // Stats (subscriptions, viewer refcount, groups/frames/bytes) are counted in @@ -1409,9 +1380,8 @@ impl Publisher { }; // We just received a subscribe for this exact namespace, so the peer must have // already seen the announcement. The immediate lookup resolves it at once, or - // falls back to the route covering it (an `origin::Requests`), if any. Pinned to - // the instance it names, if any: another is refused, never served. - let broadcast = match self.resolve(&mut stream, &msg.track_namespace, msg.epoch.clone()).await { + // falls back to the route covering it (an `origin::Requests`), if any. + let broadcast = match self.resolve(&mut stream, &msg.track_namespace).await { Ok(Some(broadcast)) => broadcast, // The reason is the origin's, not ours: a dynamic router refusing on // authorization or a drain says so, and only an unroutable path means the @@ -2809,9 +2779,6 @@ impl Publisher { .await; } - // Pinned to the instance it names, if any: another is refused, never served. - let epoch = msg.epoch.clone(); - // Draft-22's LOCATION_FILTER is answered as the standalone range it spells. Every other // filter ends at the Largest Object, which resolves once the track does. let mut largest_filter = None; @@ -2888,7 +2855,7 @@ impl Publisher { // The peer must have seen the announcement to name this namespace, so this // resolves like a SUBSCRIBE does. - let broadcast = match gate.guard(self.resolve(&mut stream, &namespace, epoch)).await { + let broadcast = match gate.guard(self.resolve(&mut stream, &namespace)).await { Ok(Some(broadcast)) => broadcast, Ok(None) => return Ok(()), Err(err) => return self.reject_fetch(stream, &err, &err.to_string()).await, @@ -3296,10 +3263,7 @@ impl Publisher { if !gate.allows() { return self.reject_track_status(stream, &Error::Unauthorized).await; } - let broadcast = match gate - .guard(self.resolve(&mut stream, &msg.track_namespace, msg.epoch.clone())) - .await - { + let broadcast = match gate.guard(self.resolve(&mut stream, &msg.track_namespace)).await { Ok(Some(broadcast)) => broadcast, Ok(None) => return Ok(()), Err(err) => return self.reject_track_status(stream, &err).await, @@ -3419,18 +3383,10 @@ impl Publisher { let request = requests.get_mut(suffix); let held_auth = request.as_ref().and_then(|request| request.auth.clone()); let unchanged = advert == watch.sent && auth == held_auth; - // The epoch names the advertisement's instance for as long as it stands, so an - // update never changes it (MoQ Epoch). One instance named by an epoch is replaced - // by another in place, the new advertised before the old is withdrawn, so the peer - // never sees the namespace absent. Gaining or losing an epoch withdraws and - // advertises afresh. - let (held_epoch, epoch) = (watch.sent.params().epoch, advert.params().epoch); - let renamed = wanted && held && held_epoch != epoch; - let replaced = renamed && held_epoch.is_some() && epoch.is_some(); // moq-transport renames a request's auth with REQUEST_UPDATE, but cannot name one on // a request that had none, nor drop one: those withdraw the request and make it // again. - let restart = renamed || held_auth != auth && (held_auth.is_none() || auth.is_none()); + let restart = held_auth != auth && (held_auth.is_none() || auth.is_none()); // An update already waiting or in flight carries whatever is current once it goes // out, and its answer brings the loop back here for anything after that. @@ -3475,21 +3431,8 @@ impl Publisher { } self.withdraw_namespace(target, requests, suffix.clone()).await?; } - (true, Some(_)) if replaced => { - // Separate streams are unordered, so the old one goes only once the - // peer answered the new one, refused or not: it serves another - // instance either way. - tracing::debug!(broadcast = %absolute, "publish_namespace for another epoch"); - let old = requests.remove(suffix); - refused = self - .advertise_namespace(requests, watch, path, suffix.clone(), advert.params(), auth) - .await?; - if let Some(old) = old { - end_request(old.stream); - } - } (true, Some(_)) => { - tracing::debug!(broadcast = %absolute, renamed, "publish_namespace restart"); + tracing::debug!(broadcast = %absolute, "publish_namespace restart"); self.withdraw_namespace(target, requests, suffix.clone()).await?; refused = self .advertise_namespace(requests, watch, path, suffix.clone(), advert.params(), auth) @@ -3517,14 +3460,6 @@ impl Publisher { Target::Inline(stream) => { match (advert.wanted(), held) { (true, _) => { - // A repeat under another epoch replaces the instance in place. - if renamed && !replaced { - tracing::debug!(broadcast = %absolute, "namespace_done for an epoch gained or lost"); - stream - .writer - .encode_message(&ietf::NamespaceDone { suffix: suffix.clone() }) - .await?; - } tracing::debug!(broadcast = %absolute, "namespace"); stream .writer @@ -3676,7 +3611,7 @@ impl Publisher { ) -> Option<(Advert, Option)> { let watch = ns.watched.get(suffix)?; let advert = match ns.permitted(path) { - true => self.select(&watch.route, &ns.peer, ns.epochs), + true => self.select(&watch.route, &ns.peer), false => Advert::None, }; let auth = match (&ns.target, advert.wanted()) { @@ -3940,8 +3875,7 @@ impl Publisher { /// /// Unlike a request, an advertisement refused for credit comes back on the retry timer: /// it is our own state the peer should hold, and credit returning raises no signal the - /// loop could wait on. A replacement under a new epoch needs this too, since the - /// instance it replaces still holds its stream when the new one opens. + /// loop could wait on. async fn open_request(&self) -> Result>, Error> { let mut session = self.session.clone(); let mut open = std::pin::pin!(Stream::open_request(&mut session, self.version)); @@ -4049,8 +3983,7 @@ impl Publisher { .advertising(self.origin.clone(), &peer) .discovery(!self.peer_setup.get().await.hidden || self.origin.includes_hidden()); - let mut ns = Namespaces::new(peer, Target::Requests); - ns.epochs = self.peer_setup.get().await.epoch; + let ns = Namespaces::new(peer, Target::Requests); self.run_namespaces(origin.announced(), crate::Path::empty(), ns, Vec::new()) .await } @@ -4135,7 +4068,7 @@ impl Publisher { let count = initial .iter() .filter(|update| permit.matches(prefix.rejoin(&update.prefix).as_str())) - .filter(|update| self.select(&update.route, &peer, declared.epoch).wanted()) + .filter(|update| self.select(&update.route, &peer).wanted()) .count(); (initial, Some(count as u64)) } @@ -4153,7 +4086,6 @@ impl Publisher { let mut ns = Namespaces::new(peer, target); ns.gate = Some(gate); ns.hidden = !declared.hidden || msg.hidden || self.origin.includes_hidden(); - ns.epochs = declared.epoch; self.run_namespaces(announced, prefix, ns, initial).await } @@ -4249,11 +4181,11 @@ impl Publisher { let event = { let Namespaces { target, - epoch, requests, inflight, woken, updates, + epoch, gate, .. } = &mut ns; @@ -4414,10 +4346,8 @@ impl Publisher { } NamespaceEvent::Update(Some((update, active, restart))) => { // moq-transport has no restart: the peer sees the namespace end and start - // again, and resubscribes. One that tells instances apart (MoQ Epoch) takes - // another epoch in place instead, which `sync_namespace` makes before it - // breaks the old. - if restart && !Self::replaces(&ns, &update) { + // again, and resubscribes. + if restart { self.apply_update(&mut ns, &prefix, update.clone(), false).await?; } self.apply_update(&mut ns, &prefix, update, active).await?; @@ -4431,16 +4361,6 @@ impl Publisher { res } - /// Whether a restart onto `update` replaces one instance the peer holds by epoch with - /// another, rather than ending the advertisement. - fn replaces(ns: &Namespaces, update: &crate::announce::Announce) -> bool { - let held = ns - .watched - .get(&update.prefix) - .and_then(|watch| watch.sent.params().epoch); - ns.epochs && held.is_some() && update.route.epoch.is_some() && held != update.route.epoch - } - /// Reconcile what the peer holds with one route (un)announce. async fn apply_update( &self, @@ -7186,7 +7106,6 @@ mod serve_tests { range_filters: false, fill_timeout: false, properties_wanted: true, - epoch: None, } .encode_msg(&mut Encoder::new(&mut fetch_body, version.into()), version) .unwrap(); @@ -7198,10 +7117,7 @@ mod serve_tests { let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) .await .unwrap(); - let task = h - .publisher - .handle_stream(id, ietf::Body(body.clone()), stream, false) - .unwrap(); + let task = h.publisher.handle_stream(id, ietf::Body(body.clone()), stream).unwrap(); assert_eq!(h.publisher.owed.load(Ordering::Relaxed), 1, "count before polling"); drop(task); assert_eq!(h.publisher.owed.load(Ordering::Relaxed), 0, "release a cancelled task"); @@ -7211,7 +7127,7 @@ mod serve_tests { .unwrap(); assert!( h.publisher - .handle_stream(id, ietf::Body(bytes::Bytes::new()), stream, false) + .handle_stream(id, ietf::Body(bytes::Bytes::new()), stream) .is_err() ); assert_eq!(h.publisher.owed.load(Ordering::Relaxed), 0, "release malformed input"); @@ -7220,10 +7136,7 @@ mod serve_tests { let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) .await .unwrap(); - h.publisher - .handle_stream(id, ietf::Body(body), stream, false) - .unwrap() - .await; + h.publisher.handle_stream(id, ietf::Body(body), stream).unwrap().await; assert_eq!(h.publisher.owed.load(Ordering::Relaxed), 0, "release a completed fetch"); } } @@ -8190,12 +8103,7 @@ mod serve_tests { let closed = h .publisher .clone() - .handle_stream( - ietf::TrackStatus::ID, - ietf::Body(bytes::Bytes::from(body)), - stream, - false, - ) + .handle_stream(ietf::TrackStatus::ID, ietf::Body(bytes::Bytes::from(body)), stream) .err(); assert!(matches!(closed, Some(Error::WrongSize)), "{version}: {closed:?}"); } @@ -8514,7 +8422,6 @@ mod serve_tests { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, } } @@ -8597,178 +8504,6 @@ mod serve_tests { } /// The refusal of request [`REQUEST_ID`] as gone. - async fn unroutable(version: Version) -> Vec { - let log = crate::lite::test_transport::Log::default(); - let mut writer = crate::coding::Writer::new( - crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), - version, - ); - writer.varint(ietf::RequestError::ID).await.unwrap(); - writer - .encode(&ietf::RequestError { - error_code: 0x10, - reason_phrase: Error::Unroutable.to_string().into(), - retry_interval: 0, - }) - .await - .unwrap(); - log.writes.lock().unwrap().clone() - } - - /// Each request MoQ Epoch pins to an instance, naming `epoch`, encoded for `version`. - fn pinned_requests(version: Version, epoch: &crate::Epoch) -> [(u64, Vec); 3] { - let mut subscribe = subscribe(Filter::NextObject, None); - subscribe.epoch = Some(epoch.clone()); - let fetch = ietf::Fetch { - request_id: RequestId(REQUEST_ID), - subscriber_priority: 128, - group_order: GroupOrder::Ascending, - fetch_type: fetch_range( - version, - "video", - Location { group: 0, object: 0 }, - Location { group: 0, object: 1 }, - ), - range_filters: false, - fill_timeout: false, - properties_wanted: true, - epoch: Some(epoch.clone()), - }; - let status = ietf::TrackStatus { - request_id: RequestId(REQUEST_ID), - track_namespace: crate::Path::from_static("room"), - track_name: "video".into(), - properties_wanted: true, - epoch: Some(epoch.clone()), - }; - let mut bodies: [Vec; 3] = Default::default(); - subscribe - .encode_msg(&mut Encoder::new(&mut bodies[0], version.into()), version) - .unwrap(); - fetch - .encode_msg(&mut Encoder::new(&mut bodies[1], version.into()), version) - .unwrap(); - status - .encode_msg(&mut Encoder::new(&mut bodies[2], version.into()), version) - .unwrap(); - let [subscribe, fetch, status] = bodies; - [ - (ietf::Subscribe::ID, subscribe), - (ietf::Fetch::ID, fetch), - (ietf::TrackStatus::ID, status), - ] - } - - /// MoQ Epoch pins SUBSCRIBE, FETCH and TRACK_STATUS to the instance they name: the - /// broadcast under the path now being another instance, or one with no epoch at all, - /// refuses them as gone instead of serving another instance's content. - #[moq_clock::test] - async fn a_request_for_another_instance_is_refused() { - for version in [Version::Draft22] { - let served = crate::Epoch::mint(); - let stale = crate::Epoch::mint(); - let pinned = crate::origin::Route::default().with_epoch(served.clone()); - for (name, route, named, refused) in [ - ("another epoch", pinned.clone(), &stale, true), - ("no epoch", crate::origin::Route::default(), &stale, true), - ("its epoch", pinned, &served, false), - ] { - let mut h = serve_route(version, crate::track::Info::timed(), route.clone()); - publish_groups(&mut h, 0); - settle().await; - let expected = unroutable(version).await; - for (id, body) in pinned_requests(version, named) { - let mark = h.log.writes.lock().unwrap().len(); - let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) - .await - .unwrap(); - let mut task = h - .publisher - .clone() - .handle_stream(id, ietf::Body(bytes::Bytes::from(body)), stream, true) - .unwrap(); - // A served SUBSCRIBE parks at the live edge, so poll rather than await. - for _ in 0..50 { - if futures::poll!(task.as_mut()).is_ready() { - break; - } - settle().await; - } - let wire = h.log.writes.lock().unwrap()[mark..].to_vec(); - let found = wire.windows(expected.len()).any(|window| window == expected.as_slice()); - assert_eq!(found, refused, "{version} {name}: request {id:#x}"); - } - } - } - } - - /// A SUBSCRIBE pinned to a gone instance is refused as missing even when it asks to - /// wait: an instance never comes back, so there is nothing to rendezvous with. - #[moq_clock::test] - async fn a_pinned_request_never_waits_for_its_instance() { - for version in [Version::Draft22] { - let stale = crate::Epoch::mint(); - let pinned = crate::origin::Route::default().with_epoch(crate::Epoch::mint()); - for (name, route) in [("another epoch", pinned), ("no epoch", crate::origin::Route::default())] { - let mut h = serve_route(version, crate::track::Info::timed(), route); - publish_groups(&mut h, 0); - settle().await; - let expected = unroutable(version).await; - - // Built by hand: we never send RENDEZVOUS_TIMEOUT ourselves. - let encode = |body: &mut Vec| -> Result<(), crate::coding::EncodeError> { - let w = &mut Encoder::new(body, version.into()); - RequestId(REQUEST_ID).encode(w, version)?; - super::super::namespace::encode_track(w, &crate::Path::from_static("room"), "video")?; - encode_params!(w, version, - 0x04 => 7u64, - super::super::epoch::EPOCH_PARAM => Some(stale.clone()), - ); - Ok(()) - }; - let mut body = Vec::new(); - encode(&mut body).unwrap(); - - let mark = h.log.writes.lock().unwrap().len(); - let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) - .await - .unwrap(); - h.publisher - .clone() - .handle_stream(ietf::Subscribe::ID, ietf::Body(body.into()), stream, true) - .unwrap() - .await; - let wire = h.log.writes.lock().unwrap()[mark..].to_vec(); - assert!( - wire.windows(expected.len()).any(|window| window == expected.as_slice()), - "{version} {name}" - ); - } - } - } - - /// An EPOCH on a request breaks a session that did not negotiate MoQ Epoch: the peer - /// cannot have learned an instance it was never told about. - #[moq_clock::test] - async fn an_epoch_without_the_extension_is_a_violation() { - for version in [Version::Draft22] { - let h = serve(version); - for (id, body) in pinned_requests(version, &crate::Epoch::mint()) { - let stream = Stream::open(&mut crate::driver::Transport::new(h.session.clone()), version) - .await - .unwrap(); - let result = h - .publisher - .clone() - .handle_stream(id, ietf::Body(bytes::Bytes::from(body)), stream, false); - assert!( - matches!(result, Err(Error::ProtocolViolation)), - "{version}: request {id:#x}" - ); - } - } - } - const ALL_VERSIONS: [Version; 2] = [Version::Draft18, Version::Draft22]; fn track_status(namespace: &str, wanted: bool, version: Version) -> Vec { @@ -8777,7 +8512,6 @@ mod serve_tests { track_namespace: crate::Path::new(namespace).unwrap(), track_name: "video".into(), properties_wanted: wanted, - epoch: None, }; let mut body = Vec::new(); msg.encode_msg(&mut Encoder::new(&mut body, version.into()), version) @@ -8804,12 +8538,7 @@ mod serve_tests { let body = track_status("room", wanted, version); h.publisher .clone() - .handle_stream( - ietf::TrackStatus::ID, - ietf::Body(bytes::Bytes::from(body)), - stream, - false, - ) + .handle_stream(ietf::TrackStatus::ID, ietf::Body(bytes::Bytes::from(body)), stream) .unwrap() .await; assert!(h.log.resets().is_empty(), "{version}: the answer was reset"); @@ -8889,12 +8618,7 @@ mod serve_tests { let mut answer = std::pin::pin!( h.publisher .clone() - .handle_stream( - ietf::TrackStatus::ID, - ietf::Body(bytes::Bytes::from(body)), - stream, - false - ) + .handle_stream(ietf::TrackStatus::ID, ietf::Body(bytes::Bytes::from(body)), stream,) .unwrap() ); assert!(futures::poll!(answer.as_mut()).is_pending(), "{version}"); @@ -8980,7 +8704,6 @@ mod serve_tests { track_namespace: crate::Path::from_static("room"), track_name: "video".into(), properties_wanted: true, - epoch: None, }, )); for _ in 0..100 { @@ -9020,12 +8743,7 @@ mod serve_tests { let mark = h.log.writes.lock().unwrap().len(); h.publisher .clone() - .handle_stream( - ietf::TrackStatus::ID, - ietf::Body(bytes::Bytes::from(body)), - stream, - false, - ) + .handle_stream(ietf::TrackStatus::ID, ietf::Body(bytes::Bytes::from(body)), stream) .unwrap() .await; refusal_code(&h.log.writes.lock().unwrap()[mark..], version) @@ -9094,7 +8812,7 @@ mod serve_tests { let mark = h.log.writes.lock().unwrap().len(); h.publisher .clone() - .handle_stream(id, ietf::Body(bytes::Bytes::from(body)), stream, false) + .handle_stream(id, ietf::Body(bytes::Bytes::from(body)), stream) .unwrap_or_else(|e| panic!("{version}: the request closed the session: {e}")) .await; assert!(h.log.resets().is_empty(), "{version}: refusal was reset"); @@ -9140,7 +8858,6 @@ mod serve_tests { properties_wanted: true, range_filters: true, fill_timeout: false, - epoch: None, }, ) .await @@ -9205,7 +8922,7 @@ mod serve_tests { let mut serving = std::pin::pin!( h.publisher .clone() - .handle_stream(ietf::TrackStatus::ID, ietf::Body(body.into()), stream, false) + .handle_stream(ietf::TrackStatus::ID, ietf::Body(body.into()), stream) .unwrap() ); let done = futures::poll!(serving.as_mut()); @@ -9349,7 +9066,6 @@ mod serve_tests { ietf::SubscribeNamespace::ID, ietf::Body(bytes::Bytes::from(body)), stream, - false, ) .unwrap_or_else(|e| panic!("{version}: the request closed the session: {e}")); let mut task = std::pin::pin!(task); @@ -10104,7 +9820,6 @@ mod serve_tests { range_filters: false, fill_timeout: false, properties_wanted: true, - epoch: None, }, ) .await?; @@ -10239,7 +9954,7 @@ mod serve_tests { .unwrap(); let mut serving = h .publisher - .handle_stream(ietf::Subscribe::ID, ietf::Body(bytes::Bytes::from(data)), stream, false) + .handle_stream(ietf::Subscribe::ID, ietf::Body(bytes::Bytes::from(data)), stream) .unwrap(); let mut fetching = Box::pin(joining_fetch(&h, 0)); assert!( @@ -10276,7 +9991,7 @@ mod serve_tests { .unwrap(); let mut serving = h .publisher - .handle_stream(ietf::Subscribe::ID, ietf::Body(bytes::Bytes::from(data)), stream, false) + .handle_stream(ietf::Subscribe::ID, ietf::Body(bytes::Bytes::from(data)), stream) .unwrap(); match futures::future::select(&mut fetching, &mut serving).await { futures::future::Either::Left((response, _)) => { @@ -10301,7 +10016,7 @@ mod serve_tests { .unwrap(); let serving = h .publisher - .handle_stream(ietf::Subscribe::ID, ietf::Body(bytes::Bytes::from(data)), stream, false) + .handle_stream(ietf::Subscribe::ID, ietf::Body(bytes::Bytes::from(data)), stream) .unwrap(); let mut fetching = Box::pin(joining_fetch(&h, 0)); assert!(futures::poll!(fetching.as_mut()).is_pending()); @@ -10427,7 +10142,6 @@ mod serve_tests { range_filters: false, fill_timeout: false, properties_wanted: true, - epoch: None, }, ) .await @@ -10656,7 +10370,6 @@ mod serve_tests { range_filters: false, fill_timeout: false, properties_wanted, - epoch: None, }, ) .await @@ -10863,7 +10576,6 @@ mod serve_tests { ietf::TrackStatus::ID, ietf::Body(track_status("room", true, version).into()), stream, - false, ) .unwrap() .await; @@ -11684,7 +11396,6 @@ mod serve_tests { range_filters: false, fill_timeout: false, properties_wanted: true, - epoch: None, }, )); // Driven until FETCH_OK is out, so the fetch is parked on uni stream credit. @@ -11767,7 +11478,6 @@ mod serve_tests { range_filters: false, fill_timeout: false, properties_wanted: true, - epoch: None, }, )); for _ in 0..100 { @@ -11885,7 +11595,6 @@ mod serve_tests { range_filters: false, fill_timeout: false, properties_wanted: true, - epoch: None, }, )); // Driven until FETCH_OK is out, so the fetch is parked on uni stream credit. @@ -12013,7 +11722,6 @@ mod serve_tests { range_filters: false, fill_timeout: false, properties_wanted: true, - epoch: None, }, )); for _ in 0..100 { @@ -12280,7 +11988,6 @@ mod serve_tests { range_filters: false, fill_timeout: false, properties_wanted: true, - epoch: None, }, ) .await @@ -12295,7 +12002,6 @@ mod serve_tests { track_namespace: crate::Path::from_static("room"), track_name: "video".into(), properties_wanted: true, - epoch: None, }, ) .await @@ -12499,7 +12205,6 @@ mod serve_tests { range_filters: false, fill_timeout: false, properties_wanted: true, - epoch: None, }, ) .await @@ -12552,7 +12257,6 @@ mod serve_tests { range_filters: false, fill_timeout: false, properties_wanted: true, - epoch: None, }, ) .await @@ -14413,7 +14117,7 @@ mod tests { let local = announced.assert_next_active("from/us"); announced.assert_next_wait(); - assert_eq!(publisher.select(&local, &peer, false), Advert::Some(Default::default())); + assert_eq!(publisher.select(&local, &peer), Advert::Some(Default::default())); } /// An anonymous chain received from an identified peer keeps the 0 on the wire @@ -14756,89 +14460,6 @@ mod tests { ); } - /// MoQ Epoch: a restart onto another epoch advertises the new instance before it - /// withdraws the old, and only once the peer accepted it, since the two streams are - /// unordered. The peer never sees the namespace absent. A peer without the extension - /// cannot tell the two apart, so it still sees the old one withdrawn first. - #[moq_clock::test] - async fn another_epoch_is_advertised_before_the_old_is_withdrawn() { - use crate::lite::test_transport::Sent; - const VERSION: Version = Version::Draft18; - - for epochs in [true, false] { - let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let _old = origin - .announce("cam", crate::origin::Route::default().with_epoch(crate::Epoch::mint())) - .unwrap(); - settle().await; - - let ok = publish_namespace_ok(VERSION).await; - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![ok.clone(), ok]); - let log = session.log.clone(); - let declared = peer::PeerSetup::default(); - declared.set(peer::Peer { - solicit: Some(false), - epoch: epochs, - ..Default::default() - }); - let publisher = Publisher::new( - crate::time::sim(), - crate::driver::Transport::new(session.clone()), - origin.consume(), - Control::new(false), - None, - declared, - VERSION, - ); - - let mut run = std::pin::pin!(publisher.run_publish_namespaces()); - let mut _new = None; - for count in [1, 2] { - for _ in 0..100 { - assert!(futures::poll!(run.as_mut()).is_pending()); - if occurrences(&log, b"cam") >= count { - break; - } - settle().await; - } - assert_eq!(occurrences(&log, b"cam"), count, "epochs: {epochs}"); - if count == 1 { - // A newer epoch outranks the old at the same path: a restart. - let route = crate::origin::Route::default().with_epoch(crate::Epoch::mint()); - _new = Some(origin.announce("cam", route).unwrap()); - } - } - for _ in 0..10 { - assert!(futures::poll!(run.as_mut()).is_pending()); - settle().await; - } - - // Where each advertisement went out, and where the first one was withdrawn. - let trail = log.trail(); - let advertised: Vec<(usize, usize)> = trail - .iter() - .enumerate() - .filter_map(|(at, (stream, sent))| match sent { - Sent::Write(bytes) if bytes.windows(3).any(|window| window == b"cam") => Some((at, *stream)), - _ => None, - }) - .collect(); - let [(_, old), (made, new)] = advertised[..] else { - panic!("epochs: {epochs}: expected two advertisements, got {advertised:?}"); - }; - assert_ne!(old, new, "epochs: {epochs}: one request carried both"); - let broken = trail - .iter() - .position(|(stream, sent)| *stream == old && matches!(sent, Sent::Reset(_))) - .unwrap_or_else(|| panic!("epochs: {epochs}: the old advertisement was never withdrawn")); - assert_eq!( - made < broken, - epochs, - "epochs: {epochs}: advertised at {made}, withdrew at {broken}" - ); - } - } - async fn publish_namespace_ok(version: Version) -> Vec { let log = crate::lite::test_transport::Log::default(); let mut writer = crate::coding::Writer::new( @@ -15496,7 +15117,6 @@ mod tests { max_age: false, auth: false, active_count: false, - epoch: false, flate: false, }); slot @@ -16665,7 +16285,6 @@ mod tests { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, }, ) .await @@ -16694,7 +16313,6 @@ mod tests { range_filters: false, fill_timeout: false, properties_wanted: true, - epoch: None, }, ) .await @@ -17122,7 +16740,6 @@ mod range_tests { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, } } diff --git a/rs/moq-net/src/ietf/publisher/tracks.rs b/rs/moq-net/src/ietf/publisher/tracks.rs index 066a563f983..42bf68fc327 100644 --- a/rs/moq-net/src/ietf/publisher/tracks.rs +++ b/rs/moq-net/src/ietf/publisher/tracks.rs @@ -1411,7 +1411,7 @@ mod tests { .unwrap(); let id = accepted.reader.varint().await.unwrap(); let body: ietf::Body = accepted.reader.decode().await.unwrap(); - let task = self.publisher.handle_stream(id, body, accepted, false).unwrap(); + let task = self.publisher.handle_stream(id, body, accepted).unwrap(); moq_clock::sim::spawn(task); stream } @@ -1800,7 +1800,6 @@ mod tests { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, max_age: None, new_group: None, }; @@ -2313,7 +2312,6 @@ mod tests { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, max_age: None, new_group: None, }; diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 28a7f6f5c14..f23e65d165f 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -10,7 +10,7 @@ use crate::{ }; use super::{ - Control, Message, Publisher, Subscriber, Version, active_count, auth, cluster, epoch, hidden, max_age, peer, + Control, Message, Publisher, Subscriber, Version, active_count, auth, cluster, hidden, max_age, peer, request_update, solicit, subscriber::is_protocol_violation, }; @@ -192,7 +192,7 @@ pub fn start(config: Config) -> Result<(Driver, crate::goaway::Handle, Option peer_setup.set(declared), @@ -603,7 +603,6 @@ fn peer_from_params(params: &ietf::Parameters, version: Version) -> Result serve, false => None, }; - // Whether requests and PUBLISH_NAMESPACE may carry EPOCH (MoQ Epoch). - let epoch = declared_setup.epoch; // From the same slot, so this costs nothing extra: it decides whether an unsolicited // advertisement is the peer ignoring our own SETUP (MoQ Solicit). @@ -1064,7 +1058,6 @@ async fn run_dispatch( publisher, subscriber, peer, - epoch, declared, serve, version, @@ -1118,8 +1111,6 @@ struct Bidi { publisher: Publisher, subscriber: Subscriber, peer: cluster::Peer, - /// Whether both sides negotiated MoQ Epoch. - epoch: bool, declared: Option, serve: Option, version: Version, @@ -1133,7 +1124,6 @@ impl Bidi { publisher, mut subscriber, peer, - epoch, declared, serve, version, @@ -1188,11 +1178,11 @@ impl Bidi { | ietf::SubscribeNamespace::ID | ietf::SubscribeTracks::ID | ietf::TrackStatus::ID => { - publisher.handle_stream(id, data, stream, epoch)?.await; + publisher.handle_stream(id, data, stream)?.await; } // Subscriber handles: Publish, PublishNamespace ietf::Publish::ID | ietf::PublishNamespace::ID => { - subscriber.handle_stream(id, data, stream, peer, epoch, declared)?.await; + subscriber.handle_stream(id, data, stream, peer, declared)?.await; } auth::Auth::ID if let Some(serve) = serve => { let mut data = data.decoder(version); @@ -1449,7 +1439,6 @@ mod tests { track_namespace: crate::Path::from_static("test"), track_name: "video".into(), properties_wanted: true, - epoch: None, } .encode_msg(&mut encoder, version) .unwrap(); @@ -2753,7 +2742,6 @@ mod tests { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, } .encode(&mut encoder, VERSION) .unwrap(); diff --git a/rs/moq-net/src/ietf/subscribe.rs b/rs/moq-net/src/ietf/subscribe.rs index 840e8c589a3..b6dc4000f04 100644 --- a/rs/moq-net/src/ietf/subscribe.rs +++ b/rs/moq-net/src/ietf/subscribe.rs @@ -11,7 +11,6 @@ use crate::{ use super::token::RequestToken; use super::Message; -use super::epoch; use super::namespace::{decode_track, encode_track}; use super::Version; @@ -47,9 +46,6 @@ pub struct Subscribe<'a> { pub new_group: Option, /// Subscriber max age in milliseconds (MoQ Timestamp), if negotiated. pub max_age: Option, - /// The publisher instance the subscriber expects (MoQ Epoch). Only on a - /// session that negotiated it; the session refuses one that arrives otherwise. - pub epoch: Option, } impl Subscribe<'_> { @@ -70,7 +66,6 @@ impl Subscribe<'_> { authorization_token: self.authorization_token, new_group: self.new_group, max_age: self.max_age, - epoch: self.epoch, } } } @@ -104,7 +99,6 @@ impl<'a> Message<'a> for Subscribe<'a> { 0x28 => object_property_filter: Vec where has_range_filters(version), 0x32 => new_group: Option, 0x35 => include_properties: Option where Filter::is_draft22(version), - epoch::EPOCH_PARAM => epoch: Option, super::max_age::PARAMETER => max_age: Option, ); let authorization_token = super::token::single(authorization_token)?; @@ -145,7 +139,6 @@ impl<'a> Message<'a> for Subscribe<'a> { authorization_token, new_group, max_age: super::max_age::validate(max_age)?, - epoch, }) } @@ -162,8 +155,6 @@ impl<'a> Message<'a> for Subscribe<'a> { // arrived in draft-22, and an older peer would read it as an unknown // parameter, which is a protocol violation. let include_properties = (!self.properties_wanted && Filter::is_draft22(version)).then_some(false); - // Only a session that negotiated MoQ Epoch names one. - let epoch = self.epoch.clone(); encode_params!(w, version, 0x03 => self.authorization_token.clone(), @@ -174,7 +165,6 @@ impl<'a> Message<'a> for Subscribe<'a> { 0x23 => fill, 0x32 => self.new_group, 0x35 => include_properties, - epoch::EPOCH_PARAM => epoch, super::max_age::PARAMETER => self.max_age, ); @@ -344,7 +334,6 @@ mod tests { range_filters: false, authorization_token: None, new_group: None, - epoch: None, }; let wire = encode_message(&msg, version); @@ -385,7 +374,6 @@ mod tests { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -420,7 +408,6 @@ mod tests { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, }; let wire = encode_message(&subscribe, version); @@ -512,7 +499,6 @@ mod tests { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, }; for version in [Version::Draft18, Version::Draft22] { @@ -548,7 +534,6 @@ mod tests { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -672,7 +657,6 @@ mod tests { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, }; let encoded = encode_message(&msg, version); @@ -745,7 +729,6 @@ mod tests { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -933,7 +916,6 @@ mod tests { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, }; #[rustfmt::skip] @@ -990,7 +972,6 @@ mod tests { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, }; let wire = encode_message(&msg, version); diff --git a/rs/moq-net/src/ietf/subscribe_namespace.rs b/rs/moq-net/src/ietf/subscribe_namespace.rs index 5ceb1f74fec..fcb2964d782 100644 --- a/rs/moq-net/src/ietf/subscribe_namespace.rs +++ b/rs/moq-net/src/ietf/subscribe_namespace.rs @@ -67,15 +67,14 @@ impl<'a> Message<'a> for SubscribeNamespace { /// Indicates a namespace suffix matching the subscribed prefix is active. /// /// The base message carries only the suffix. A session that negotiated the MoQ Cluster -/// or MoQ Epoch extension uses the extended form instead, which appends one Parameters -/// field for both, so the advertisement can carry its HOP_PATH and ROUTE_COST (see -/// [`super::cluster`]) and its EPOCH (see [`super::epoch`]). +/// extension appends a Parameters field carrying HOP_PATH and ROUTE_COST (see +/// [`super::cluster`]). #[derive(Clone, Debug)] pub struct Namespace { pub suffix: Path, /// The extension parameters. `Some` selects the extended form; `None` the base one. - /// An endpoint must not append them on a session that negotiated neither extension. + /// An endpoint must not append them on a session without MoQ Cluster. pub params: Option, } @@ -150,19 +149,7 @@ mod tests { cluster::HopPath::new(crate::Hops::try_from(hops).unwrap()) } - const CLUSTER: Negotiated = Negotiated { - cluster: true, - epoch: false, - }; - const EPOCH: Negotiated = Negotiated { - cluster: false, - epoch: true, - }; - const BOTH: Negotiated = Negotiated { - cluster: true, - epoch: true, - }; - + const CLUSTER: Negotiated = Negotiated { cluster: true }; fn decode(bytes: Vec, version: Version, negotiated: Negotiated) -> Result<(Namespace, bool), DecodeError> { let mut buf = bytes::Bytes::from(bytes); let decoded = crate::coding::decode_buf(&mut buf, version, |r, v| Namespace::decode_body(r, v, negotiated))?; @@ -175,7 +162,6 @@ mod tests { hops: hop_path(hops), cost, }), - epoch: None, }) } @@ -227,80 +213,6 @@ mod tests { assert_eq!(decoded.params.unwrap().cluster.unwrap().cost, 0); } - /// MoQ Epoch alone extends NAMESPACE too: the block is present, empty for a route - /// without an epoch, and carries EPOCH for one with. - #[test] - fn epoch_namespace_round_trips() { - let version = Version::Draft22; - let epoch = crate::Epoch::mint(); - for epoch in [None, Some(epoch)] { - let msg = Namespace { - suffix: Path::from_static("a"), - params: Some(AdvertParams { - cluster: None, - epoch: epoch.clone(), - }), - }; - let (decoded, consumed) = decode(body(&msg, version), version, EPOCH).unwrap(); - assert!(consumed); - assert_eq!(decoded.params, msg.params, "{epoch:?}"); - } - } - - /// With both extensions, one block holds the parameters of both. - #[test] - fn both_extensions_share_one_block() { - let version = Version::Draft22; - let mut params = clustered(&[7], 3).unwrap(); - params.epoch = Some(crate::Epoch::mint()); - let msg = Namespace { - suffix: Path::from_static("a"), - params: Some(params), - }; - let encoded = body(&msg, version); - let (decoded, consumed) = decode(encoded.clone(), version, BOTH).unwrap(); - assert!(consumed); - assert_eq!(decoded.params, msg.params); - - // Read as the base form, a single block is all that is left over. - let mut suffix = Vec::new(); - encode_namespace(&mut Encoder::new(&mut suffix, version.into()), &Path::from_static("a")).unwrap(); - let mut block = Vec::new(); - super::super::publish_namespace::encode_advert_params( - &mut Encoder::new(&mut block, version.into()), - version, - msg.params.as_ref().unwrap(), - ) - .unwrap(); - assert_eq!(encoded, [suffix, block].concat()); - } - - /// EPOCH on a session that did not negotiate MoQ Epoch is an unknown parameter, and so - /// is a malformed one on a session that did. - #[test] - fn epoch_refused_unless_negotiated_and_well_formed() { - let version = Version::Draft22; - let msg = Namespace { - suffix: Path::from_static("a"), - params: Some(AdvertParams { - cluster: clustered(&[7], 0).unwrap().cluster, - epoch: Some(crate::Epoch::mint()), - }), - }; - assert!(matches!( - decode(body(&msg, version), version, CLUSTER), - Err(DecodeError::InvalidValue) - )); - - let mut buf = Vec::new(); - encode_namespace(&mut Encoder::new(&mut buf, version.into()), &Path::from_static("a")).unwrap(); - let w = &mut Encoder::new(&mut buf, version.into()); - w.varint(1).unwrap(); - w.varint(super::super::epoch::EPOCH_PARAM).unwrap(); - w.bytes(&[0; 16]).unwrap(); - assert!(matches!(decode(buf, version, EPOCH), Err(DecodeError::InvalidValue))); - } - /// A negotiated session that omits HOP_PATH is a protocol violation. #[test] fn cluster_namespace_requires_hop_path() { @@ -330,7 +242,6 @@ mod tests { hops: hop_path(&[7]), cost: 0, }), - epoch: None, }, authorization_token: None, }; diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index ed6fa325a39..60c2f21e299 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -752,12 +752,11 @@ struct TrackState { // stream and the subgroup stream carrying the rest of the group. fill: kio::Producer, - // The broadcast this track was subscribed from. With the track name and the instance - // it names, it forms the full track name, which is what decides whether a repeated + // The broadcast this track was subscribed from. With the track name, it forms the + // full track name, which is what decides whether a repeated // alias is the fatal collision (one alias, two tracks) or the legal sharing of an alias // across subscriptions. broadcast: Path, - epoch: Option, // Units for this track's object Timestamps, from the TIMESCALE Track Property in // SUBSCRIBE_OK. `None` until it arrives, and for a track that declares none: its @@ -805,7 +804,6 @@ impl TrackState { name, alias: None, broadcast, - epoch: None, timescale: None, fill_largest: None, fill_start: None, @@ -885,14 +883,6 @@ struct Advertised { route: crate::origin::Route, } -impl Advertised { - /// The publisher instance the advertisement named (MoQ Epoch), taken as given. - fn with_epoch(mut self, epoch: Option) -> Self { - self.route.epoch = epoch; - self - } -} - /// One NAMESPACE a SUBSCRIBE_NAMESPACE stream holds, live on the wire until its /// NAMESPACE_DONE whether or not its route is attached. struct Advertisement { @@ -900,8 +890,6 @@ struct Advertisement { /// [`Subscriber::update_announce`]; `None` while it loops back through us or falls /// outside the scope. id: Option, - /// The publisher instance it names: a repeat may name another, but not gain or lose one. - epoch: Option, /// Counts it against the session's announce cap until its NAMESPACE_DONE, attached or /// not; the namespace's route shares it rather than taking a second. slot: crate::auth::Hold, @@ -911,9 +899,6 @@ struct Advertisement { struct Held { /// The MoQ Cluster parameters, which a REQUEST_UPDATE carries only when they change. cluster: Option, - /// The MoQ Epoch instance, fixed for the advertisement's life: REQUEST_UPDATE cannot - /// carry one. - epoch: Option, } /// A joining request may be abandoned before it takes an ID. Once committed, its header @@ -963,10 +948,6 @@ impl Drop for JoiningTask { pub(super) struct Subscriber { // Scoped to a minted source on the clone that serves its requests. validity: crate::model::broadcast::RequestValidity, - // The publisher instance the minted source was resolved under (MoQ Epoch), named on - // every request for it, so the peer refuses rather than answer from another. Scoped - // like `validity`, and only ever set on a session that negotiated the extension. - epoch: Option, // Arms the track-alias and request-id timeouts. runtime: crate::time::Deadlines, session: Transport, @@ -1104,7 +1085,6 @@ impl Subscriber { announces: Default::default(), listings: Default::default(), validity: Default::default(), - epoch: None, } } @@ -1321,8 +1301,7 @@ impl Subscriber { let (Some(held), Some(new)) = (state.subscribes.get(&holder), state.subscribes.get(&request_id)) else { return false; }; - - held.broadcast == new.broadcast && held.epoch == new.epoch && held.name == new.name + held.broadcast == new.broadcast && held.name == new.name } /// Take the origin request back out of a subscription that is still setting up. @@ -1461,7 +1440,6 @@ impl Subscriber { let peer = self.peer().await; let negotiated = ietf::Negotiated { cluster: peer.negotiated(), - epoch: declared.epoch, }; self.run_namespace_entries(request, &peer, negotiated, Initial { initial, snapshot }, live) @@ -1570,22 +1548,14 @@ impl Subscriber { let path = prefix.join(&msg.suffix)?; let params = msg.params.unwrap_or_default(); let held = match live.entry(path.clone()) { - // A repeat under another epoch replaces the instance in place, but one that - // gains or loses an epoch skipped the NAMESPACE_DONE it needs. - Entry::Occupied(entry) if entry.get().epoch.is_some() != params.epoch.is_some() => { - tracing::warn!(%path, "namespace repeated gaining or losing an epoch"); - return Err(Error::ProtocolViolation); - } Entry::Occupied(entry) => entry.into_mut(), // The peer holds the namespace until its NAMESPACE_DONE whatever we make // of it, so it takes its announce slot before the decision. Entry::Vacant(entry) => entry.insert(Advertisement { id: None, - epoch: None, slot: std::sync::Arc::new(self.acquire_announce()?), }), }; - held.epoch = params.epoch.clone(); let Some(advert) = self.route(params.cluster.as_ref(), peer) else { // Looped back through us: forwarding it would extend the loop and // subscribing through it would route us back to ourselves. @@ -1594,20 +1564,18 @@ impl Subscriber { // replacement retracts the route we were holding. Keeping it would // leave subscriptions on a path the peer no longer offers. // - // The advertisement itself stays live on the wire, epoch and all. + // The advertisement itself stays live on the wire. tracing::debug!(%path, "dropping reflected namespace"); if let Some(id) = held.id.take() { let _ = self.stop_announce(path, id, false); } continue; }; - let advert = advert.with_epoch(params.epoch.clone()); - tracing::debug!(%path, hops = advert.route.hops.len(), cost = ?advert.route.cost, epoch = ?params.epoch, "namespace"); + tracing::debug!(%path, hops = advert.route.hops.len(), cost = ?advert.route.cost, "namespace"); match held.id { // A repeat replaces the advertisement atomically; nothing is torn - // down merely because an update arrived, and another epoch restarts - // the route without it ever going absent. + // down merely because an update arrived. Some(id) => self.update_announce(path, id, advert)?, None => { match self.start_announce(path.clone(), advert, Admit::Session(Some(held.slot.clone()))) { @@ -1653,7 +1621,6 @@ impl Subscriber { body: ietf::Body, stream: Stream, peer: cluster::Peer, - epoch: bool, declared: Option, ) -> Result, Error> { let mut this = self.clone(); @@ -1688,7 +1655,6 @@ impl Subscriber { // the dispatcher turns into the protocol violation the draft requires. let negotiated = ietf::Negotiated { cluster: peer.negotiated(), - epoch, }; let msg = match ietf::PublishNamespace::decode_body(&mut data, this.version, negotiated) { Err(err) if super::token::refuses_request(&err) => { @@ -1830,7 +1796,6 @@ impl Subscriber { // A path that already contains our own Hop ID looped back. Reject it rather // than attaching a source we would then have to route around. - let epoch = msg.params.epoch.clone(); let Some(advert) = self.route(msg.params.cluster.as_ref(), &peer) else { tracing::debug!(%path, "dropping reflected publish_namespace"); self.write_error(&mut stream, &Error::Unroutable, "route loops through this relay") @@ -1838,7 +1803,6 @@ impl Subscriber { let _ = stream.writer.close().await; return Ok(()); }; - let advert = advert.with_epoch(epoch.clone()); // Our authorization of the peer (the limit) admits an announce without a token, and // holds back one it does not cover until it does. An AUTHORIZATION TOKEN on the @@ -1916,7 +1880,6 @@ impl Subscriber { &path, Held { cluster: msg.params.cluster, - epoch, }, peer, &mut attached, @@ -1977,10 +1940,7 @@ impl Subscriber { attached: &mut Option, token_grant: Option, ) -> Result { - let Held { - cluster: mut held, - epoch, - } = held; + let Held { cluster: mut held } = held; // A REQUEST_UPDATE carrying a fresh token refreshes the announce's request grant (MoQ // request-token); its verify is raced against the stream in the loop's single wait // (never a bare await), so a peer ending the announce ends it whatever the acceptor @@ -2191,8 +2151,6 @@ impl Subscriber { Ok(()) } Some(advert) => { - // The epoch is the advertisement's for as long as it stands. - let advert = advert.with_epoch(epoch.clone()); tracing::debug!(%path, hops = advert.route.hops.len(), cost = ?advert.route.cost, "publish_namespace update"); match *attached { Some(id) => self.update_announce(path.clone(), id, advert), @@ -2450,18 +2408,12 @@ impl Subscriber { Entry::Occupied(entry) => { let entry = entry.into_mut(); match entry.adverts.iter().position(|(advert, _)| *advert == id) { - // A repeat under another epoch is a newer advertisement, so it becomes the - // newest; one of the same instance stays where it is. - Some(index) if entry.adverts[index].1.epoch != route.epoch => { - entry.adverts.remove(index); - entry.adverts.push((id, route)); - } Some(index) => entry.adverts[index].1 = route, None => entry.adverts.push((id, route)), } // The newest advertisement stands, and an update to an older one changes // nothing. A repeat of the same instance re-prices in place and in-flight - // tracks keep flowing; another epoch restarts it. + // tracks keep flowing. let (newest, route) = entry.adverts.last().expect("just recorded"); if *newest == id { entry.route = route.clone(); @@ -2652,22 +2604,19 @@ impl Subscriber { Some(requested) => requested.clone(), None => continue, }; - // The source serves the instance the route names now: every request for it names - // that epoch when the peer can read one, and the source takes new tracks only - // while the peer still selects that instance. - let (validity, epoch) = { + // The source takes new tracks only while the peer still selects that instance. + let validity = { let state = self.state.lock(); let entry = state .broadcasts .get(&path) .filter(|entry| entry.generation == generation); - let validity = entry + entry .and_then(|entry| entry.dynamic.as_ref()) .map(|route| crate::model::broadcast::RequestValidity::for_source(route, request.path())) .unwrap_or_else(|| { crate::model::broadcast::RequestValidity::token(Some(kio::Producer::new(()).consume())) - }); - (validity, entry.and_then(|entry| entry.route.epoch.clone())) + }) }; let source = crate::model::broadcast::SourceGuard::new(self.origin.create_source(requested.clone(), validity)); @@ -2692,7 +2641,6 @@ impl Subscriber { let mut this = self.clone(); this.validity = dynamic.validity().clone(); - this.epoch = epoch; broadcasts.push(async move { if let Err(err) = this.run_broadcast(requested, source, dynamic, route).await { tracing::debug!(%err, "error running broadcast"); @@ -3050,7 +2998,6 @@ impl Subscriber { TrackState { // A resumed copy is the session's to abort from here on. producer: target.producer().cloned(), - epoch: self.epoch.clone(), alias, tail, ..TrackState::pending(track_name.to_owned(), broadcast_path.clone(), fill.clone()) @@ -3105,7 +3052,6 @@ impl Subscriber { rendezvous: false, range_filters: false, authorization_token: None, - epoch: self.epoch.clone(), }) .await } @@ -4104,7 +4050,6 @@ impl Subscriber { fill_timeout: false, properties_wanted: true, // Served by the subscription it joins, which named the instance. - epoch: None, }) .await?; Ok::<(), Error>(()) @@ -4264,7 +4209,6 @@ impl Subscriber { track_namespace: broadcast_path.clone(), track_name: track_name.into(), properties_wanted: true, - epoch: self.epoch.clone(), }) .await } @@ -5394,7 +5338,6 @@ impl Subscriber { // The copy learned the track before any fetch, from SUBSCRIBE_OK or // TRACK_STATUS_OK, so a FETCH_OK repeating it is waste. properties_wanted: false, - epoch: self.epoch.clone(), }) .await?; Ok::<_, Error>(()) @@ -6408,101 +6351,6 @@ mod tests { ); } - /// A NAMESPACE that loops back through us detaches its route, but the advertisement - /// stays live on the wire until its NAMESPACE_DONE: a repeat that restores a clean path - /// while gaining or losing an epoch is still the violation it would be without the loop. - #[moq_clock::test] - async fn a_reflected_namespace_keeps_its_epoch_live() { - const VERSION: Version = Version::Draft18; - let us = crate::Hop::new(5).unwrap(); - let epoch = crate::Epoch::mint(); - - for (reflected, drops) in [(false, true), (true, true), (true, false)] { - let log = crate::lite::test_transport::Log::default(); - let mut writer = crate::coding::Writer::new( - crate::transport::boxed::SendStream::new(crate::lite::test_transport::SinkSend::new(log.clone())), - VERSION, - ); - writer.varint(ietf::RequestOk::ID).await.unwrap(); - writer - .encode(&ietf::RequestOk { - largest: None, - active: None, - }) - .await - .unwrap(); - let mut adverts = vec![(hop_path(&[7, 9]), Some(epoch.clone()))]; - if reflected { - adverts.push((hop_path(&[7, 5, 9]), Some(epoch.clone()))); - } - adverts.push((hop_path(&[7, 9]), (!drops).then(|| epoch.clone()))); - for (hops, epoch) in adverts { - writer.varint(ietf::Namespace::ID).await.unwrap(); - writer - .encode(&ietf::Namespace { - suffix: crate::Path::from_static("room"), - params: Some(ietf::AdvertParams { - cluster: Some(cluster::Advert { hops, cost: 0 }), - epoch, - }), - }) - .await - .unwrap(); - } - let script = log.writes.lock().unwrap().clone(); - - let origin = crate::origin::Config::new(us).produce(); - let session = crate::lite::test_transport::ScriptedSession::new(script); - let (tasks, _task_set) = crate::util::TaskSet::new(); - let peer_setup = peer::PeerSetup::default(); - peer_setup.set(peer::Peer { - cluster: cluster::Peer { - hop: Some(crate::Hop::new(9).unwrap()), - cost: None, - }, - epoch: true, - ..Default::default() - }); - let mut subscriber = Subscriber::new( - crate::time::sim(), - crate::driver::Transport::new(session.clone()), - origin, - Control::new(false), - None, - peer_setup, - us, - None, - VERSION, - tasks, - Default::default(), - ); - - let prefix = crate::model::interest_prefixes(&subscriber.origin.allowed()) - .pop() - .expect("one prefix"); - let (_live, live) = live(); - let stream = Stream::open(&mut crate::driver::Transport::new(session.clone()), VERSION) - .await - .unwrap(); - let mut run = std::pin::pin!(subscriber.run_subscribe_namespace( - stream, - prefix, - crate::model::Snapshot::landed(), - live - )); - let mut result = None; - for _ in 0..100 { - if let std::task::Poll::Ready(done) = futures::poll!(run.as_mut()) { - result = Some(done); - break; - } - settle().await; - } - let violated = matches!(result, Some(Err(ref err)) if is_protocol_violation(err)); - assert_eq!(violated, drops, "reflected: {reflected}, drops: {drops}: {result:?}"); - } - } - /// A namespace the peer holds on a SUBSCRIBE_NAMESPACE stream takes an announce slot /// whatever we make of it, so reflected ones the origin never sees still exhaust the /// session's budget, and a NAMESPACE_DONE gives the slot back. @@ -6528,7 +6376,6 @@ mod tests { hops: hop_path(&[7, 5, 9]), cost: 0, }), - epoch: None, }), }; writer.varint(ietf::Namespace::ID).await.unwrap(); @@ -8673,7 +8520,6 @@ mod tests { hops: hop_path(&[7, 9]), cost, }), - epoch: None, }), }) .await @@ -9063,98 +8909,6 @@ mod tests { assert!(routed_now(&consumer, "room/host").is_none()); } - /// MoQ Epoch: a publisher replacing its instance withdraws one advertisement and makes - /// another, on separate streams that arrive in either order. The newest advertisement - /// stands, so neither the old one's late update nor its withdrawal reverts the route - /// to the instance it named. - #[moq_clock::test] - async fn a_reordered_replacement_keeps_the_new_instance() { - let (mut subscriber, origin) = cluster_subscriber(crate::Hop::new(1).unwrap()); - let consumer = origin.consume(); - let path = crate::Path::from_static("room/host"); - let peer = cluster::Peer::default(); - let (old, new) = (crate::Epoch::mint(), crate::Epoch::mint()); - let advert = |subscriber: &Subscriber, epoch: &crate::Epoch| { - subscriber - .route(None, &peer) - .expect("route") - .with_epoch(Some(epoch.clone())) - }; - let epoch_now = || routed_now(&consumer, "room/host").and_then(|route| route.epoch); - - let first = advert(&subscriber, &old); - let first = subscriber - .start_announce(path.clone(), first, Admit::Session(None)) - .unwrap(); - assert_eq!(epoch_now(), Some(old.clone())); - - // The replacement's PUBLISH_NAMESPACE beats the old one's PUBLISH_NAMESPACE_DONE. - let second = advert(&subscriber, &new); - let second = subscriber - .start_announce(path.clone(), second, Admit::Session(None)) - .unwrap(); - assert_eq!(epoch_now(), Some(new.clone())); - - // A late update to the old advertisement changes nothing. - let stale = advert(&subscriber, &old); - subscriber.update_announce(path.clone(), first, stale).unwrap(); - assert_eq!(epoch_now(), Some(new.clone()), "a stale update reverted the instance"); - - // Its withdrawal, arriving last, leaves the new instance standing. - subscriber.stop_announce(path.clone(), first, false).unwrap(); - assert_eq!( - epoch_now(), - Some(new.clone()), - "the old withdrawal retracted the new instance" - ); - - subscriber.stop_announce(path, second, false).unwrap(); - assert_eq!(routed_now(&consumer, "room/host"), None); - } - - /// MoQ Epoch: a namespace advertised both inline and by a later PUBLISH_NAMESPACE takes - /// another epoch from either. An inline repeat under the new epoch is the newest - /// advertisement, not an update to the older of the two. - #[moq_clock::test] - async fn a_repeat_under_another_epoch_becomes_the_newest() { - let (mut subscriber, origin) = cluster_subscriber(crate::Hop::new(1).unwrap()); - let consumer = origin.consume(); - let path = crate::Path::from_static("room/host"); - let peer = cluster::Peer::default(); - let (old, new) = (crate::Epoch::mint(), crate::Epoch::mint()); - let advert = |subscriber: &Subscriber, epoch: &crate::Epoch| { - subscriber - .route(None, &peer) - .expect("route") - .with_epoch(Some(epoch.clone())) - }; - let epoch_now = || routed_now(&consumer, "room/host").and_then(|route| route.epoch); - - let inline = advert(&subscriber, &old); - let inline = subscriber - .start_announce(path.clone(), inline, Admit::Session(None)) - .unwrap(); - let request = advert(&subscriber, &old); - let request = subscriber - .start_announce(path.clone(), request, Admit::Session(None)) - .unwrap(); - assert_eq!(epoch_now(), Some(old.clone())); - - let repeat = advert(&subscriber, &new); - subscriber.update_announce(path.clone(), inline, repeat).unwrap(); - assert_eq!( - epoch_now(), - Some(new.clone()), - "the older request kept the old instance" - ); - - // The old instance's withdrawal leaves the new one standing. - subscriber.stop_announce(path.clone(), request, false).unwrap(); - assert_eq!(epoch_now(), Some(new)); - subscriber.stop_announce(path, inline, false).unwrap(); - assert_eq!(routed_now(&consumer, "room/host"), None); - } - /// Two *separate* advertisements for one namespace refcount a single route: /// it takes both retractions to retract it. #[moq_clock::test] @@ -9424,7 +9178,6 @@ mod tests { &path, Held { cluster: Some(clean.clone()), - epoch: None }, peer, &mut attached, @@ -9474,7 +9227,6 @@ mod tests { &path, Held { cluster: Some(clean.clone()), - epoch: None }, peer, &mut attached, @@ -9564,7 +9316,6 @@ mod tests { &path, Held { cluster: Some(clean.clone()), - epoch: None }, peer, &mut attached, @@ -9637,7 +9388,6 @@ mod tests { &path, Held { cluster: Some(held.clone()), - epoch: None }, peer, &mut attached, @@ -9689,7 +9439,6 @@ mod tests { &path, Held { cluster: Some(clean.clone()), - epoch: None }, peer, &mut attached, @@ -9743,7 +9492,6 @@ mod tests { &path, Held { cluster: Some(clean.clone()), - epoch: None }, peer, &mut attached, @@ -9791,7 +9539,6 @@ mod tests { cost: 0, ..clean.clone() }), - epoch: None, }, authorization_token: None, }) @@ -9812,7 +9559,6 @@ mod tests { &path, Held { cluster: Some(clean.clone()), - epoch: None }, peer, &mut attached, @@ -12582,7 +12328,6 @@ mod stitch_tests { range_filters: false, fill_timeout: false, properties_wanted: true, - epoch: None, } .encode_msg(&mut Encoder::new(&mut body, egress.into()), egress) .unwrap(); @@ -12603,7 +12348,6 @@ mod stitch_tests { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, } .encode_msg(&mut Encoder::new(&mut body, egress.into()), egress) .unwrap(); @@ -12613,7 +12357,7 @@ mod stitch_tests { .await .unwrap(); publisher - .handle_stream(id, ietf::Body(body.into()), stream, false) + .handle_stream(id, ietf::Body(body.into()), stream) .unwrap() .await; let mut streams = std::collections::BTreeMap::>::new(); @@ -13110,7 +12854,6 @@ mod stitch_tests { rendezvous: false, range_filters: false, authorization_token: None, - epoch: None, }; let mut body = Vec::new(); subscribe @@ -13120,7 +12863,7 @@ mod stitch_tests { .await .unwrap(); publisher - .handle_stream(ietf::Subscribe::ID, ietf::Body(body.into()), stream, false) + .handle_stream(ietf::Subscribe::ID, ietf::Body(body.into()), stream) .unwrap() .await; let sent = downstream.log.datagrams.lock().unwrap(); @@ -14439,7 +14182,6 @@ mod stitch_tests { range_filters: false, fill_timeout: false, properties_wanted: false, - epoch: None, } .encode_msg(&mut Encoder::new(&mut body, version.into()), version) .unwrap(); @@ -14461,7 +14203,7 @@ mod stitch_tests { moq_clock::timeout( Duration::from_secs(1), publisher - .handle_stream(ietf::Fetch::ID, ietf::Body(body.into()), stream, false) + .handle_stream(ietf::Fetch::ID, ietf::Body(body.into()), stream) .unwrap(), ) .await diff --git a/rs/moq-net/src/ietf/subscriber/tracks.rs b/rs/moq-net/src/ietf/subscriber/tracks.rs index d27a291ba33..3117d89175b 100644 --- a/rs/moq-net/src/ietf/subscriber/tracks.rs +++ b/rs/moq-net/src/ietf/subscriber/tracks.rs @@ -624,7 +624,6 @@ impl Subscriber { self.state.lock().subscribes.insert( request_id, TrackState { - epoch: self.epoch.clone(), ..TrackState::pending(key.1.to_string(), key.0.clone(), kio::Producer::new(Fill::Done)) }, ); @@ -648,7 +647,6 @@ impl Subscriber { rendezvous: false, range_filters: false, authorization_token: None, - epoch: self.epoch.clone(), }) .await?; let mut response = std::pin::pin!(self.read_subscribe_response(&mut stream, &mut messages)); @@ -838,7 +836,6 @@ impl Subscriber { self.state.lock().subscribes.insert( request_id, TrackState { - epoch: self.epoch.clone(), ..TrackState::pending(key.1.to_string(), key.0.clone(), kio::Producer::new(Fill::Done)) }, ); @@ -1107,7 +1104,6 @@ impl Subscriber { } let parked = TrackState { alias: held.alias, - epoch: held.epoch.clone(), tail: std::mem::take(&mut held.tail), ..TrackState::pending( std::mem::take(&mut held.name), diff --git a/rs/moq-net/src/ietf/track.rs b/rs/moq-net/src/ietf/track.rs index 093c6617f7d..63d84d2100d 100644 --- a/rs/moq-net/src/ietf/track.rs +++ b/rs/moq-net/src/ietf/track.rs @@ -25,8 +25,6 @@ pub struct TrackStatus<'a> { /// Whether the requester wants Track Properties on the answer (INCLUDE_PROPERTIES, /// draft-22). pub properties_wanted: bool, - /// The publisher instance the requester expects (MoQ Epoch). - pub epoch: Option, } impl TrackStatus<'_> { @@ -37,7 +35,6 @@ impl TrackStatus<'_> { track_namespace: self.track_namespace, track_name: Cow::Owned(self.track_name.into_owned()), properties_wanted: self.properties_wanted, - epoch: self.epoch, } } } @@ -52,10 +49,8 @@ impl<'a> Message<'a> for TrackStatus<'a> { // Only the opt-out is worth bytes, and an older peer would read the // parameter as unknown, which is a protocol violation. let include_properties = (!self.properties_wanted && Filter::is_draft22(version)).then_some(false); - let epoch = self.epoch.clone(); encode_params!(w, version, 0x35 => include_properties, - super::epoch::EPOCH_PARAM => epoch, ); Ok(()) } @@ -70,7 +65,6 @@ impl<'a> Message<'a> for TrackStatus<'a> { track_namespace: subscribe.track_namespace, track_name: subscribe.track_name, properties_wanted: subscribe.properties_wanted, - epoch: subscribe.epoch, }) } } @@ -166,7 +160,6 @@ mod tests { track_namespace: Path::from_static("test/ns"), track_name: "video".into(), properties_wanted: true, - epoch: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -190,7 +183,6 @@ mod tests { track_namespace: Path::from_static("ns"), track_name: "video".into(), properties_wanted: wanted, - epoch: None, }; let wire = encode_message(&msg, version); diff --git a/rs/moq-net/src/setup.rs b/rs/moq-net/src/setup.rs index 230e674fafe..db523bfb025 100644 --- a/rs/moq-net/src/setup.rs +++ b/rs/moq-net/src/setup.rs @@ -74,7 +74,7 @@ pub(crate) fn implementation(value: &str) -> Option { /// The SETUP extensions this side offers. /// /// Turning one off connects as a peer that does not speak that extension. moq-lite carries -/// auth, solicit, and epoch in its core, so those only change moq-transport sessions. +/// auth and solicit in its core, so those only change moq-transport sessions. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(default, deny_unknown_fields)] #[non_exhaustive] @@ -84,9 +84,6 @@ pub struct Extensions { /// The MoQ Solicit extension: the peer answers our SUBSCRIBE_NAMESPACE rather than /// announcing unasked (moq-transport only). pub solicit: bool, - /// The MoQ Epoch extension: announcements and requests carry the publisher's - /// [`Epoch`](crate::Epoch), as moq-lite 07 does. - pub epoch: bool, /// The MoQ IETF extension, off by default: a moq-lite 07 hop between relays carries the /// moq-transport object state a relay preserves, such as Object IDs and properties. pub ietf: bool, @@ -97,7 +94,6 @@ impl Default for Extensions { Self { auth: true, solicit: true, - epoch: true, ietf: false, } } diff --git a/rs/moq-net/src/test_interop.rs b/rs/moq-net/src/test_interop.rs index 68c639c2862..812e44dc045 100644 --- a/rs/moq-net/src/test_interop.rs +++ b/rs/moq-net/src/test_interop.rs @@ -63,7 +63,6 @@ fn ietf_range_updates_interop() { authorization_token: None, new_group: None, max_age: None, - epoch: None, } .encode(&mut w, version) .unwrap(); @@ -605,7 +604,6 @@ fn ietf_track_status_interop() { track_namespace: crate::Path::new(namespace).unwrap(), track_name: "video".into(), properties_wanted: wanted, - epoch: None, } .encode(&mut w, version) .unwrap(); diff --git a/rs/moq-net/tests/integration/catalog_acquisition.rs b/rs/moq-net/tests/integration/catalog_acquisition.rs index 78b3430fc64..3d2533055d4 100644 --- a/rs/moq-net/tests/integration/catalog_acquisition.rs +++ b/rs/moq-net/tests/integration/catalog_acquisition.rs @@ -21,8 +21,7 @@ async fn link(version: Version, from: &origin::Producer, to: &origin::Producer, connect_mock(options).await } -/// `carries_epoch`: the wire carries the epoch, as lite-07 and moq-transport with the MoQ -/// Epoch extension do. +/// `carries_epoch`: the wire carries the epoch, as lite-07 does. async fn acquisition(version: &str, carries_epoch: bool) { let version: Version = version.parse().unwrap(); let publisher = origin(1); @@ -81,11 +80,11 @@ async fn acquisition(version: &str, carries_epoch: bool) { #[moq_clock::test] async fn epochless_replacement_refuses_pending_catalog() { acquisition("moq-lite-06", false).await; + acquisition("moq-transport-18", false).await; + acquisition("moq-transport-22", false).await; } #[moq_clock::test] async fn same_epoch_replacement_acquires_pending_catalog() { acquisition("moq-lite-07-wip", true).await; - acquisition("moq-transport-18", true).await; - acquisition("moq-transport-22", true).await; } diff --git a/rs/moq-net/tests/integration/extensions.rs b/rs/moq-net/tests/integration/extensions.rs index 16e2aee3537..56e1a425ba5 100644 --- a/rs/moq-net/tests/integration/extensions.rs +++ b/rs/moq-net/tests/integration/extensions.rs @@ -141,74 +141,3 @@ async fn a_declined_auth_extension_is_not_negotiated() { } } } - -/// The route `path` is announced on in `origin` right now, if any. -fn route(origin: &origin::Producer, path: &str) -> Option { - let mut announced = origin.consume().announced(); - let mut route = None; - while let Some(event) = announced.try_next() { - match event { - moq_net::announce::Event::Start(update) - | moq_net::announce::Event::Update(update) - | moq_net::announce::Event::Restart(update) => { - if update.prefix.as_str() == path { - route = Some(update.route); - } - } - moq_net::announce::Event::End(update) => { - if update.prefix.as_str() == path { - route = None; - } - } - } - } - route -} - -/// MoQ Epoch carries the publisher's epoch, in either direction, only when both sides -/// offer it. A side declining it leaves the route epochless exactly as before the -/// extension, and nothing on the wire breaks the session. -#[moq_clock::test] -async fn an_epoch_crosses_only_when_both_offer_it() { - for name in MOQT { - let version: Version = name.parse().unwrap(); - for server_publishes in [true, false] { - for declines in [None, Some("server"), Some("client")] { - let context = format!("{name}, server publishes: {server_publishes}, declines: {declines:?}"); - let publisher = produce_origin(1); - let subscriber = produce_origin(2); - - let mut options = MockConnectOptions::new(version); - match server_publishes { - true => { - options.server_publish = Some(publisher.consume()); - options.client_subscribe = Some(subscriber.clone()); - } - false => { - options.client_publish = Some(publisher.consume()); - options.server_subscribe = Some(subscriber.clone()); - } - } - let no_epoch = declined(|e| e.epoch = false); - match declines { - Some("server") => options.server_extensions = no_epoch, - Some(_) => options.client_extensions = no_epoch, - None => {} - } - let pair = connect_mock(options).await; - - let epoch = moq_net::Epoch::mint(); - let broadcast = publisher.create_broadcast("room/cam").unwrap(); - broadcast - .announce(origin::Route::default().with_epoch(epoch.clone())) - .unwrap(); - moq_clock::sleep(SETTLE).await; - - let route = route(&subscriber, "room/cam").unwrap_or_else(|| panic!("{context}: never announced")); - let expected = declines.is_none().then_some(epoch); - assert_eq!(route.epoch, expected, "{context}"); - assert_open(&pair, &context); - } - } - } -} diff --git a/rs/moq-net/tests/integration/multi_cdn.rs b/rs/moq-net/tests/integration/multi_cdn.rs index 14f67296342..75212b80586 100644 --- a/rs/moq-net/tests/integration/multi_cdn.rs +++ b/rs/moq-net/tests/integration/multi_cdn.rs @@ -7,8 +7,7 @@ //! [`origin::Producer::with_preference`] while pricing `B` cheaper, so only the //! preference puts `A` ahead. Losing `A` moves `S` to `B`, and `A` returning moves it //! back. Between lite-07 routes with the same epoch each move resumes the subscription. -//! A moq-transport `B` with the MoQ Epoch extension carries the epoch, but its copy's -//! track info records the draft, so the move onto it is refused. +//! A moq-transport `B` carries no epoch, so moving onto it restarts the path. use crate::support; @@ -311,11 +310,8 @@ async fn preferred_cdn_fails_over_and_back() { fails_over_and_back("moq-lite-07-wip").await; } -/// A moq-transport `B` with the MoQ Epoch extension carries `P`'s epoch, so losing `A` is -/// an update, not a restart. Its copy's track info differs from `A`'s, recording the draft -/// it crossed, and one epoch means identical content: `S` refuses the copy, so the track -/// ends with no frame of `B`'s. -async fn refused_across_protocols(secondary: &str) { +/// Losing the epoch-carrying route restarts onto the epochless moq-transport route. +async fn restarts_across_protocols(secondary: &str) { moq_clock::timeout(TEST_TIMEOUT, async { let mut topology = Topology::new(secondary.parse().unwrap()).await; let mut announced = topology.subscriber.consume().announced(); @@ -329,16 +325,23 @@ async fn refused_across_protocols(secondary: &str) { topology.lose_a().await; let update = announced.next().await.expect("announced"); assert!( - matches!(update, announce::Event::Update(_)) && !via_a(&update), - "same epoch over B: {update:?}" + matches!(update, announce::Event::Restart(_)) && !via_a(&update), + "epochless route over B: {update:?}" ); topology.group(); settle().await; let rest: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect(); assert!( - matches!(rest.as_slice(), [(u64::MAX, Err(Error::Unsupported))]), - "the track did not end with the refusal: {rest:?}" + matches!( + rest.as_slice(), + [(u64::MAX, Err(Error::Session(moq_net::SessionError::Cancel)))] + ), + "the old route did not end its track: {rest:?}" ); + let mut fresh = topology.subscribe().await; + let (sequence, _, bytes) = next(&mut fresh).await; + assert_eq!(sequence, 1, "the fresh subscription joins the current group"); + assert_eq!(bytes, payload(1, 0)); drop((topology.publisher, topology.cdn_b)); }) .await @@ -346,12 +349,12 @@ async fn refused_across_protocols(secondary: &str) { } #[moq_clock::test] -async fn transport_secondary_is_refused() { - refused_across_protocols("moq-transport-22").await; +async fn transport_secondary_restarts() { + restarts_across_protocols("moq-transport-22").await; } -/// The same over the first draft that can carry the epoch. +/// The same over the oldest supported moq-transport draft. #[moq_clock::test] -async fn transport_17_secondary_is_refused() { - refused_across_protocols("moq-transport-18").await; +async fn transport_18_secondary_restarts() { + restarts_across_protocols("moq-transport-18").await; } diff --git a/rs/moq-net/tests/integration/replacement.rs b/rs/moq-net/tests/integration/replacement.rs index 2e94c937366..1eb1b2553c4 100644 --- a/rs/moq-net/tests/integration/replacement.rs +++ b/rs/moq-net/tests/integration/replacement.rs @@ -77,6 +77,7 @@ const WITHIN_HOLD: Duration = Duration::from_millis(10); const PAST_HOLD: Duration = Duration::from_millis(500); async fn replace(version: &str, epoch: Option) { + let carried = (version == "moq-lite-07-wip").then_some(epoch.clone()).flatten(); let version: Version = version.parse().unwrap(); let publisher = produce_origin(1); let viewer = produce_origin(3); @@ -91,7 +92,10 @@ async fn replace(version: &str, epoch: Option) { let _first = link(version, &publisher, &viewer).await; let mut announced = viewer.consume().announced(); match next_event(&mut announced).await { - announce::Event::Start(announce) => assert_eq!(announce.prefix.as_str(), "cam"), + announce::Event::Start(announce) => { + assert_eq!(announce.prefix.as_str(), "cam"); + assert_eq!(announce.route.epoch, carried, "{version}: carried epoch"); + } other => panic!("{version}: expected the broadcast, got {other:?}"), } @@ -112,7 +116,7 @@ async fn replace(version: &str, epoch: Option) { write(&audio, "audio:0"); assert_eq!(read(&mut late).await, "audio:0", "{version}"); - if epoch.is_some() { + if carried.is_some() { // The epoch continues across the replacement: nothing to restart, and the old // handle keeps serving. if let Ok(event) = moq_clock::timeout(PAST_HOLD, announced.next()).await { @@ -171,9 +175,9 @@ async fn epoch_lite07() { .expect("timed out"); } -/// moq-transport carries the epoch with the MoQ Epoch extension. +/// A local epoch never crosses a moq-transport hop. #[moq_clock::test] -async fn epoch_ietf() { +async fn epoch_is_stripped_ietf() { for version in ["moq-transport-18", "moq-transport-22"] { moq_clock::timeout(TEST_TIMEOUT, replace(version, Some(epoch()))) .await @@ -184,7 +188,7 @@ async fn epoch_ietf() { /// Without one, a moq-transport replacement restarts as it does over lite. #[moq_clock::test] async fn epochless_ietf() { - for version in ["moq-transport-22"] { + for version in ["moq-transport-18", "moq-transport-22"] { moq_clock::timeout(TEST_TIMEOUT, replace(version, None)) .await .expect("timed out"); diff --git a/rs/moq-net/tests/integration/route_change.rs b/rs/moq-net/tests/integration/route_change.rs index d384e438774..0f745be2cb5 100644 --- a/rs/moq-net/tests/integration/route_change.rs +++ b/rs/moq-net/tests/integration/route_change.rs @@ -8,8 +8,7 @@ //! every frame exactly once, in order, whether the route changes between groups or //! in the middle of one, and however it changes. //! -//! moq-transport carries the epoch with the MoQ Epoch extension, so it behaves the -//! same. Older lite versions cannot carry the epoch, so `R` cannot tell the +//! moq-transport and older lite versions carry no epoch, so `R` cannot tell the //! two routes serve the same bytes: its subscription stays on its route and ends with //! it, while a better route restarts the path for new requests. //! @@ -467,8 +466,6 @@ macro_rules! route_change_tests { route_change_tests! { lite_07: "moq-lite-07-wip", - ietf_18: "moq-transport-18", - ietf_22: "moq-transport-22", } macro_rules! pinned_tests { @@ -507,6 +504,8 @@ macro_rules! pinned_tests { } pinned_tests! { + ietf_18: "moq-transport-18", + ietf_22: "moq-transport-22", lite_06: "moq-lite-06", lite_05: "moq-lite-05", lite_04: "moq-lite-04", diff --git a/test/interop/clients/js-native/control.ts b/test/interop/clients/js-native/control.ts index 11ddda7b9f4..7f966e6c5b4 100644 --- a/test/interop/clients/js-native/control.ts +++ b/test/interop/clients/js-native/control.ts @@ -20,7 +20,7 @@ const quic = new Qmux(new URL(address), { requireProtocol: true, }); await quic.ready; -const extensions = offered({ auth: false, epoch: false, solicit: false }); +const extensions = offered({ auth: false, solicit: false }); await exchangeSetup(quic, version, "interop-control", { client: true, extensions }); const session = new NativeSession(quic, version, true); diff --git a/test/interop/clients/js-native/goaway.ts b/test/interop/clients/js-native/goaway.ts index 6ed95ee2e90..f113fca0794 100644 --- a/test/interop/clients/js-native/goaway.ts +++ b/test/interop/clients/js-native/goaway.ts @@ -1,4 +1,4 @@ -/** Follow a Rust server's GOAWAY redirect, retaining the reader since the epoch proves continuity. */ +/** Follow a Rust server's epochless GOAWAY redirect by subscribing to its replacement announcement. */ import assert from "node:assert/strict"; import { Milli } from "@moq/clock"; import * as Moq from "@moq/net"; @@ -28,7 +28,9 @@ async function active(request: (typeof requests)[number]) { return broadcast; } try { - const track = (await active(requests[0])).track("video").subscribe({ groups: { start: { included: 0 } } }); + const initial = await active(requests[0]); + assert.equal(initial.epoch, undefined, "moq-transport carries no epoch"); + let track = initial.track("video").subscribe({ groups: { start: { included: 0 } } }); try { for (let sequence = 0; sequence < 3; sequence++) { const group = await track.recvGroup(); @@ -73,11 +75,20 @@ try { } finally { probe.close(); } + let restarted = requests[0].active.peek(); + while (restarted === undefined || restarted === initial) { + await requests[0].active.changed(); + restarted = requests[0].active.peek(); + } + assert.equal(restarted.epoch, undefined, "replacement is epochless"); + const next = restarted.track("video").subscribe({ groups: { start: { included: 2 } } }); + track.close(); + track = next; process.stdout.write("replacement ready\n"); } } // The server only writes group 2 after this acknowledgement and closing `first`. - process.stdout.write("goaway resumed\n"); + process.stdout.write("goaway restarted\n"); } finally { track.close(); } diff --git a/test/interop/clients/js-native/server.ts b/test/interop/clients/js-native/server.ts index 24c6d03f57c..7d1166423f5 100644 --- a/test/interop/clients/js-native/server.ts +++ b/test/interop/clients/js-native/server.ts @@ -143,7 +143,7 @@ try { next.writeFrame({ payload: new Uint8Array(32).fill(1) }); next.finish(); assert.equal((await commands.next()).value, "done"); - console.error("js goaway redirect resumed"); + console.error("js goaway redirect restarted"); } finally { lines.close(); initial?.connection.abort(); diff --git a/test/interop/clients/js/media.ts b/test/interop/clients/js/media.ts index 30096b1a860..2efac39390e 100644 --- a/test/interop/clients/js/media.ts +++ b/test/interop/clients/js/media.ts @@ -1089,7 +1089,9 @@ try { predicate: (s) => s.generation === 0 && !s.audioStalled, }); check( - version === "moq-lite-06" ? first.broadcastEpoch === undefined : first.broadcastEpoch !== undefined, + version === "moq-lite-07-wip" + ? first.broadcastEpoch !== undefined + : first.broadcastEpoch === undefined, "negotiated epoch identity", () => JSON.stringify(first), ); @@ -1121,7 +1123,7 @@ try { predicate: (s) => s.generation === 1 && !s.audioStalled && playsInSync(s), }); const replacementMs = Date.now() - ready; - if (version !== "moq-lite-06") + if (version === "moq-lite-07-wip") check(switched.broadcastEpoch !== first.broadcastEpoch, "replacement has a fresh epoch", () => JSON.stringify(switched), ); diff --git a/test/interop/clients/rust/goaway-client.rs b/test/interop/clients/rust/goaway-client.rs index b4499699885..1c50e4b0a58 100644 --- a/test/interop/clients/rust/goaway-client.rs +++ b/test/interop/clients/rust/goaway-client.rs @@ -1,4 +1,4 @@ -//! Follow a real JS QMux server's redirect, retaining the reader across the epoch-proven handover. +//! Follow a real JS QMux server's epochless redirect through its replacement announcement. use std::process::Stdio; use anyhow::{Context, ensure}; @@ -40,7 +40,8 @@ async fn main() -> anyhow::Result<()> { connection.version() == Some(version.parse().map_err(anyhow::Error::msg)?), "wrong negotiated version" ); - let broadcast = consumer.follow("handover", None)?.available().await?; + let mut following = consumer.follow("handover", None)?; + let broadcast = following.available().await?; let mut track = broadcast.track("video")?.subscribe(None).await?; let input = child.stdin.as_mut().context("stdin")?; for sequence in 0..2 { @@ -62,6 +63,9 @@ async fn main() -> anyhow::Result<()> { marker.read_frame().await?.context("marker frame")?.payload.as_ref() == [42], "wrong marker" ); + let restarted = following.available().await?; + ensure!(!restarted.is_clone(&broadcast), "redirect did not restart the instance"); + track = restarted.track("video")?.subscribe(None).await?; input.write_all(b"replacement ready\n").await?; } } @@ -69,6 +73,6 @@ async fn main() -> anyhow::Result<()> { ensure!(child.wait().await?.success(), "JS server failed"); let _ = connection.close().await; client.close().await; - eprintln!("reverse goaway resumed {version}"); + eprintln!("reverse goaway restarted {version}"); Ok(()) } diff --git a/test/interop/clients/rust/goaway.rs b/test/interop/clients/rust/goaway.rs index 79863b955e1..9161b1898d3 100644 --- a/test/interop/clients/rust/goaway.rs +++ b/test/interop/clients/rust/goaway.rs @@ -76,12 +76,12 @@ async fn main() -> anyhow::Result<()> { let _ = session.closed().await; write(&mut track, 2)?; ensure!( - output.next_line().await?.as_deref() == Some("goaway resumed"), - "JS subscription did not resume" + output.next_line().await?.as_deref() == Some("goaway restarted"), + "JS subscription did not restart" ); ensure!(child.wait().await?.success(), "JS client failed"); replacement.abort(moq_net::Error::Cancel); let _ = replacement.closed().await; - eprintln!("goaway redirect resumed {version}"); + eprintln!("goaway redirect restarted {version}"); Ok(()) } From 46cbf0ef3dbcf1072827a7d3e2a84a95f09c158b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 29 Oct 2026 07:17:12 -0700 Subject: [PATCH 03/41] chore(quest): record epoch-cut validation still awaiting host admission Co-Authored-By: GPT-6.1 Sol --- doc/concept/standard.md | 3 +- quest/m1/ietf-epoch-cut.md | 67 ++++++++++------------ test/interop/clients/rust/goaway-client.rs | 2 +- 3 files changed, 32 insertions(+), 40 deletions(-) diff --git a/doc/concept/standard.md b/doc/concept/standard.md index 4c3819b1b71..270d39957e1 100644 --- a/doc/concept/standard.md +++ b/doc/concept/standard.md @@ -46,8 +46,7 @@ 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, -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. diff --git a/quest/m1/ietf-epoch-cut.md b/quest/m1/ietf-epoch-cut.md index 02d6787aae7..b75481fd02a 100644 --- a/quest/m1/ietf-epoch-cut.md +++ b/quest/m1/ietf-epoch-cut.md @@ -46,43 +46,36 @@ Decision prompts: - Docs: ✅ inline fixes only; a separate docs page. - Rank: ✅ m1, after the other moq-transport narrowing quests; m2. -Implementation: - -- **Rust.** Delete `rs/moq-net/src/ietf/epoch.rs`, the `Peer::epoch` flag, - the declined-offer bookkeeping in `ietf/peer.rs`, the SETUP option in - `ietf/session.rs`, and the EPOCH parameter on PUBLISH_NAMESPACE, - NAMESPACE, SUBSCRIBE, FETCH, and TRACK_STATUS in `ietf/publisher.rs`, - `ietf/subscriber.rs`, and the message codecs. Delete the replacement - branches only an epoch-carrying advertisement takes (around `replaces` in - `ietf/publisher.rs`, and the subscriber's refusal of a namespace that gains - or loses an epoch). An inbound EPOCH parameter, now never negotiated, is - handled like any other unknown parameter. Remove `Extensions::epoch` - from `rs/moq-net/src/setup.rs` and its test - `an_epoch_crosses_only_when_both_offer_it` in - `rs/moq-net/tests/integration/extensions.rs`. -- **JS.** Delete `js/net/src/ietf/epoch.ts` and its tests, the `epoch` - option in `js/net/src/connection/extensions.ts`, the handshake's - `Ietf.Epoch.fromSetup`, the `Ietf.Epoch` export, and the EPOCH paths in the - IETF publisher, subscriber, and message codecs. -- **Tests.** The integration tests that run epoch scenarios across versions - (`replacement`, `rejoin`, `route_change`, `catalog_handover`, `multi_cdn`, - and the rest under `rs/moq-net/tests/integration`), and the interop clients - in `test/interop/clients`, expect epochless behavior on moq-transport - instead of epoch behavior. Delete the cases that only pinned EPOCH framing. - [A demoted subscriber priority reaches the publisher](/quest/m1/ts-reliability/stitch-catch-up.md) - has a "moq-transport-18 after an epoch switch, relayed" case. Whichever - lands second turns that case into an epochless restart. If a compat cell - in `test/interop/compat.sh` asserted an epoch across moq-transport, add a - planned break to `test/interop/compat/planned-breaks.json`. -- **Docs.** Say moq-lite 07 only where a doc promises epochs or redundant - ingest over moq-transport: `doc/setup/redundancy.md`, - `doc/concept/moq-lite.md`, `doc/concept/standard.md`, - `doc/concept/stats.md`, and `doc/bin/relay/cluster.md`. Remove the - `epoch` lines from `doc/bin/relay/config.md`. The PR's Impact section says - how to migrate (delete any `epoch` extension setting), for the release cut - to write into `MIGRATE.md`. - -Run `just check`, `just test interop --all`, and the compat lane. +Implemented in this quest's PR: + +- Rust and JS no longer negotiate, encode, or interpret the IETF EPOCH option + or parameter. The IETF replacement and request-pinning paths are removed; + moq-lite and the shared epoch model remain unchanged. +- Removed Rust `Extensions::epoch`, JS `extensions.epoch`, and `Ietf.Epoch`. + Updated the API report and existing docs. The epoch, cluster, and hang + drafts stay unchanged as decided. +- Updated replacement, pending-catalog, route-change, multi-CDN, browser + identity, and cross-language GOAWAY scenarios for epochless IETF restarts. + Local epoch pinning for a publisher's own track-listing resolution remains. + +Remaining work before readiness: + +- Rerun the corrected multi-CDN tests and the full moq-net suite. The last + full run compiled and reached 2,880 passing tests before stopping on the + old multi-CDN error expectation. The test now expects the cancelled old + session and checks a fresh subscription on the IETF route; that change + still needs execution. +- Run `just check`, `just ci`, `just test interop --all`, and + `just test wire-compat`. All four attempts remained at `quest gate` host + admission, with no suite output. Finish the updated GOAWAY clients' runtime + validation as part of interop. +- Complete the quest and remove its file and links only after these checks + pass. JS unit tests passed (2,563), and the JS workspace build and API + report update passed before the last interop-client edits. + +The compat matrix covers only moq-lite, so this cut adds no planned break. +[Wire compatibility remeasurement](/quest/m0/interop-green/wire-compat-remeasure.md) +owns the existing release-pin problem if the compat run reaches it. Public API: breaking. `setup::Extensions::epoch` and JS `extensions.epoch` are removed, and so is `Ietf.Epoch`. Wire: moq-transport sessions stop diff --git a/test/interop/clients/rust/goaway-client.rs b/test/interop/clients/rust/goaway-client.rs index 1c50e4b0a58..6130351d52f 100644 --- a/test/interop/clients/rust/goaway-client.rs +++ b/test/interop/clients/rust/goaway-client.rs @@ -65,7 +65,7 @@ async fn main() -> anyhow::Result<()> { ); let restarted = following.available().await?; ensure!(!restarted.is_clone(&broadcast), "redirect did not restart the instance"); - track = restarted.track("video")?.subscribe(None).await?; + track = restarted.track("video")?.subscribe(None).await?.with_range(1..); input.write_all(b"replacement ready\n").await?; } } From 3b3a10da9656038fae908736f597ae333cde275c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 30 Oct 2026 22:08:09 -0700 Subject: [PATCH 04/41] fix(net): drop the EPOCH fields main's new tests still set Main's joining-FETCH test and the moq-transport-18 e2ee transport case still expected an epoch over moq-transport, and the priority-update restart reads lite-07 alone as in place. Co-Authored-By: Claude Opus 5.5 --- doc/concept/identity.md | 12 +++++----- js/e2ee/src/transport.test.ts | 2 +- rs/moq-net-tests/tests/integration/restart.rs | 22 +++++++++---------- rs/moq-net/src/ietf/publisher.rs | 1 - 4 files changed, 16 insertions(+), 21 deletions(-) diff --git a/doc/concept/identity.md b/doc/concept/identity.md index e1da740269d..0bd922ae201 100644 --- a/doc/concept/identity.md +++ b/doc/concept/identity.md @@ -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 @@ -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 diff --git a/js/e2ee/src/transport.test.ts b/js/e2ee/src/transport.test.ts index 6731284b1c0..c372403f458 100644 --- a/js/e2ee/src/transport.test.ts +++ b/js/e2ee/src/transport.test.ts @@ -52,7 +52,7 @@ async function active(origin: Origin.Producer, path: Path.Valid): Promise, to: Option) { assert!(same.is_clone(&stale), "{version}: a re-price replaced the broadcast"); assert_eq!(claim.answered(), 1, "{version}: a re-price asked the handler again"); - // Another epoch, or none, is another instance downstream. lite-07 restarts it in place, - // and so does moq-transport (MoQ Epoch) between two epochs: the new instance is - // advertised before the old is withdrawn, so the path is never absent. - let lite = |name: &str| version == name.parse().unwrap(); - let in_place = lite("moq-lite-07-wip") || !lite("moq-lite-06") && from.is_some() && to.is_some(); + // Another epoch, or none, is another instance downstream. Only lite-07 restarts it in + // place: the new instance is advertised before the old is withdrawn, so the path is + // never absent. + let lite07 = version == "moq-lite-07-wip".parse().unwrap(); claim.update_epoch(to.clone()); - expect_replaced(version, &mut announced, "live", in_place).await; + expect_replaced(version, &mut announced, "live", lite07).await; // The subscription already open stays on the old answer. claim.write(0, "old:2"); @@ -358,11 +357,9 @@ async fn dynamic_epoch(version: &str, from: Option, to: Option) { let (fresh, mut subscription) = subscribe(&viewer, "live/cam").await; assert!(!fresh.is_clone(&stale), "{version}: joined the old instance's copy"); assert_eq!(claim.answered(), 2, "{version}: the old answer was served again"); - // Every wire here but lite-06 carries the epoch: lite-07, and moq-transport with the - // MoQ Epoch extension. - if version != "moq-lite-06".parse().unwrap() { - assert_eq!(fresh.info().epoch, to, "{version}: resolved under the old epoch"); - } + // Only lite-07 carries the epoch; the other wires resolve an epochless instance. + let epoch = if lite07 { to } else { None }; + assert_eq!(fresh.info().epoch, epoch, "{version}: resolved under the old epoch"); claim.write(1, "new:0"); assert_eq!(read(&mut subscription).await, "new:0", "{version}"); @@ -795,7 +792,8 @@ async fn priority_update(version: &str, relayed: bool, switch: bool) { .unwrap() }); if switch { - let in_place = version != "moq-lite-06".parse().unwrap(); + // Only lite-07 carries the epoch, so only it restarts in place. + let in_place = version == "moq-lite-07-wip".parse().unwrap(); expect_replaced(version, &mut announced, "live", in_place).await; } diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index ac1e7db1dbd..32f27789362 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -11460,7 +11460,6 @@ mod serve_tests { range_filters: false, fill_timeout: false, properties_wanted: true, - epoch: None, }, )); for _ in 0..100 { From cb1751501f525335c025681c7b5f14e8e99fcb62 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 31 Oct 2026 00:30:22 -0700 Subject: [PATCH 05/41] test(net): drop moq-transport resumption cases main added Main's moq-cli replacement and route-change suites asserted an in-place restart or a cross-route resume over moq-transport, which carried the epoch until this cut. Epochless moq-transport route loss stays covered by the pinned cases. Co-Authored-By: Claude Opus 5.5 --- rs/moq-cli/tests/integration/epochs.rs | 3 ++- rs/moq-net/tests/integration/route_change.rs | 12 ------------ 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/rs/moq-cli/tests/integration/epochs.rs b/rs/moq-cli/tests/integration/epochs.rs index e0843551721..7aaf82ef64f 100644 --- a/rs/moq-cli/tests/integration/epochs.rs +++ b/rs/moq-cli/tests/integration/epochs.rs @@ -205,7 +205,8 @@ async fn replacement(version: &str) { (changed(&mut catalog_follow).await, changed(&mut media_follow).await) }) .await; - assert_eq!(catalog_restart, version != "moq-lite-06"); + // Only lite-07 carries the epoch, so only it restarts in place. + assert_eq!(catalog_restart, version == LITE_07); assert_eq!(media_restart, catalog_restart); println!( "{version}: relay replacement observed in {:?} of {:?} (publisher already connected)", diff --git a/rs/moq-net/tests/integration/route_change.rs b/rs/moq-net/tests/integration/route_change.rs index 9e0589b913e..c37433006b4 100644 --- a/rs/moq-net/tests/integration/route_change.rs +++ b/rs/moq-net/tests/integration/route_change.rs @@ -303,7 +303,6 @@ async fn route_flaps(version: &str) { let triggers = [Trigger::BetterRoute, Trigger::BetterRouteDies, Trigger::Disconnect]; let mut expected = Vec::new(); let mut seen = Vec::new(); - // Keep every recovery within the largest group's head, which our IETF publishers serve. let mut group = topology.track.append_group().unwrap(); for frame in 0..FRAMES * (triggers.len() as u64 + 1) { if frame % FRAMES == FRAMES / 2 @@ -605,16 +604,6 @@ async fn lite_resumes_after_a_lagging_route_dies() { lagging_route_dies("moq-lite-07-wip".parse().unwrap()).await; } -#[moq_clock::test] -async fn ietf_17_resumes_after_a_lagging_route_dies() { - lagging_route_dies("moq-transport-18".parse().unwrap()).await; -} - -#[moq_clock::test] -async fn ietf_22_resumes_after_a_lagging_route_dies() { - lagging_route_dies("moq-transport-22".parse().unwrap()).await; -} - /// `P` publishes through the relay `O`, which `M1` and `M2` both pull from, and `E` /// reads through `M1` with `budget`, leaving `M2` cold. `P` writes a one-second group of /// ten frames every second. `M1` is killed partway through group 4: it falls silent @@ -744,7 +733,6 @@ macro_rules! route_flap_tests { route_flap_tests! { flaps_lite_07: "moq-lite-07-wip", - flaps_ietf_22: "moq-transport-22", } /// How the incumbent of a redundant pair goes away. From 60c8cd34250c088cba0820d49952a715fd04981d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 31 Oct 2026 00:30:56 -0700 Subject: [PATCH 06/41] chore(quest): finish quest/m1/ietf-epoch-cut Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 1 - quest/m1/announce-takeover.md | 5 +- quest/m1/ietf-epoch-cut.md | 93 ----------------------------------- 3 files changed, 3 insertions(+), 96 deletions(-) delete mode 100644 quest/m1/ietf-epoch-cut.md diff --git a/quest/m1/README.md b/quest/m1/README.md index aaebf63bfb1..d1b3ce98f8d 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -150,7 +150,6 @@ removed). - [moq-transport 18 and 22 only](/quest/m1/ietf-drop/README.md) - drafts 14-17 and 19-21 are deleted from Rust, JS, tests, interop lanes, and docs, removing the legacy control-stream adapter and its request limit - [Our moq-transport publishers refuse SUBSCRIBE_TRACKS](/quest/m1/ietf-tracks-refuse.md) - a peer's listing gets NOT_SUPPORTED, while Rust still lists an IETF peer's tracks upstream for the archive - [A moq-transport request token is one auth per distinct token](/quest/m1/ietf-token-auth.md) - requests naming the same token share one verification and grant, on any request type, ending with the last such request -- [moq-transport carries no epoch](/quest/m1/ietf-epoch-cut.md) - Rust and JS moq-transport sessions stop negotiating MoQ Epoch, so every moq-transport hop is epochless; the draft stays - [moq-quic's API improves on quinn's](/quest/m1/quic-api/README.md) - moq-quic and both QUIC drivers ship one mirrored API: `send::`/`recv::` namespaces, one error per direction, consuming terminal operations, plain configs, no quinn baggage - [On-demand JavaScript FETCH](/quest/m1/js-fetch-on-demand.md) - browser publishers answer IETF FETCH for evicted or uncached groups through the JS ranges request surface - [Rust cancels an unclaimed fill head when a subscription ends](/quest/m1/ietf-fill-end-cancel.md) - SUBSCRIPTION_ENDED cancels, not finishes, a fill no group was sent for, as in JS diff --git a/quest/m1/announce-takeover.md b/quest/m1/announce-takeover.md index 00470d8891b..5b5c7a9f111 100644 --- a/quest/m1/announce-takeover.md +++ b/quest/m1/announce-takeover.md @@ -13,8 +13,9 @@ job never inherits those readers. Rust and JS expose the transition as Decided in the 2026-10-10 quest audit interview: replace response-only claim epochs with `ANNOUNCE_TAKEOVER`. Keep this shared-model and lite-07 work in m1 and a prerequisite of the lite-07 cut, not the m0 release. moq-transport -gets no counterpart, since [moq-transport carries no epoch](/quest/m1/ietf-epoch-cut.md) -(decided 2026-10-28), so a moq-transport hop uses Start/Restart. Ordinary +gets no counterpart, since moq-transport carries no epoch (decided 2026-10-28, +removed in [moq-dev/moq#7239](https://github.com/moq-dev/moq/pull/7239)), so a +moq-transport hop uses Start/Restart. Ordinary Start/Restart is a supported fallback, including the accepted cold-start reset before this lands. diff --git a/quest/m1/ietf-epoch-cut.md b/quest/m1/ietf-epoch-cut.md deleted file mode 100644 index e90740778cb..00000000000 --- a/quest/m1/ietf-epoch-cut.md +++ /dev/null @@ -1,93 +0,0 @@ -# [M] moq-transport carries no epoch - -## Goal - -The Rust (`rs/moq-net/src/ietf/`) and JS (`js/net/src/ietf/`) moq-transport -sessions no longer offer, honor, or send MoQ Epoch: no EPOCH Setup Option -(`0x40B68`) and no EPOCH parameter (`0x40B69`). Every moq-transport hop is -epochless, as with a third-party peer: namespace alone names the broadcast, -and a publisher restart there reads as Restart. moq-lite 07 keeps carrying -epochs unchanged, so our own relays, which talk moq-lite to each other, keep -them. - -Non-goals: `drafts/draft-lcurley-moq-epoch.md` stays as an unimplemented -proposal, and the cluster and hang drafts keep their references to it. No -moq-transport takeover is planned. - -## Plan - -Decided with the maintainer on 2026-10-28 (`/quest-plan`, "cut epoch over -moq-transport"). - -Why: no third party implements the extension (a GitHub code search for its -code points finds only this repository), the draft was never submitted to -the datatracker, and the matching and refusal logic already lives in -`rs/moq-net/src/model/origin.rs` and `js/net/src/origin.ts`. What the -moq-transport side adds is about 360 source lines and 580 test lines from -[the feature commit](https://github.com/moq-dev/moq/commit/2dae63603f), much -of it deciding between in-place replacement and DONE plus re-advertising, -since moq-transport has no restart message. It was also the base for an [L] -negotiated takeover, which this cut drops. - -Decision prompts: - -- Scope: ✅ cut the code and the IETF takeover quest; only drop the takeover - quest; keep it all. The maintainer first picked a full cut, then kept the - draft text. -- Shared moq-lite/moq-transport core first, to make epochs cheaper: no. The - epoch semantics are already shared in the model, so a shared core would - move the moq-transport-specific replacement code, not delete it. The - shared serving machines are planned separately in - [Shared group serving and ingest](/quest/m2/shared-serve.md). -- `setup::Extensions.epoch` (Rust, `deny_unknown_fields`) and JS - `extensions.epoch`: ✅ delete, as a breaking `feat(net)!`, so a relay - config still setting `epoch = false` fails loudly at startup; accept and - ignore. -- Docs: ✅ inline fixes only; a separate docs page. -- Rank: ✅ m1, after the other moq-transport narrowing quests; m2. - -Implementation: - -- **Rust.** Delete `rs/moq-net/src/ietf/epoch.rs`, the `Peer::epoch` flag, - the declined-offer bookkeeping in `ietf/peer.rs`, the SETUP option in - `ietf/session.rs`, and the EPOCH parameter on PUBLISH_NAMESPACE, - NAMESPACE, SUBSCRIBE, FETCH, and TRACK_STATUS in `ietf/publisher.rs`, - `ietf/subscriber.rs`, and the message codecs. Delete the replacement - branches only an epoch-carrying advertisement takes (around `replaces` in - `ietf/publisher.rs`, and the subscriber's refusal of a namespace that gains - or loses an epoch). An inbound EPOCH parameter, now never negotiated, is - handled like any other unknown parameter. Remove `Extensions::epoch` - from `rs/moq-net/src/setup.rs` and its test - `an_epoch_crosses_only_when_both_offer_it` in - `rs/moq-net/tests/integration/extensions.rs`. -- **JS.** Delete `js/net/src/ietf/epoch.ts` and its tests, the `epoch` - option in `js/net/src/connection/extensions.ts`, the handshake's - `Ietf.Epoch.fromSetup`, the `Ietf.Epoch` export, and the EPOCH paths in the - IETF publisher, subscriber, and message codecs. -- **Tests.** The integration tests that run epoch scenarios across versions - (`replacement`, `rejoin`, `route_change`, `catalog_handover`, `multi_cdn`, - and the rest under `rs/moq-net/tests/integration`), and the interop clients - in `test/interop/clients`, expect epochless behavior on moq-transport - instead of epoch behavior. Delete the cases that only pinned EPOCH framing. - The priority-update coverage in `rs/moq-net-tests/tests/integration/restart.rs` - has a "moq-transport-18 after an epoch switch, relayed" case; turn it into an - epochless restart. If a compat cell - in `test/interop/compat.sh` asserted an epoch across moq-transport, add a - planned break to `test/interop/compat/planned-breaks.json`. -- **Docs.** Say moq-lite 07 only where a doc promises epochs or redundant - ingest over moq-transport: `doc/setup/redundancy.md`, - `doc/concept/moq-lite.md`, `doc/concept/standard.md`, - `doc/concept/stats.md`, and `doc/bin/relay/cluster.md`. Remove the - `epoch` lines from `doc/bin/relay/config.md`. The PR's Impact section says - how to migrate (delete any `epoch` extension setting), for the release cut - to write into `MIGRATE.md`. - -Run `just check`, `just test interop --all`, and the compat lane. - -Public API: breaking. `setup::Extensions::epoch` and JS `extensions.epoch` -are removed, and so is `Ietf.Epoch`. Wire: moq-transport sessions stop -offering EPOCH and never send its parameter. moq-lite is unchanged. - -## Related - -- [Announcement takeover](/quest/m1/announce-takeover.md) - the lite-07 takeover, which no moq-transport counterpart follows From 46249bbadb5c6646f84e52ddcbef9fb8fc280c41 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 31 Oct 2026 01:32:07 -0700 Subject: [PATCH 07/41] test(e2ee): assert moq-transport delivers no route epoch Co-Authored-By: Claude Opus 5.5 --- js/e2ee/src/transport.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/js/e2ee/src/transport.test.ts b/js/e2ee/src/transport.test.ts index c372403f458..26967bb40ba 100644 --- a/js/e2ee/src/transport.test.ts +++ b/js/e2ee/src/transport.test.ts @@ -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), }); From 846eb146cf1a3927e03327c06630eaecf14c2cf0 Mon Sep 17 00:00:00 2001 From: quest fixer Date: Sat, 31 Oct 2026 21:37:00 -0700 Subject: [PATCH 08/41] docs: repair links to retired epoch removal quest --- quest/m1/api-audit/route/README.md | 2 +- quest/m1/api-audit/route/identity.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/quest/m1/api-audit/route/README.md b/quest/m1/api-audit/route/README.md index 926c5b7d9a1..c216f8298e3 100644 --- a/quest/m1/api-audit/route/README.md +++ b/quest/m1/api-audit/route/README.md @@ -18,7 +18,7 @@ mirror it. Non-goals: the route layer's wire messages and federation ([Routes and announces](/quest/m1/cluster-routing/routes.md)), lite-07 takeover ([announce takeover](/quest/m1/announce-takeover.md)), and any -moq-transport identity ([moq-transport carries no epoch](/quest/m1/ietf-epoch-cut.md)). +moq-transport identity ([moq-transport carries no epoch](https://github.com/moq-dev/moq/pull/7239)). ## Plan diff --git a/quest/m1/api-audit/route/identity.md b/quest/m1/api-audit/route/identity.md index 4563cd9974b..8e2184bc782 100644 --- a/quest/m1/api-audit/route/identity.md +++ b/quest/m1/api-audit/route/identity.md @@ -30,7 +30,7 @@ ends that broadcast, and `follow` yields the next one. Consumers, pins and catalog references see only the identity; the epoch is never public. Non-goals: carrying identity over moq-transport (it carries none, -[decided 2026-10-28](/quest/m1/ietf-epoch-cut.md)), public-key identities +[decided 2026-10-28](https://github.com/moq-dev/moq/pull/7239)), public-key identities (a later option), and the route-handle API shape ([One Route handle](/quest/m1/api-audit/route/handle.md)). From ce9462d23f5bb727dfc9cf0557777c1fa973ef9c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 31 Oct 2026 22:49:19 -0700 Subject: [PATCH 09/41] chore(quest): claim quest/m1/api-audit/route/identity.md Quest-Claim: 9952c428ab2a4d64 From b32cb26f1462dd332c8ca2d184f626b849a5aa56 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 30 Oct 2026 20:07:04 -0700 Subject: [PATCH 10/41] feat(net)!: routes carry an identity and a per-identity epoch Every route in an origin now carries an `Identity`, non-empty text naming one continuous stream at its path, plus a crate-private `u64` epoch within it. The UUIDv7 `Epoch` is retired. - Ranking: the most specific prefix, then only an identity's highest epoch is eligible, then link preference, an identified chain, cost, a local broadcast, hop length, and a hash of the identity and path (a route that entered for exactly the path skips the hashes, so the newest wins), then within one identity a hash of the hop chain. - `request(path, identity)` and `follow(path, identity)` pin an identity; a pinned lookup follows that identity's own routes. Moving to another identity ends the broadcast and `follow` yields the next one. - A broadcast mints a random identity and keeps it across re-announcing; `Route::with_identity(identity, epoch)` names a stable one. - lite-07-wip: ANNOUNCE_START and ANNOUNCE_RESTART carry identity and epoch; TRACK, TRACKS, SUBSCRIBE and FETCH carry an optional identity pin. - Sessions mint a `~`-prefixed identity for each announcement a wire without identities delivers (older lite, moq-transport), so the origin holds no route without one. Fronts never re-splice a track through a minted identity. - Deleted the epochless machinery: route generations and renewals, `restarted_since`, the held-restart release, and Selection retirement. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 - rs/moq-net-tests/tests/integration/restart.rs | 234 +-- rs/moq-net/Cargo.toml | 1 - rs/moq-net/benches/origin.rs | 35 +- rs/moq-net/src/coding/writer.rs | 2 +- rs/moq-net/src/epoch.json | 33 - rs/moq-net/src/epoch.rs | 115 -- rs/moq-net/src/fuzz.rs | 10 +- rs/moq-net/src/identity.rs | 113 ++ rs/moq-net/src/ietf/publisher.rs | 52 +- rs/moq-net/src/ietf/subscriber.rs | 9 +- rs/moq-net/src/lib.rs | 6 +- rs/moq-net/src/lite/announce.rs | 139 +- rs/moq-net/src/lite/compress.rs | 4 +- rs/moq-net/src/lite/epoch.rs | 68 - rs/moq-net/src/lite/fetch.rs | 14 +- rs/moq-net/src/lite/identity.rs | 131 ++ rs/moq-net/src/lite/mod.rs | 2 +- rs/moq-net/src/lite/publisher.rs | 193 +- rs/moq-net/src/lite/subscribe.rs | 14 +- rs/moq-net/src/lite/subscriber.rs | 227 ++- rs/moq-net/src/lite/track.rs | 16 +- rs/moq-net/src/lite/tracks.rs | 18 +- rs/moq-net/src/lite/version.rs | 9 +- rs/moq-net/src/model/broadcast.rs | 46 +- rs/moq-net/src/model/follow.rs | 116 +- rs/moq-net/src/model/front.rs | 49 +- rs/moq-net/src/model/origin.rs | 1724 +++++++---------- rs/moq-net/src/model/resume.rs | 2 +- rs/moq-net/src/model/resume_model.rs | 29 +- rs/moq-net/src/model/track.rs | 2 +- rs/moq-net/src/path/mod.rs | 4 +- rs/moq-net/src/test_interop.rs | 2 +- .../tests/integration/announce_takeover.rs | 4 +- .../tests/integration/catalog_acquisition.rs | 13 +- rs/moq-net/tests/integration/catalog_churn.rs | 2 +- .../tests/integration/catalog_handover.rs | 6 +- rs/moq-net/tests/integration/drained_claim.rs | 4 +- rs/moq-net/tests/integration/identity.rs | 99 + rs/moq-net/tests/integration/main.rs | 1 + rs/moq-net/tests/integration/mesh_withdraw.rs | 51 +- rs/moq-net/tests/integration/multi_cdn.rs | 14 +- rs/moq-net/tests/integration/rejoin.rs | 4 +- rs/moq-net/tests/integration/replacement.rs | 114 +- rs/moq-net/tests/integration/route_change.rs | 252 ++- .../integration/routing_contracts/mod.rs | 6 +- .../integration/track_abort_over_session.rs | 2 +- .../tests/integration/track_info_cancel.rs | 2 +- .../subscriber-leaks-broadcasts.patch | 21 +- test/interop/clients/rust/audio.rs | 4 +- test/interop/clients/rust/goaway.rs | 2 +- 51 files changed, 1982 insertions(+), 2039 deletions(-) delete mode 100644 rs/moq-net/src/epoch.json delete mode 100644 rs/moq-net/src/epoch.rs create mode 100644 rs/moq-net/src/identity.rs delete mode 100644 rs/moq-net/src/lite/epoch.rs create mode 100644 rs/moq-net/src/lite/identity.rs create mode 100644 rs/moq-net/tests/integration/identity.rs diff --git a/Cargo.lock b/Cargo.lock index fc99949eb79..d2591188365 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5811,7 +5811,6 @@ dependencies = [ "tracing", "tracing-test", "url", - "uuid", ] [[package]] diff --git a/rs/moq-net-tests/tests/integration/restart.rs b/rs/moq-net-tests/tests/integration/restart.rs index 446117bfd16..047bffaf925 100644 --- a/rs/moq-net-tests/tests/integration/restart.rs +++ b/rs/moq-net-tests/tests/integration/restart.rs @@ -1,9 +1,9 @@ //! A replaced broadcast restarts announce consumers across relays, end to end over //! real sessions, while the subscriptions already on it stay. //! -//! On moq-lite-07 the restart travels as ANNOUNCE_RESTART, and on moq-transport between -//! two epochs as a new advertisement before the old one's withdrawal (MoQ Epoch); older -//! versions and anything epochless carry it as an end and a fresh start. Either way a downstream relay +//! On moq-lite-07 the restart travels as ANNOUNCE_RESTART, carrying the new identity; +//! older versions and moq-transport carry no identity, so it travels as an end and a +//! fresh start, which the receiving relay mints a new identity for. Either way a downstream relay //! retires its copy of the old source, so a new subscriber never sees the old //! instance's cached groups. @@ -11,7 +11,7 @@ use crate::support; use std::{cell::RefCell, rc::Rc, time::Duration}; -use moq_net::{Epoch, Hop, Timestamp, Version, announce, broadcast, origin, track}; +use moq_net::{Hop, Identity, Timestamp, Version, announce, broadcast, origin, track}; use support::harness::{MockConnectOptions, MockPair, connect_mock}; /// A millisecond track: the frames written here carry timestamps. @@ -108,7 +108,7 @@ async fn subscribe(origin: &origin::Producer, path: &str) -> (broadcast::Consume (broadcast, subscription) } -/// `P -> R -> V`, where a publisher at `P` without an epoch restarts while the old one +/// `P -> R -> V`, where a publisher at `P` under another identity restarts while the old one /// lingers: `V` restarts the path, its old subscription keeps the old instance, and a /// re-request reaches the new one without any of the old instance's cached groups. async fn relay_chain(version: &str) { @@ -133,7 +133,7 @@ async fn relay_chain(version: &str) { assert_eq!(read(&mut sticky).await, format!("old:{group}"), "{version}"); } - // The publisher restarts without an epoch, its old session still open. + // The publisher restarts under another identity, its old session still open. let new = publisher.publish("live", origin::Route::default()).unwrap(); let new_track = new.create_track("video", timed()).unwrap(); expect_restart(version, &mut announced, "live").await; @@ -169,8 +169,8 @@ relay_chain_tests! { relay_chain_ietf22: "moq-transport-22", } -/// A worker claiming `pool` without an epoch, answering each request with an output -/// whose `video` track carries the worker's name. The claim is a peer's, landed through +/// A worker claiming `pool` under its own identity, `worker-`, answering each request +/// with an output whose `video` track carries the worker's name. The claim is a peer's, landed through /// the hidden `fuzz` module as a session lands it, so its answers carry no exact route. struct Worker { origin: origin::Producer, @@ -182,8 +182,12 @@ impl Worker { fn new(hop: u64) -> Self { let origin = produce_origin(hop); let claim = Rc::new( - moq_net::fuzz::requests_route(&origin, moq_net::Path::from_static("pool"), origin::Route::default()) - .unwrap(), + moq_net::fuzz::requests_route( + &origin, + moq_net::Path::from_static("pool"), + origin::Route::default().with_identity(format!("worker-{hop}").parse().unwrap(), 0), + ) + .unwrap(), ); let outputs = Rc::new(RefCell::new(Vec::new())); let (handler, served) = (claim.clone(), outputs.clone()); @@ -204,10 +208,25 @@ impl Worker { } } -/// Workers `10` and `11` claim `pool` behind `U -> D`. Worker `12` joining moves -/// `pool/job-2` from `10` to itself while `11` keeps winning `pool` itself and -/// `pool/job-0`, and `10` keeps `pool/job-1` (pinned by the spread hash). `D` restarts -/// `pool`, and the next subscribes reach the right workers. +/// The worker that wins `path` among `workers`: the lowest spread hash of the path and its +/// identity, which every relay computes alike (`identity_key` in `moq-net`). +fn spread(path: &str, workers: &[u64]) -> String { + let key = |hop: &u64| { + let mut hash: u64 = 0x420C0DECB00B; + for byte in path.bytes().chain(format!("worker-{hop}").into_bytes()) { + hash = (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3); + } + hash + }; + workers.iter().min_by_key(|hop| key(hop)).unwrap().to_string() +} + +/// Workers `10` and `11` claim `pool` behind `U -> D`, each under its own identity. `U` +/// spreads the pool's paths over them, but advertises one route for `pool`, its own winner +/// for the prefix, and `D` names that route's identity on every request it forwards, so +/// every hop serves the identity the first one chose: behind `D` the pool collapses onto +/// that one worker. Worker `12` joining takes `U`'s requests for the paths it wins, and +/// `D`'s whenever `U` advertises it for `pool`. async fn prefix_pool(version: &str) { let version: Version = version.parse().unwrap(); let upstream = produce_origin(2); @@ -220,35 +239,57 @@ async fn prefix_pool(version: &str) { } settle().await; + // The worker whose identity `D`'s route for `pool` names. + let advertised = |announce: announce::Announce| { + assert_eq!(announce.prefix.as_str(), "pool"); + let identity = announce.route.identity.expect("a delivered route has an identity"); + identity.as_str().strip_prefix("worker-").expect("a worker").to_string() + }; let mut announced = downstream.consume().announced(); - match next_event(&mut announced).await { - announce::Event::Start(announce) => assert_eq!(announce.prefix.as_str(), "pool"), + let mut prefix = match next_event(&mut announced).await { + announce::Event::Start(announce) => advertised(announce), other => panic!("{version}: expected the pool, got {other:?}"), - } + }; + let paths = ["pool/job-0", "pool/job-1", "pool/job-2", "pool/job-3"]; let mut held = Vec::new(); - for (path, worker) in [("pool/job-0", "11"), ("pool/job-1", "10"), ("pool/job-2", "10")] { + for path in paths { let (broadcast, mut subscription) = subscribe(&downstream, path).await; - assert_eq!(read(&mut subscription).await, worker, "{version}: {path}"); + assert_eq!(read(&mut subscription).await, prefix, "{version}: {path}"); held.push((broadcast, subscription)); + let (_broadcast, mut subscription) = subscribe(&upstream, path).await; + assert_eq!( + read(&mut subscription).await, + spread(path, &[10, 11]), + "{version}: {path} at U" + ); } + assert!( + paths.iter().any(|path| spread(path, &[10, 11, 12]) == "12"), + "the paths must exercise the joining worker" + ); links.push(link(version, &workers[2].origin, &upstream).await); - expect_restart(version, &mut announced, "pool").await; + settle().await; + while let Ok(Some(event)) = moq_clock::timeout(Duration::from_secs(1), announced.next()).await { + match event { + announce::Event::Restart(announce) => prefix = advertised(announce), + other => panic!("{version}: expected a restart at most, got {other:?}"), + } + } - for (path, worker) in [("pool/job-0", "11"), ("pool/job-1", "10"), ("pool/job-2", "12")] { + for path in paths { let (_broadcast, mut subscription) = subscribe(&downstream, path).await; - assert_eq!(read(&mut subscription).await, worker, "{version}: {path}"); + assert_eq!(read(&mut subscription).await, prefix, "{version}: {path}"); + let (_broadcast, mut subscription) = subscribe(&upstream, path).await; + assert_eq!( + read(&mut subscription).await, + spread(path, &[10, 11, 12]), + "{version}: {path} at U" + ); } } -#[moq_clock::test] -async fn prefix_pool_lite06() { - moq_clock::timeout(TEST_TIMEOUT, prefix_pool("moq-lite-06")) - .await - .expect("timed out"); -} - #[moq_clock::test] async fn prefix_pool_lite07() { moq_clock::timeout(TEST_TIMEOUT, prefix_pool("moq-lite-07-wip")) @@ -258,17 +299,17 @@ async fn prefix_pool_lite07() { /// A claim at `live` whose handler answers each request with a fresh output, kept so /// the test can write into it: `outputs[n]` answered the `n`th request. Only a peer's -/// claim carries an epoch or re-prices, so it lands through the hidden `fuzz` module as -/// a session lands it. +/// claim moves to another identity or re-prices in place, so it lands through the hidden +/// `fuzz` module as a session lands it. struct Claim { dynamic: Rc, outputs: Rc>>, } impl Claim { - fn new(origin: &origin::Producer, epoch: Option) -> Self { + fn new(origin: &origin::Producer, identity: Option) -> Self { let mut route = origin::Route::default(); - route.epoch = epoch; + route.identity = identity; let dynamic = Rc::new(moq_net::fuzz::requests_route(origin, moq_net::Path::from_static("live"), route).unwrap()); let outputs = Rc::new(RefCell::new(Vec::new())); @@ -293,10 +334,9 @@ impl Claim { self.outputs.borrow().len() } - /// Move the claim to `epoch` in place, keeping the rest of its route. - fn update_epoch(&self, epoch: Option) { - let mut route = self.dynamic.route(); - route.epoch = epoch; + /// Move the claim to `identity` in place, keeping the rest of its route. + fn update_identity(&self, identity: Identity) { + let route = self.dynamic.route().with_identity(identity, 0); self.update_route(route); } @@ -305,11 +345,11 @@ impl Claim { } } -/// `P -> R -> V`, where `P` claims `live` at `from` and updates the claim in place. -/// A same-epoch re-price keeps every handle. Moving to `to` restarts `V`, the -/// subscription already open stays on the old answer, and a re-request asks the -/// handler again and reads none of the old answer's cached groups. -async fn dynamic_epoch(version: &str, from: Option, to: Option) { +/// `P -> R -> V`, where `P` claims `live` under `from` and updates the claim in place. +/// A re-price keeps every handle. Moving to `to` restarts `V`, the subscription already +/// open stays on the old answer, and a re-request asks the handler again and reads none +/// of the old answer's cached groups. +async fn dynamic_identity(version: &str, from: Option, to: Identity) { let version: Version = version.parse().unwrap(); let publisher = produce_origin(1); let relay = produce_origin(2); @@ -328,7 +368,7 @@ async fn dynamic_epoch(version: &str, from: Option, to: Option) { claim.write(0, "old:0"); assert_eq!(read(&mut sticky).await, "old:0", "{version}"); - // A re-price at the same epoch is an update at most: every handle survives. + // A re-price is an update at most: every handle survives. claim.update_route(claim.dynamic.route().with_cost(5)); while let Ok(event) = moq_clock::timeout(Duration::from_secs(1), announced.next()).await { assert!( @@ -342,12 +382,11 @@ async fn dynamic_epoch(version: &str, from: Option, to: Option) { assert!(same.is_clone(&stale), "{version}: a re-price replaced the broadcast"); assert_eq!(claim.answered(), 1, "{version}: a re-price asked the handler again"); - // Another epoch, or none, is another instance downstream. Only lite-07 restarts it in - // place: the new instance is advertised before the old is withdrawn, so the path is - // never absent. - let lite07 = version == "moq-lite-07-wip".parse().unwrap(); - claim.update_epoch(to.clone()); - expect_replaced(version, &mut announced, "live", lite07).await; + // Another identity is another stream downstream. lite-07 restarts it in place; the + // other wires end it and start a fresh one. + let in_place = version == "moq-lite-07-wip".parse().unwrap(); + claim.update_identity(to.clone()); + expect_replaced(version, &mut announced, "live", in_place).await; // The subscription already open stays on the old answer. claim.write(0, "old:2"); @@ -357,9 +396,14 @@ async fn dynamic_epoch(version: &str, from: Option, to: Option) { let (fresh, mut subscription) = subscribe(&viewer, "live/cam").await; assert!(!fresh.is_clone(&stale), "{version}: joined the old instance's copy"); assert_eq!(claim.answered(), 2, "{version}: the old answer was served again"); - // Only lite-07 carries the epoch; the other wires resolve an epochless instance. - let epoch = if lite07 { to } else { None }; - assert_eq!(fresh.info().epoch, epoch, "{version}: resolved under the old epoch"); + // Only lite-07 carries the identity. + if in_place { + assert_eq!( + fresh.info().identity, + Some(to), + "{version}: resolved under the old identity" + ); + } claim.write(1, "new:0"); assert_eq!(read(&mut subscription).await, "new:0", "{version}"); @@ -368,22 +412,22 @@ async fn dynamic_epoch(version: &str, from: Option, to: Option) { quiet(version, &mut announced).await; } -/// The epoch the claim starts at, and a newer one it moves to. -fn epochs() -> (Epoch, Epoch) { +/// The identity the claim starts at, and another one it moves to. +fn epochs() -> (Identity, Identity) { ( "01900000-0000-7000-8000-000000000001".parse().unwrap(), "01900000-0000-7000-8000-000000000002".parse().unwrap(), ) } -macro_rules! dynamic_epoch_tests { +macro_rules! dynamic_identity_tests { ($($name:ident: $version:literal,)*) => { $( #[moq_clock::test] async fn $name() { let (a, b) = epochs(); - for (from, to) in [(Some(a.clone()), Some(b.clone())), (Some(a), None), (None, Some(b))] { - moq_clock::timeout(TEST_TIMEOUT, dynamic_epoch($version, from, to)) + for from in [Some(a), None] { + moq_clock::timeout(TEST_TIMEOUT, dynamic_identity($version, from, b.clone())) .await .expect("timed out"); } @@ -392,51 +436,11 @@ macro_rules! dynamic_epoch_tests { }; } -dynamic_epoch_tests! { - dynamic_epoch_lite06: "moq-lite-06", - dynamic_epoch_lite07: "moq-lite-07-wip", - dynamic_epoch_ietf18: "moq-transport-18", - dynamic_epoch_ietf22: "moq-transport-22", -} - -/// An update does not override route selection: with a second claim still at the -/// epoch, a claim dropping it hands re-requests to the second, which outranks any -/// route without one. -#[moq_clock::test] -async fn dynamic_epoch_second_route_keeps_winning() { - let (a, _) = epochs(); - let origin = produce_origin(1); - let first = Claim::new(&origin, Some(a.clone())); - first.update_route(first.dynamic.route().with_cost(1)); - let second = Claim::new(&origin, Some(a.clone())); - second.update_route(second.dynamic.route().with_cost(5)); - let mut announced = origin.consume().announced(); - match next_event(&mut announced).await { - announce::Event::Start(announce) => assert_eq!(announce.route.epoch.as_ref(), Some(&a)), - other => panic!("expected the claim, got {other:?}"), - } - - let (resolved, mut sticky) = subscribe(&origin, "live/cam").await; - first.write(0, "first:0"); - assert_eq!(read(&mut sticky).await, "first:0"); - assert_eq!(second.answered(), 0, "the costlier claim answered"); - - // The epoch still stands through the second claim, so this is no restart, and the - // instance's subscriptions resume through it. - first.update_epoch(None); - while let Ok(event) = moq_clock::timeout(Duration::from_secs(1), announced.next()).await { - assert!( - matches!(event, Some(announce::Event::Update(_))), - "dropping a shared epoch delivered {event:?}" - ); - } - let fresh = origin.consume().request("live/cam", None).await.unwrap(); - assert!(fresh.is_clone(&resolved), "the epoch's broadcast was replaced"); - assert_eq!((first.answered(), second.answered()), (1, 1)); - // The same epoch serves the same bytes: group 0 was delivered, so it resumes at 1. - second.write(0, "first:0"); - second.write(0, "second:1"); - assert_eq!(read(&mut sticky).await, "second:1"); +dynamic_identity_tests! { + dynamic_identity_lite06: "moq-lite-06", + dynamic_identity_lite07: "moq-lite-07-wip", + dynamic_identity_ietf18: "moq-transport-18", + dynamic_identity_ietf22: "moq-transport-22", } /// A held track must not fill an old instance's cache from its replacement. @@ -447,12 +451,12 @@ async fn retired_fetch(version: &str, explicit: bool) { let _session = link(version, &publisher, &viewer).await; let (a, b) = epochs(); let old_route = if explicit { - origin::Route::default().with_epoch(a) + origin::Route::default().with_identity(a, 0) } else { origin::Route::default() }; let new_route = if explicit { - origin::Route::default().with_epoch(b) + origin::Route::default().with_identity(b, 0) } else { origin::Route::default() }; @@ -523,10 +527,11 @@ retired_fetch_tests! { retired_fetch_ietf22: "moq-transport-22", } -/// An epochless SUBSCRIBE names no instance, so until the publisher answers it, it may have -/// resolved whichever instance won the path. Unanswered when its instance is replaced, it is -/// refused, even if the publisher had resolved it on the old one, rather than risk filling -/// the old instance from the replacement. +/// A SUBSCRIBE on a wire without identities names none, so until the publisher answers it, +/// it may have resolved whichever identity won the path. Unanswered when its stream is +/// replaced, it is refused, even if the publisher had resolved it on the old one, rather than +/// risk filling the old stream from the replacement. lite-07 pins the identity, so its +/// SUBSCRIBE reads the old stream it named. async fn unanswered_takeover(version: &str) { let version: Version = version.parse().unwrap(); let publisher = produce_origin(1); @@ -576,7 +581,6 @@ macro_rules! unanswered_takeover_tests { unanswered_takeover_tests! { unanswered_takeover_lite05: "moq-lite-05", unanswered_takeover_lite06: "moq-lite-06", - unanswered_takeover_lite07: "moq-lite-07-wip", } #[moq_clock::test] @@ -654,7 +658,7 @@ impl Pool { drop(moq_clock::sim::spawn(async move { while let Ok(request) = handler.requested_broadcast().await { let path = request.path().to_string(); - let prepared = request.prepare(Epoch::mint()).unwrap(); + let prepared = request.prepare().unwrap(); let track = prepared.broadcast().create_track("video", timed()).unwrap(); let output = prepared.publish().unwrap(); served.borrow_mut().insert(path, (output, track)); @@ -754,7 +758,7 @@ claim_restart_tests! { /// A mid-stream priority change on a viewer's subscription reaches the publisher's /// aggregate, directly or through a relay (`P -> R -> V`), and on an old instance after -/// an epoch switch left the subscription on it. +/// an identity switch left the subscription on it. async fn priority_update(version: &str, relayed: bool, switch: bool) { let version: Version = version.parse().unwrap(); let case = format!("{version} relayed={relayed} switch={switch}"); @@ -771,7 +775,7 @@ async fn priority_update(version: &str, relayed: bool, switch: bool) { let (a, b) = epochs(); let old = publisher - .publish("live", origin::Route::default().with_epoch(a)) + .publish("live", origin::Route::default().with_identity(a, 0)) .unwrap(); let old_track = old.create_track("video", timed()).unwrap(); let mut announced = viewer.consume().announced(); @@ -786,13 +790,11 @@ async fn priority_update(version: &str, relayed: bool, switch: bool) { let _new = switch.then(|| { publisher - .publish("live", origin::Route::default().with_epoch(b)) + .publish("live", origin::Route::default().with_identity(b, 0)) .unwrap() }); if switch { - // Only lite-07 carries the epoch, so only it restarts in place. - let in_place = version == "moq-lite-07-wip".parse().unwrap(); - expect_replaced(version, &mut announced, "live", in_place).await; + expect_restart(version, &mut announced, "live").await; } // Demoted, promoted, then back to the default, each reaching the publisher before the diff --git a/rs/moq-net/Cargo.toml b/rs/moq-net/Cargo.toml index ddaff4c7a5f..608d34006e4 100644 --- a/rs/moq-net/Cargo.toml +++ b/rs/moq-net/Cargo.toml @@ -42,7 +42,6 @@ slab = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } url = { workspace = true } -uuid = { workspace = true } [dev-dependencies] criterion = { package = "bench-harness", path = "../bench-harness" } diff --git a/rs/moq-net/benches/origin.rs b/rs/moq-net/benches/origin.rs index f1319c13a18..afeb6c61f48 100644 --- a/rs/moq-net/benches/origin.rs +++ b/rs/moq-net/benches/origin.rs @@ -51,7 +51,7 @@ use std::time::Duration; use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; use futures::FutureExt; -use moq_net::{Epoch, Hop, Hops, Pattern, Patterns, Timestamp, announce, auth, broadcast, kio, origin, track}; +use moq_net::{Hop, Hops, Identity, Pattern, Patterns, Timestamp, announce, auth, broadcast, kio, origin, track}; use support::harness::{MockConnectOptions, MockPair, connect_mock}; /// A millisecond track: the frames written here carry timestamps. @@ -249,14 +249,14 @@ fn bench_withdraw(c: &mut Criterion) { fn bench_withdraw_wave(c: &mut Criterion) { let mut group = c.benchmark_group("origin/withdraw_wave"); group.sample_size(10); - let epoch: Epoch = "01900000-0000-7000-8000-000000000001".parse().unwrap(); - // One epoch, so the winner moving between the peers is an update, which is held. + let epoch: Identity = "01900000-0000-7000-8000-000000000001".parse().unwrap(); + // One identity, so the winner moving between the peers is an update, which is held. let route = |chain: &[u64]| { let mut hops = Hops::new(); for &hop in chain { hops.push(Hop::new(hop).unwrap()).unwrap(); } - origin::Route::default().with_epoch(epoch.clone()).with_hops(hops) + origin::Route::default().with_identity(epoch.clone(), 0).with_hops(hops) }; for broadcasts in [1_000, 10_000] { for subscribers in [1, 20, 100] { @@ -570,7 +570,7 @@ fn bench_reprice_duplicate(c: &mut Criterion) { for cost in [incumbent_cost - 1, incumbent_cost + 1] { moq_net::fuzz::update_route(&route, peer_route(challenger, cost)).unwrap(); for cursor in &mut cursors { - // The winner switches between epochless routes, which is another instance. + // The winner switches between unnamed routes, which is another instance. let event = cursor.next().now_or_never().flatten().expect("winner changed"); let moq_net::announce::Event::Restart(update) = event else { panic!("expected a restart: got {event:?}"); @@ -637,7 +637,7 @@ fn answer(claim: &origin::Requests, path: &str) -> origin::Publication { .expect("the request reached the claim") .unwrap(); if request.path().as_str() == path { - return request.prepare(Epoch::mint()).unwrap().publish().unwrap(); + return request.prepare().unwrap().publish().unwrap(); } } } @@ -915,8 +915,11 @@ fn bench_restart_fronts(c: &mut Criterion) { b.iter_batched_ref( || { let pool = pool(1, fronts, 0); - moq_net::fuzz::update_route(&pool._members[0], pool_route(POOL_HOP).with_epoch(Epoch::mint())) - .unwrap(); + moq_net::fuzz::update_route( + &pool._members[0], + pool_route(POOL_HOP).with_identity(Identity::random(), 0), + ) + .unwrap(); let paths: Vec<_> = (0..restarting).map(|i| format!("pool/job-{i}")).collect(); (pool, paths) }, @@ -1466,9 +1469,9 @@ impl Generation { } /// Route the front to this generation without waiting. - fn route(&self, epoch: &Epoch) { + fn route(&self, epoch: &Identity) { self.broadcast - .announce(origin::Route::default().with_epoch(epoch.clone())) + .announce(origin::Route::default().with_identity(epoch.clone(), 0)) .unwrap(); } @@ -1478,7 +1481,7 @@ impl Generation { /// copy, before its splice. That bounds the splice only on a current-thread /// runtime, where every local query is ready within the front poll that /// splices it. - async fn announce(&self, epoch: &Epoch) { + async fn announce(&self, epoch: &Identity) { self.route(epoch); for track in &self.tracks { moq_clock::timeout(COPY_WAIT, support::wanted(track.demand())) @@ -1491,7 +1494,7 @@ impl Generation { /// A front serving [`COPY_PATH`], with `copies` route copies on each of its tracks. struct CopyWalk { - epoch: Epoch, + epoch: Identity, _producer: origin::Producer, _subscribers: Vec, generations: Vec, @@ -1508,7 +1511,7 @@ async fn copy_walk(tracks: usize, copies: usize) -> CopyWalk { assert!(copies >= 1, "a front holds at least the serving copy"); let (producer, driver) = origin::Producer::new(origin::Config::default()); tokio::spawn(moq_net::time::run(driver)); - let epoch = Epoch::mint(); + let epoch = Identity::random(); let first = Generation::build(&producer, tracks); first.route(&epoch); let resolved = moq_clock::timeout(COPY_WAIT, producer.consume().request(COPY_PATH, None)) @@ -1541,7 +1544,7 @@ async fn copy_walk(tracks: usize, copies: usize) -> CopyWalk { } impl CopyWalk { - /// Swap in the prebuilt newer route at the same epoch. + /// Swap in the prebuilt newer route at the same identity. /// /// A local track's info is already known, so the front subscribes, walks /// every track, and each reader walks its copies in one driver turn. @@ -1653,7 +1656,7 @@ async fn resume_overlap(shape: ResumeShape, ordered: bool) -> ResumeOverlap { } = shape; let (producer, driver) = origin::Producer::new(origin::Config::default()); tokio::spawn(moq_net::time::run(driver)); - let epoch = Epoch::mint(); + let epoch = Identity::random(); let generation = |order: &mut dyn Iterator| { let broadcast = producer.create_broadcast(COPY_PATH).unwrap(); let track = broadcast.create_track("0", timed()).unwrap(); @@ -1667,7 +1670,7 @@ async fn resume_overlap(shape: ResumeShape, ordered: bool) -> ResumeOverlap { group.finish().unwrap(); } broadcast - .announce(origin::Route::default().with_epoch(epoch.clone())) + .announce(origin::Route::default().with_identity(epoch.clone(), 0)) .unwrap(); (broadcast, track) }; diff --git a/rs/moq-net/src/coding/writer.rs b/rs/moq-net/src/coding/writer.rs index d158ee2f780..b6c380a5109 100644 --- a/rs/moq-net/src/coding/writer.rs +++ b/rs/moq-net/src/coding/writer.rs @@ -474,7 +474,7 @@ mod tests { writer .buffer(&lite::Track { broadcast: crate::Path::from_static("test"), - epoch: None, + identity: None, track: "video".into(), }) .unwrap(); diff --git a/rs/moq-net/src/epoch.json b/rs/moq-net/src/epoch.json deleted file mode 100644 index 505e6b8be4d..00000000000 --- a/rs/moq-net/src/epoch.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "valid": [ - "00000000-0000-7000-8000-000000000000", - "0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7f", - "0199b7f4-3c2a-7d1e-af0b-2b6c1a9d8e7f", - "0199b7f4-9e10-7f42-8a3c-5d7e2b1c0f9a", - "ffffffff-ffff-7fff-bfff-ffffffffffff" - ], - "invalid": [ - "", - "alice", - "2026/09", - "0199B7F4-3C2A-7D1E-9F0B-2B6C1A9D8E7F", - "0199b7f43c2a7d1e9f0b2b6c1a9d8e7f", - "@0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7f", - "{0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7f}", - "urn:uuid:0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7f", - "0199b7f4-3c2a-4d1e-9f0b-2b6c1a9d8e7f", - "0199b7f4-3c2a-7d1e-cf0b-2b6c1a9d8e7f", - "0199b7f4-3c2a-7d1e-7f0b-2b6c1a9d8e7f", - "0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7f\n", - " 0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7f", - "0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7f ", - "0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7g" - ], - "ordered": [ - "00000000-0000-7000-8000-000000000000", - "0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7f", - "0199b7f4-3c2a-7d1e-af0b-2b6c1a9d8e7f", - "0199b7f4-9e10-7f42-8a3c-5d7e2b1c0f9a", - "ffffffff-ffff-7fff-bfff-ffffffffffff" - ] -} diff --git a/rs/moq-net/src/epoch.rs b/rs/moq-net/src/epoch.rs deleted file mode 100644 index 7e1796a5cfb..00000000000 --- a/rs/moq-net/src/epoch.rs +++ /dev/null @@ -1,115 +0,0 @@ -use std::fmt; -use std::str::FromStr; -use std::sync::Arc; - -/// One publisher instance's identity, the lowercase hyphenated text of a UUIDv7. -#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct Epoch(Arc); - -/// The epoch is not a lowercase hyphenated UUIDv7 with the RFC variant. -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] -#[error("invalid epoch: expected a lowercase hyphenated UUIDv7")] -#[non_exhaustive] -pub struct InvalidEpoch; - -impl Epoch { - /// Mint a fresh identity using the wall clock and secure randomness, ordered newest last. - pub fn mint() -> Self { - Self(uuid::Uuid::now_v7().hyphenated().to_string().into()) - } - - /// The UUID text. - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl Epoch { - /// The UUID's 16 bytes, as the wire carries them. - #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] - pub(crate) fn to_bytes(&self) -> [u8; 16] { - // Parsing enforces the canonical form, so this cannot fail. - uuid::Uuid::parse_str(&self.0).unwrap().into_bytes() - } - - /// Parse the wire's 16 bytes, refusing anything but a UUIDv7 with the RFC variant. - #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] - pub(crate) fn from_bytes(bytes: &[u8]) -> Result { - let uuid = uuid::Uuid::from_slice(bytes).map_err(|_| InvalidEpoch)?; - uuid.hyphenated().to_string().parse() - } -} - -impl FromStr for Epoch { - type Err = InvalidEpoch; - - fn from_str(text: &str) -> Result { - let uuid = uuid::Uuid::parse_str(text).map_err(|_| InvalidEpoch)?; - if uuid.get_version_num() != 7 - || uuid.get_variant() != uuid::Variant::RFC4122 - || uuid.hyphenated().to_string() != text - { - return Err(InvalidEpoch); - } - Ok(Self(text.into())) - } -} - -impl fmt::Debug for Epoch { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_tuple("Epoch").field(&self.as_str()).finish() - } -} - -impl fmt::Display for Epoch { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } -} - -impl AsRef for Epoch { - fn as_ref(&self) -> &str { - self.as_str() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn mint_is_canonical_and_ordered() { - let epoch = Epoch::mint(); - assert_eq!(epoch.as_str().parse::().unwrap(), epoch); - assert!(Epoch::mint() > epoch); - } - - /// Shared with `js/net/src/epoch.test.ts`, so both parse and order alike. - #[test] - fn epoch_vectors() { - let vectors: serde_json::Value = serde_json::from_str(include_str!("epoch.json")).unwrap(); - for row in vectors["valid"].as_array().unwrap() { - let text = row.as_str().unwrap(); - let epoch: Epoch = text.parse().unwrap(); - assert_eq!(epoch.as_str(), text); - } - for row in vectors["invalid"].as_array().unwrap() { - assert!(row.as_str().unwrap().parse::().is_err(), "{row}"); - } - let ordered: Vec = vectors["ordered"] - .as_array() - .unwrap() - .iter() - .map(|row| row.as_str().unwrap().parse().unwrap()) - .collect(); - assert!(ordered.windows(2).all(|pair| pair[0] < pair[1])); - } - - #[test] - fn bytes_roundtrip_and_refuse_other_versions() { - let epoch = Epoch::mint(); - assert_eq!(Epoch::from_bytes(&epoch.to_bytes()).unwrap(), epoch); - assert!(Epoch::from_bytes(uuid::Builder::from_random_bytes([7; 16]).into_uuid().as_bytes()).is_err()); - assert!(Epoch::from_bytes(&epoch.to_bytes()[..15]).is_err()); - } -} diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index a1b86fe84fd..4177d685520 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -349,8 +349,10 @@ impl AnnounceWriter { let msg = match announced { Announced::Start(suffix, hops) => { let (_, suffix, hops) = self.encoder.start(suffix.clone(), hops.clone()); + // A fixed identity, so the bytes stay pinned and match what `js/net` writes. lite::AnnounceBroadcast::Active { - epoch: None, + identity: self.version.has_identity().then(|| "a".parse().unwrap()), + epoch: 0, suffix, hops, cost: Default::default(), @@ -620,7 +622,7 @@ impl LiteSample { .unwrap(), Self::Subscribe => lite::Subscribe { order: crate::track::Order::Descending, - epoch: None, + identity: None, id: 3, broadcast: Path::from_static("room/alice"), track: "video".into(), @@ -825,7 +827,7 @@ impl Default for Messages { Self { lite_subscribe: lite::Subscribe { order: crate::track::Order::Descending, - epoch: None, + identity: None, id: 7, broadcast: Path::from_static("room/alice"), track: "video".into(), @@ -1004,7 +1006,7 @@ pub fn encode_subscribe(broadcast: &Path, track: &str, ietf: bool, out: &mut Vec false => lite::Subscribe { id: 0, broadcast: broadcast.clone(), - epoch: None, + identity: None, track: track.into(), priority: 0, max_delay: std::time::Duration::ZERO, diff --git a/rs/moq-net/src/identity.rs b/rs/moq-net/src/identity.rs new file mode 100644 index 00000000000..68f896b25dd --- /dev/null +++ b/rs/moq-net/src/identity.rs @@ -0,0 +1,113 @@ +use std::fmt; +use std::str::FromStr; +use std::sync::Arc; + +/// Names one continuous stream at a path. +/// +/// Within an identity, group sequences never restart and never name two different groups, +/// so a reader continues across every route that shares it. Different identities are +/// independent streams, never stitched together. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Identity(Arc); + +/// What every [minted](Identity::is_minted) identity starts with. +const MINTED: char = '~'; + +/// The identity is empty. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("invalid identity: empty")] +#[non_exhaustive] +pub struct InvalidIdentity; + +impl Identity { + /// A fresh random identity, for a new stream. + pub fn random() -> Self { + Self(random().into()) + } + + /// A fresh identity a session mints for a route its wire delivered without one. + #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] + pub(crate) fn minted() -> Self { + Self(format!("{MINTED}{}", random()).into()) + } + + /// The identity's text. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Whether a relay minted this identity, for a route whose wire (an older moq-lite, or + /// moq-transport) carries none: its text starts with `~`. It names the announcement + /// rather than the publisher's stream, so it can neither match nor contradict an identity + /// the publisher named elsewhere, such as in a catalog. Hops that forward it keep the mark. + pub fn is_minted(&self) -> bool { + self.0.starts_with(MINTED) + } +} + +/// 128 random bits as hex. +fn random() -> String { + use rand::RngExt; + format!("{:032x}", rand::rng().random::()) +} + +impl FromStr for Identity { + type Err = InvalidIdentity; + + fn from_str(text: &str) -> Result { + match text.is_empty() { + true => Err(InvalidIdentity), + false => Ok(Self(text.into())), + } + } +} + +impl TryFrom for Identity { + type Error = InvalidIdentity; + + fn try_from(text: String) -> Result { + match text.is_empty() { + true => Err(InvalidIdentity), + false => Ok(Self(text.into())), + } + } +} + +impl fmt::Debug for Identity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Identity").field(&self.as_str()).finish() + } +} + +impl fmt::Display for Identity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl AsRef for Identity { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn random_is_fresh_and_round_trips() { + let identity = Identity::random(); + assert_ne!(identity, Identity::random()); + assert_eq!(identity.as_str().parse::().unwrap(), identity); + assert!("".parse::().is_err()); + assert!(!identity.is_minted()); + let minted = Identity::minted(); + assert!(minted.is_minted()); + assert_eq!( + minted.as_str().parse::().unwrap(), + minted, + "a forwarded mark survives" + ); + } +} diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index ef632a34760..cfb73be8fcd 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -14829,9 +14829,12 @@ mod tests { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); // One epoch, so the second route re-prices the instance rather than restart it. - let epoch = crate::Epoch::mint(); + let epoch = crate::Identity::random(); let cam = origin - .announce("solo-cam", crate::origin::Route::default().with_epoch(epoch.clone())) + .announce( + "solo-cam", + crate::origin::Route::default().with_identity(epoch.clone(), 0), + ) .unwrap(); settle().await; @@ -14866,7 +14869,7 @@ mod tests { let _standby = origin .announce( "solo-cam", - crate::origin::Route::default().with_epoch(epoch).with_cost(0), + crate::origin::Route::default().with_identity(epoch, 0).with_cost(0), ) .unwrap(); @@ -14930,11 +14933,13 @@ mod tests { } .produce(); // One epoch, so the warm route re-prices the instance rather than restart it. - let epoch = crate::Epoch::mint(); + let epoch = crate::Identity::random(); let _cold = origin .announce( "cam", - crate::origin::Route::default().with_epoch(epoch.clone()).with_cost(4), + crate::origin::Route::default() + .with_identity(epoch.clone(), 0) + .with_cost(4), ) .unwrap(); settle().await; @@ -14967,7 +14972,10 @@ mod tests { // We start carrying it: a free route outranks the cold one. let _warm = origin - .announce("cam", crate::origin::Route::default().with_epoch(epoch).with_cost(0)) + .announce( + "cam", + crate::origin::Route::default().with_identity(epoch, 0).with_cost(0), + ) .unwrap(); // The request consumed id 1, so the update takes the next of our parity. The @@ -15002,11 +15010,13 @@ mod tests { const VERSION: Version = Version::Draft18; let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let epoch = crate::Epoch::mint(); + let epoch = crate::Identity::random(); let _cold = origin .announce( "cam", - crate::origin::Route::default().with_epoch(epoch.clone()).with_cost(4), + crate::origin::Route::default() + .with_identity(epoch.clone(), 0) + .with_cost(4), ) .unwrap(); settle().await; @@ -15056,7 +15066,10 @@ mod tests { peer.push(&counted); // Repricing sends the REQUEST_UPDATE whose OK is counted. let _warm = origin - .announce("cam", crate::origin::Route::default().with_epoch(epoch).with_cost(0)) + .announce( + "cam", + crate::origin::Route::default().with_identity(epoch, 0).with_cost(0), + ) .unwrap(); let res = moq_clock::timeout(Duration::from_secs(5), run) @@ -15068,7 +15081,7 @@ mod tests { assert!(log.closes().iter().any(|(c, _)| *c == code), "{:?}", log.closes()); } - /// A route from a different original publisher under the same epoch (a replica) + /// A route from a different original publisher under the same identity (a replica) /// updates the advertisement in place, like any other change within an instance: /// withdrawing it would make the namespace briefly vanish downstream just because /// its replica moved. @@ -15084,12 +15097,12 @@ mod tests { .produce(); let publisher_a = crate::Hops::try_from(vec![crate::Hop::new(7).unwrap()]).unwrap(); let publisher_b = crate::Hops::try_from(vec![crate::Hop::new(8).unwrap()]).unwrap(); - let epoch = crate::Epoch::mint(); + let epoch = crate::Identity::random(); let _from_a = origin .announce( "cam", crate::origin::Route::default() - .with_epoch(epoch.clone()) + .with_identity(epoch.clone(), 0) .with_hops(publisher_a) .with_cost(4), ) @@ -15127,7 +15140,7 @@ mod tests { .announce( "cam", crate::origin::Route::default() - .with_epoch(epoch) + .with_identity(epoch, 0) .with_hops(publisher_b) .with_cost(0), ) @@ -15673,7 +15686,7 @@ mod tests { ); } - /// moq-transport has no restart: another source winning a namespace without an epoch + /// moq-transport has no restart: another source winning a namespace under another identity /// withdraws it and advertises it afresh, so the peer resubscribes. #[moq_clock::test] async fn a_restart_re_advertises_the_namespace() { @@ -15738,11 +15751,13 @@ mod tests { const VERSION: Version = Version::Draft18; let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); - let epoch = crate::Epoch::mint(); + let epoch = crate::Identity::random(); let _cold = origin .announce( "cam", - crate::origin::Route::default().with_epoch(epoch.clone()).with_cost(4), + crate::origin::Route::default() + .with_identity(epoch.clone(), 0) + .with_cost(4), ) .unwrap(); settle().await; @@ -15778,7 +15793,10 @@ mod tests { peer.push(&refusal); let _warm = origin - .announce("cam", crate::origin::Route::default().with_epoch(epoch).with_cost(0)) + .announce( + "cam", + crate::origin::Route::default().with_identity(epoch, 0).with_cost(0), + ) .unwrap(); for _ in 0..100 { diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 6f7317ed8fc..f8ad7cc9d61 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -2453,6 +2453,8 @@ impl Subscriber { match state.broadcasts.entry(path.clone()) { Entry::Occupied(entry) => { let entry = entry.into_mut(); + // Another advertisement of a namespace the peer holds is the same stream. + route.identity = entry.route.identity.clone(); match entry.adverts.iter().position(|(advert, _)| *advert == id) { Some(index) => entry.adverts[index].1 = route, None => entry.adverts.push((id, route)), @@ -2470,6 +2472,9 @@ impl Subscriber { Ok(()) } Entry::Vacant(entry) => { + // moq-transport carries no identity, so each namespace the peer advertises anew + // is a stream of its own, even one it advertised before on this session. + route.identity = Some(crate::Identity::minted()); // A peer past its limits loses the session, not just this namespace. let slot = match slot { Some(slot) => slot, @@ -7511,7 +7516,7 @@ mod tests { } } - /// An unanswered epochless request cannot prove which instance the peer resolved. + /// An unanswered unnamed request cannot prove which instance the peer resolved. /// Retiring its source cancels it even while the caller keeps its demand. #[moq_clock::test] async fn a_retirement_before_subscribe_ok_refuses_the_subscription() { @@ -8627,7 +8632,7 @@ mod tests { } /// Two sessions assigned the same identity announce the same anonymous chain. - /// Without an epoch the fresh session is another source, so consumers restart + /// Under another identity the fresh session is another source, so consumers restart /// once, and retracting the stale session leaves the fresh route standing quietly. #[moq_clock::test] async fn reconnecting_peer_restarts_the_route() { diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index ff460ebf788..202bb7e9fea 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -56,7 +56,7 @@ //! //! ## Driving and time //! Session and origin drivers never spawn tasks or read the clock. -//! Explicitly minting an [`Epoch`] uses the wall clock and secure randomness. `Client::connect` and +//! Minting an [`Identity`] uses secure randomness. `Client::connect` and //! `Server::accept` take an initial [`time::Instant`] and return //! `(Session, Driver)`. Poll the `Driver` with the current instant and a //! [`kio::Waiter`], then wake on external activity or at the deadline it @@ -97,7 +97,6 @@ mod coding; mod discovery; #[cfg(feature = "async")] mod driver; -mod epoch; mod error; #[cfg(feature = "async")] pub mod goaway; @@ -109,6 +108,7 @@ mod goaway; #[cfg(all(feature = "async", any(test, feature = "fuzz")))] #[doc(hidden)] pub mod fuzz; +mod identity; mod ietf; mod ledger; mod lite; @@ -143,8 +143,8 @@ pub use client::*; pub use coding::{BoundsExceeded, DecodeError, EncodeError, varint}; #[cfg(feature = "async")] pub use driver::Driver; -pub use epoch::{Epoch, InvalidEpoch}; pub use error::*; +pub use identity::{Identity, InvalidIdentity}; pub use model::*; pub use path::{InvalidPattern, Path, Pattern, Patterns}; #[cfg(feature = "async")] diff --git a/rs/moq-net/src/lite/announce.rs b/rs/moq-net/src/lite/announce.rs index a47aea82aae..b2e2a0d3383 100644 --- a/rs/moq-net/src/lite/announce.rs +++ b/rs/moq-net/src/lite/announce.rs @@ -1,6 +1,6 @@ use num_enum::{IntoPrimitive, TryFromPrimitive}; -use crate::{Epoch, Hop, Hops, Path, coding::*, origin::Cost}; +use crate::{Hop, Hops, Identity, Path, coding::*, origin::Cost}; use super::{ Message, Version, @@ -13,7 +13,7 @@ use super::{ const ANNOUNCE_START: u64 = 0; const ANNOUNCE_END: u64 = 1; const ANNOUNCE_UPDATE: u64 = 2; -// lite-07: another publisher instance replaces the advertisement. +// lite-07: the advertisement serves another identity, or a newer epoch of its own. const ANNOUNCE_RESTART: u64 = 3; const SYNCED: u64 = 4; // lite-07: a new advertisement continues what a live one served beneath its prefix. @@ -49,12 +49,13 @@ pub fn update_supported(version: Version) -> bool { #[derive(Clone, Debug, PartialEq, Eq)] pub enum AnnounceBroadcast { /// ANNOUNCE_START (lite-06) / active (older): a broadcast is now available. - /// Carries the path suffix, the hop chain, and (lite-06+) the static route - /// cost, and assigns the next announce id. The epoch (lite-07+) changes only - /// with a `Restart`. + /// Carries the path suffix, (lite-07+) the identity and epoch it serves, the hop + /// chain, and (lite-06+) the static route cost, and assigns the next announce id. + /// The identity and epoch change only with a `Restart`. Active { suffix: PathRef, - epoch: Option, + identity: Option, + epoch: u64, hops: HopsRef, cost: Cost, }, @@ -67,12 +68,14 @@ pub enum AnnounceBroadcast { /// with this id (e.g. a new hop chain after a relay failover, or a route whose cost /// moved). The id stays live. Update { id: u64, hops: HopsRef, cost: Cost }, - /// ANNOUNCE_RESTART (lite-07+): another publisher instance replaces the announcement - /// with this id, under the epoch given (or none). Whatever the receiver resolved - /// under its prefix is the old instance. The id stays live. + /// ANNOUNCE_RESTART (lite-07+): the announcement with this id now serves the + /// identity and epoch given. Another identity is another stream, so whatever the + /// receiver resolved under its prefix is stale; a higher epoch of the same one + /// continues it. The id stays live. Restart { id: u64, - epoch: Option, + identity: Option, + epoch: u64, hops: HopsRef, cost: Cost, }, @@ -82,7 +85,8 @@ pub enum AnnounceBroadcast { Takeover { predecessor: u64, suffix: PathRef, - epoch: Option, + identity: Option, + epoch: u64, hops: HopsRef, cost: Cost, }, @@ -251,12 +255,13 @@ impl Encode for AnnounceBroadcast { match self { Self::Active { suffix, + identity, epoch, hops, cost, } => { suffix.encode(w, version)?; - super::epoch::encode_epoch(w, version, epoch.as_ref())?; + super::identity::encode_route(w, version, identity.as_ref(), *epoch)?; hops.encode(w, version)?; cost.encode(w, version)?; } @@ -266,22 +271,29 @@ impl Encode for AnnounceBroadcast { hops.encode(w, version)?; cost.encode(w, version)?; } - Self::Restart { id, epoch, hops, cost } => { + Self::Restart { + id, + identity, + epoch, + hops, + cost, + } => { w.varint(*id)?; - super::epoch::encode_epoch(w, version, epoch.as_ref())?; + super::identity::encode_route(w, version, identity.as_ref(), *epoch)?; hops.encode(w, version)?; cost.encode(w, version)?; } Self::Takeover { predecessor, suffix, + identity, epoch, hops, cost, } => { w.varint(*predecessor)?; suffix.encode(w, version)?; - super::epoch::encode_epoch(w, version, epoch.as_ref())?; + super::identity::encode_route(w, version, identity.as_ref(), *epoch)?; hops.encode(w, version)?; cost.encode(w, version)?; } @@ -342,12 +354,17 @@ impl<'a> Decode<'a, Version> for AnnounceBroadcast { let mut body = buf.sub(size)?; let msg = (|| { Ok(match typ { - ANNOUNCE_START => Self::Active { - suffix: PathRef::decode(&mut body, version)?, - epoch: super::epoch::decode_epoch(&mut body, version)?, - hops: HopsRef::decode(&mut body, version)?, - cost: Cost::decode(&mut body, version)?, - }, + ANNOUNCE_START => { + let suffix = PathRef::decode(&mut body, version)?; + let (identity, epoch) = super::identity::decode_route(&mut body, version)?; + Self::Active { + suffix, + identity, + epoch, + hops: HopsRef::decode(&mut body, version)?, + cost: Cost::decode(&mut body, version)?, + } + } SYNCED if version.has_synced() => Self::Synced, ANNOUNCE_END => Self::EndedId { id: body.varint()? }, ANNOUNCE_UPDATE => Self::Update { @@ -355,19 +372,30 @@ impl<'a> Decode<'a, Version> for AnnounceBroadcast { hops: HopsRef::decode(&mut body, version)?, cost: Cost::decode(&mut body, version)?, }, - ANNOUNCE_RESTART if version.has_announce_restart() => Self::Restart { - id: body.varint()?, - epoch: super::epoch::decode_epoch(&mut body, version)?, - hops: HopsRef::decode(&mut body, version)?, - cost: Cost::decode(&mut body, version)?, - }, - ANNOUNCE_TAKEOVER if version.has_announce_takeover() => Self::Takeover { - predecessor: body.varint()?, - suffix: PathRef::decode(&mut body, version)?, - epoch: super::epoch::decode_epoch(&mut body, version)?, - hops: HopsRef::decode(&mut body, version)?, - cost: Cost::decode(&mut body, version)?, - }, + ANNOUNCE_RESTART if version.has_announce_restart() => { + let id = body.varint()?; + let (identity, epoch) = super::identity::decode_route(&mut body, version)?; + Self::Restart { + id, + identity, + epoch, + hops: HopsRef::decode(&mut body, version)?, + cost: Cost::decode(&mut body, version)?, + } + } + ANNOUNCE_TAKEOVER if version.has_announce_takeover() => { + let predecessor = body.varint()?; + let suffix = PathRef::decode(&mut body, version)?; + let (identity, epoch) = super::identity::decode_route(&mut body, version)?; + Self::Takeover { + predecessor, + suffix, + identity, + epoch, + hops: HopsRef::decode(&mut body, version)?, + cost: Cost::decode(&mut body, version)?, + } + } // Unknown types are skipped by length so an earlier Lite06 build // negotiating the same ALPN does not kill the announce stream. _ => { @@ -418,7 +446,8 @@ impl AnnounceBroadcast { Ok(match status { AnnounceStatus::Active => Self::Active { suffix: PathRef::literal(suffix), - epoch: None, + identity: None, + epoch: 0, hops: HopsRef::literal(hops), cost: Cost::UNKNOWN, }, @@ -430,7 +459,8 @@ impl AnnounceBroadcast { // invalid value there. AnnounceStatus::Update if update_supported(version) => Self::Active { suffix: PathRef::literal(suffix), - epoch: None, + identity: None, + epoch: 0, hops: HopsRef::literal(hops), cost: Cost::UNKNOWN, }, @@ -701,7 +731,8 @@ mod tests { // Encode a normal Active, then flip its status byte (1 -> 2). let mut buf = Vec::new(); AnnounceBroadcast::Active { - epoch: None, + identity: None, + epoch: 0, suffix: PathRef::literal(Path::from_static("foo/bar")), hops: HopsRef::default(), cost: Cost::default(), @@ -792,7 +823,8 @@ mod tests { let mut hops = Hops::new(); hops.push(Hop::new(7).unwrap()).unwrap(); let msg = AnnounceBroadcast::Active { - epoch: None, + identity: None, + epoch: 0, suffix: PathRef::literal(Path::from_static("room/cam")), hops: HopsRef::literal(hops.clone()), cost: Cost::UNKNOWN, @@ -815,7 +847,8 @@ mod tests { let cost = Cost::new(12); let active = AnnounceBroadcast::Active { - epoch: None, + identity: None, + epoch: 0, suffix: PathRef::literal(Path::from_static("room/cam")), hops: HopsRef::literal(hops.clone()), cost, @@ -841,7 +874,8 @@ mod tests { let cost = Cost::new(12); let active = AnnounceBroadcast::Active { - epoch: None, + identity: Some("a".parse().unwrap()), + epoch: 5, suffix: PathRef { base: 2, keep: 3, @@ -867,10 +901,11 @@ mod tests { }; assert_eq!(broadcast_round_trip(&update, Version::Lite07), update); - for epoch in [None, Some(Epoch::mint())] { + for epoch in [0, 9] { let restart = AnnounceBroadcast::Restart { id: 3, - epoch: epoch.clone(), + identity: Some(Identity::random()), + epoch, hops: HopsRef { base: 4, literal: hops.clone(), @@ -887,6 +922,7 @@ mod tests { keep: 1, rest: Path::from_static("p"), }, + identity: Some(Identity::random()), epoch, hops: HopsRef { base: 1, @@ -905,7 +941,8 @@ mod tests { let takeover = AnnounceBroadcast::Takeover { predecessor: 0, suffix: PathRef::literal(Path::from_static("p")), - epoch: Some(Epoch::mint()), + identity: Some(Identity::random()), + epoch: 0, hops: HopsRef::default(), cost: Cost::default(), }; @@ -924,7 +961,8 @@ mod tests { fn announce_restart_needs_lite07() { let restart = AnnounceBroadcast::Restart { id: 1, - epoch: None, + identity: Some("a".parse().unwrap()), + epoch: 0, hops: HopsRef::default(), cost: Cost::default(), }; @@ -957,8 +995,8 @@ mod tests { #[test] fn a_keep_without_a_base_is_rejected() { for (path_keep, hop_keep) in [(1u8, 0u8), (0, 1)] { - // Path base, path keep, empty rest, no epoch, hop base, no hops, hop keep, cost. - let body = [0, path_keep, 0, 0, 0, 0, hop_keep, 0, 0]; + // Path base, path keep, empty rest, identity "a", epoch, hop base, no hops, hop keep, cost. + let body = [0, path_keep, 0, 1, b'a', 0, 0, 0, hop_keep, 0]; let mut buf = vec![ANNOUNCE_START as u8, body.len() as u8]; buf.extend_from_slice(&body); assert!(matches!( @@ -972,7 +1010,8 @@ mod tests { #[test] fn a_base_needs_lite07() { let msg = AnnounceBroadcast::Active { - epoch: None, + identity: None, + epoch: 0, suffix: PathRef { base: 1, keep: 1, @@ -1025,7 +1064,8 @@ mod tests { #[test] fn route_cost_is_dropped_before_lite06() { let msg = AnnounceBroadcast::Active { - epoch: None, + identity: None, + epoch: 0, suffix: PathRef::literal(Path::from_static("room/cam")), hops: HopsRef::default(), cost: Cost::new(9), @@ -1034,7 +1074,8 @@ mod tests { assert_eq!( got, AnnounceBroadcast::Active { - epoch: None, + identity: None, + epoch: 0, suffix: PathRef::literal(Path::from_static("room/cam")), hops: HopsRef::default(), cost: Cost::UNKNOWN, diff --git a/rs/moq-net/src/lite/compress.rs b/rs/moq-net/src/lite/compress.rs index 15a2ff30593..1ab2bfeb1ea 100644 --- a/rs/moq-net/src/lite/compress.rs +++ b/rs/moq-net/src/lite/compress.rs @@ -742,7 +742,7 @@ mod tests { /// A compressed stream pinned byte for byte: `js/net/src/lite/announce.test.ts` /// decodes the same hex, so a codec change on either side breaks both. - const GOLDEN: &str = "001600000a726f6f6d2f612f63616d0000029111a2220000000d0102036d6963000101b33301000208000101c044440100010101000c02010162000201c055550100"; + const GOLDEN: &str = "001800000a726f6f6d2f612f63616d01610000029111a2220000000f0102036d69630161000101b33301000208000101c044440100010101000e020101620161000201c055550100"; #[cfg(feature = "async")] fn golden() -> Vec { @@ -767,7 +767,7 @@ mod tests { /// The literal lite-07 stream `js/net` writes for the same announcements, which /// never picks a base. - const JS_LITERAL: &str = "001600000a726f6f6d2f612f63616d0000029111a2220000001600000a726f6f6d2f612f6d6963000002b333a2220000020a000002c04444a22200000101010013000006726f6f6d2f62000002c05555a2220000"; + const JS_LITERAL: &str = "001800000a726f6f6d2f612f63616d01610000029111a2220000001800000a726f6f6d2f612f6d69630161000002b333a2220000020a000002c04444a22200000101010015000006726f6f6d2f620161000002c05555a2220000"; #[test] #[cfg(feature = "async")] diff --git a/rs/moq-net/src/lite/epoch.rs b/rs/moq-net/src/lite/epoch.rs deleted file mode 100644 index 3bbd0838009..00000000000 --- a/rs/moq-net/src/lite/epoch.rs +++ /dev/null @@ -1,68 +0,0 @@ -use crate::{ - Epoch, - coding::{DecodeError, Decoder, EncodeError, Encoder}, -}; - -use super::Version; - -/// Encode an optional epoch as its 16 bytes, or empty for none. Older versions -/// carry nothing, so the peer sees a route or request of unknown identity. -pub(super) fn encode_epoch(w: &mut Encoder<'_>, version: Version, epoch: Option<&Epoch>) -> Result<(), EncodeError> { - if !version.has_epoch() { - return Ok(()); - } - match epoch { - Some(epoch) => w.bytes(&epoch.to_bytes()), - None => w.bytes(&[]), - } -} - -/// Decode an optional epoch: empty is none, anything but a UUIDv7 is refused. -pub(super) fn decode_epoch(r: &mut Decoder<'_>, version: Version) -> Result, DecodeError> { - if !version.has_epoch() { - return Ok(None); - } - match r.bytes()? { - [] => Ok(None), - bytes => Epoch::from_bytes(bytes) - .map(Some) - .map_err(|_| DecodeError::InvalidValue), - } -} - -#[cfg(test)] -mod test { - use super::*; - - fn roundtrip(version: Version, epoch: Option<&Epoch>) -> Option { - let mut buf = Vec::new(); - encode_epoch(&mut Encoder::new(&mut buf, version.into()), version, epoch).unwrap(); - let mut decoder = Decoder::new(&buf, version.into()); - let decoded = decode_epoch(&mut decoder, version).unwrap(); - assert!(decoder.is_empty()); - decoded - } - - #[test] - fn lite07_carries_the_epoch() { - let epoch = Epoch::mint(); - assert_eq!(roundtrip(Version::Lite07, Some(&epoch)), Some(epoch)); - assert_eq!(roundtrip(Version::Lite07, None), None); - } - - #[test] - fn older_versions_drop_it() { - let epoch = Epoch::mint(); - assert_eq!(roundtrip(Version::Lite06, Some(&epoch)), None); - } - - #[test] - fn refuses_a_non_v7_uuid() { - let mut buf = Vec::new(); - Encoder::new(&mut buf, Version::Lite07.into()) - .bytes(uuid::Builder::from_random_bytes([7; 16]).into_uuid().as_bytes()) - .unwrap(); - let mut decoder = Decoder::new(&buf, Version::Lite07.into()); - assert!(decode_epoch(&mut decoder, Version::Lite07).is_err()); - } -} diff --git a/rs/moq-net/src/lite/fetch.rs b/rs/moq-net/src/lite/fetch.rs index 331aa627c2c..d302a5fb907 100644 --- a/rs/moq-net/src/lite/fetch.rs +++ b/rs/moq-net/src/lite/fetch.rs @@ -13,9 +13,9 @@ use super::{Message, Version}; #[derive(Clone, Debug)] pub struct Fetch<'a> { pub broadcast: Path, - /// The publisher instance the subscriber expects; see [`crate::origin::Route::epoch`]. + /// The identity the subscriber expects; see [`crate::origin::Route::identity`]. /// Lite07+ only. - pub epoch: Option, + pub identity: Option, pub track: Cow<'a, str>, pub priority: i8, /// Reader freshness budget. Lite07+ only; omission uses publisher retention alone. @@ -33,7 +33,7 @@ impl Fetch<'_> { pub fn into_owned(self) -> Fetch<'static> { Fetch { broadcast: self.broadcast, - epoch: self.epoch, + identity: self.identity, track: Cow::Owned(self.track.into_owned()), priority: self.priority, max_delay: self.max_delay, @@ -55,7 +55,7 @@ impl<'a> Message<'a> for Fetch<'a> { } let broadcast = Path::decode(r, version)?; - let epoch = super::epoch::decode_epoch(r, version)?; + let identity = super::identity::decode_pin(r, version)?; let track = Cow::Borrowed(r.str()?); let priority = super::priority::from_wire(r.u8()?); let max_delay = match version.has_fetch_max_delay() { @@ -75,7 +75,7 @@ impl<'a> Message<'a> for Fetch<'a> { Ok(Self { broadcast, - epoch, + identity, track, priority, max_delay, @@ -94,7 +94,7 @@ impl<'a> Message<'a> for Fetch<'a> { } self.broadcast.encode(w, version)?; - super::epoch::encode_epoch(w, version, self.epoch.as_ref())?; + super::identity::encode_pin(w, version, self.identity.as_ref())?; w.string(&self.track)?; w.u8(super::priority::to_wire(self.priority)); match version.has_fetch_max_delay() { @@ -127,7 +127,7 @@ mod test { fn fetch_sample() -> Fetch<'static> { Fetch { - epoch: None, + identity: None, broadcast: Path::from_static("room"), track: Cow::Borrowed("video"), priority: 3, diff --git a/rs/moq-net/src/lite/identity.rs b/rs/moq-net/src/lite/identity.rs new file mode 100644 index 00000000000..47374c41760 --- /dev/null +++ b/rs/moq-net/src/lite/identity.rs @@ -0,0 +1,131 @@ +use crate::{ + Identity, + coding::{DecodeError, Decoder, EncodeError, Encoder}, +}; + +use super::Version; + +/// Encode the identity a request pins as its UTF-8 bytes, or empty for none. Older +/// versions carry nothing, so the peer resolves whichever identity wins the path. +pub(super) fn encode_pin( + w: &mut Encoder<'_>, + version: Version, + identity: Option<&Identity>, +) -> Result<(), EncodeError> { + if !version.has_identity() { + return Ok(()); + } + w.bytes(identity.map_or(&[][..], |identity| identity.as_str().as_bytes())) +} + +/// Decode the identity a request pins: empty is none, and anything but UTF-8 is refused. +pub(super) fn decode_pin(r: &mut Decoder<'_>, version: Version) -> Result, DecodeError> { + if !version.has_identity() { + return Ok(None); + } + match r.bytes()? { + [] => Ok(None), + bytes => decode(bytes).map(Some), + } +} + +/// Encode the identity and epoch an announcement serves. Every route carries an identity, +/// so lite-07 refuses to announce one without. Older versions carry neither, so the +/// receiving session mints an identity of its own. +pub(super) fn encode_route( + w: &mut Encoder<'_>, + version: Version, + identity: Option<&Identity>, + epoch: u64, +) -> Result<(), EncodeError> { + if !version.has_identity() { + return Ok(()); + } + let identity = identity.ok_or(EncodeError::InvalidState)?; + w.bytes(identity.as_str().as_bytes())?; + w.varint(epoch) +} + +/// Decode the identity and epoch an announcement serves; see [`encode_route`]. +pub(super) fn decode_route(r: &mut Decoder<'_>, version: Version) -> Result<(Option, u64), DecodeError> { + if !version.has_identity() { + return Ok((None, 0)); + } + let identity = decode(r.bytes()?)?; + Ok((Some(identity), r.varint()?)) +} + +fn decode(bytes: &[u8]) -> Result { + std::str::from_utf8(bytes) + .map_err(|_| DecodeError::InvalidValue)? + .parse() + .map_err(|_| DecodeError::InvalidValue) +} + +#[cfg(test)] +mod test { + use super::*; + + fn pin(version: Version, identity: Option<&Identity>) -> Option { + let mut buf = Vec::new(); + encode_pin(&mut Encoder::new(&mut buf, version.into()), version, identity).unwrap(); + let mut decoder = Decoder::new(&buf, version.into()); + let decoded = decode_pin(&mut decoder, version).unwrap(); + assert!(decoder.is_empty()); + decoded + } + + #[test] + fn lite07_carries_the_pin() { + let identity = Identity::random(); + assert_eq!(pin(Version::Lite07, Some(&identity)), Some(identity)); + assert_eq!(pin(Version::Lite07, None), None); + } + + #[test] + fn older_versions_drop_it() { + assert_eq!(pin(Version::Lite06, Some(&Identity::random())), None); + } + + #[test] + fn a_route_carries_its_identity_and_epoch() { + let identity = Identity::random(); + let mut buf = Vec::new(); + encode_route( + &mut Encoder::new(&mut buf, Version::Lite07.into()), + Version::Lite07, + Some(&identity), + 7, + ) + .unwrap(); + let mut decoder = Decoder::new(&buf, Version::Lite07.into()); + assert_eq!( + decode_route(&mut decoder, Version::Lite07).unwrap(), + (Some(identity), 7) + ); + assert!(decoder.is_empty()); + + // Every lite-07 route names one: none is refused both ways. + assert!( + encode_route( + &mut Encoder::new(&mut Vec::new(), Version::Lite07.into()), + Version::Lite07, + None, + 0 + ) + .is_err() + ); + let mut buf = Vec::new(); + let w = &mut Encoder::new(&mut buf, Version::Lite07.into()); + w.bytes(&[]).unwrap(); + w.varint(0).unwrap(); + assert!(decode_route(&mut Decoder::new(&buf, Version::Lite07.into()), Version::Lite07).is_err()); + } + + #[test] + fn refuses_non_utf8() { + let mut buf = Vec::new(); + Encoder::new(&mut buf, Version::Lite07.into()).bytes(&[0xff]).unwrap(); + assert!(decode_pin(&mut Decoder::new(&buf, Version::Lite07.into()), Version::Lite07).is_err()); + } +} diff --git a/rs/moq-net/src/lite/mod.rs b/rs/moq-net/src/lite/mod.rs index 651b06abca6..e583e2ddc35 100644 --- a/rs/moq-net/src/lite/mod.rs +++ b/rs/moq-net/src/lite/mod.rs @@ -17,11 +17,11 @@ mod announce; mod auth; mod compress; mod datagram; -mod epoch; pub(crate) mod error; mod fetch; mod goaway; mod group; +mod identity; pub(crate) mod ietf; mod info; pub mod message; diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index cc7a76e29c4..77631fdb50d 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -996,6 +996,16 @@ struct Advertised { /// wire cannot carry (the route's source, servability), which must not restart. hops: Hops, cost: crate::origin::Cost, + /// The identity and epoch last put on the wire (lite-07); see [`served`]. + served: Served, +} + +/// The identity and epoch a route serves, as an advertisement carries them. +type Served = (Option, u64); + +/// What `route` serves; see [`Served`]. +fn served(route: &crate::origin::Route) -> Served { + (route.identity.clone(), route.epoch) } enum AnnouncePhase { @@ -1096,7 +1106,7 @@ impl AnnounceRun { } let absolute = origin.root().rejoin(&suffix); if let Some((hops, cost)) = self.outgoing(&route, &absolute) { - self.advertise(stream, suffix, hops, cost, route.epoch, &absolute)?; + self.advertise(stream, suffix, hops, cost, served(&route), &absolute)?; } } Ok(()) @@ -1110,10 +1120,14 @@ impl AnnounceRun { suffix: crate::Path, hops: Hops, cost: crate::origin::Cost, - epoch: Option, + route: Served, absolute: &crate::Path, ) -> Result<(), Error> { match self.live.get_mut(&suffix) { + // A newer epoch of the same identity is no new stream, but the peer must rank it. + Some(advertised) if self.version.has_identity() && advertised.served != route => { + self.restart(stream, suffix, hops, cost, route, absolute)?; + } // The peer would decode what it already holds. Some(advertised) if advertised.hops == hops && advertised.cost == cost => {} // A metadata update on a live advertisement: restart it in @@ -1131,7 +1145,8 @@ impl AnnounceRun { } // lite-05: a duplicate ANNOUNCE, which assigns no id. None => stream.writer.buffer(&lite::AnnounceBroadcast::Active { - epoch: None, + identity: None, + epoch: 0, suffix: lite::PathRef::literal(suffix), hops: lite::HopsRef::literal(hops), cost, @@ -1144,7 +1159,7 @@ impl AnnounceRun { Some(_) => {} None => { tracing::debug!(route = %absolute, "announce"); - self.start(stream, suffix, hops, cost, epoch)?; + self.start(stream, suffix, hops, cost, route)?; } } Ok(()) @@ -1187,11 +1202,21 @@ impl AnnounceRun { hops: Hops, cost: crate::origin::Cost, // Fixed while the peer holds the advertisement: a new one is a restart. - epoch: Option, + route: Served, ) -> Result<(), Error> { let (id, wire, chain) = self.encoder.start(suffix.clone(), hops.clone()); - self.live.insert(suffix, Advertised { id, hops, cost }); + self.live.insert( + suffix, + Advertised { + id, + hops, + cost, + served: route.clone(), + }, + ); + let (identity, epoch) = route; stream.writer.buffer(&lite::AnnounceBroadcast::Active { + identity, epoch, suffix: wire, hops: chain, @@ -1211,7 +1236,7 @@ impl AnnounceRun { suffix: crate::Path, hops: Hops, cost: crate::origin::Cost, - epoch: Option, + route: Served, absolute: &crate::Path, ) -> Result<(), Error> { tracing::debug!(route = %absolute, predecessor = %predecessor.0, "takeover"); @@ -1222,11 +1247,14 @@ impl AnnounceRun { id: Some(id), hops, cost, + served: route.clone(), }, ); + let (identity, epoch) = route; stream.writer.buffer(&lite::AnnounceBroadcast::Takeover { predecessor: predecessor.1, suffix: wire, + identity, epoch, hops: chain, cost, @@ -1235,7 +1263,7 @@ impl AnnounceRun { Ok(()) } - /// Replace the peer's advertisement for `suffix` with another publisher instance: + /// Replace the peer's advertisement for `suffix` with another identity or epoch: /// ANNOUNCE_RESTART on lite-07, an end and a fresh start before it. fn restart( &mut self, @@ -1243,7 +1271,7 @@ impl AnnounceRun { suffix: crate::Path, hops: Hops, cost: crate::origin::Cost, - epoch: Option, + route: Served, absolute: &crate::Path, ) -> Result<(), Error> { tracing::debug!(route = %absolute, "restart"); @@ -1256,17 +1284,23 @@ impl AnnounceRun { id: Some(id), hops: hops.clone(), cost, + served: route.clone(), }, ); let hops = self.encoder.update(id, hops); - stream - .writer - .buffer(&lite::AnnounceBroadcast::Restart { id, epoch, hops, cost })?; + let (identity, epoch) = route; + stream.writer.buffer(&lite::AnnounceBroadcast::Restart { + id, + identity, + epoch, + hops, + cost, + })?; self.sent(stream)?; } _ => { self.retract(stream, suffix.clone(), absolute)?; - self.start(stream, suffix, hops, cost, epoch)?; + self.start(stream, suffix, hops, cost, route)?; } } Ok(()) @@ -1327,7 +1361,7 @@ impl AnnounceRun { let absolute = origin.root().rejoin(&suffix); let (hops, cost) = self.outgoing(&update.route, &absolute)?; tracing::debug!(route = %absolute, "announce"); - Some((suffix, hops, cost, update.route.epoch)) + Some((suffix, hops, cost, served(&update.route))) }) .collect(); match self.version { @@ -1335,8 +1369,16 @@ impl AnnounceRun { let suffixes = initial.iter().map(|(suffix, ..)| suffix.clone()).collect(); stream.writer.buffer(&lite::AnnounceInit { suffixes })?; // The peer holds these now, so a later retraction or narrower grant ends them. - for (suffix, hops, cost, _) in initial { - self.live.insert(suffix, Advertised { id: None, hops, cost }); + for (suffix, hops, cost, served) in initial { + self.live.insert( + suffix, + Advertised { + id: None, + hops, + cost, + served, + }, + ); } } _ => { @@ -1344,8 +1386,8 @@ impl AnnounceRun { origin: self.self_origin, active: initial.len() as u64, })?; - for (suffix, hops, cost, epoch) in initial { - self.start(stream, suffix, hops, cost, epoch)?; + for (suffix, hops, cost, served) in initial { + self.start(stream, suffix, hops, cost, served)?; } } } @@ -1489,16 +1531,14 @@ impl AnnounceRun { suffix, hops, cost, - update.route.epoch, + served(&update.route), &absolute, )?; } Some((hops, cost)) if restart && self.live.contains_key(&suffix) => { - self.restart(stream, suffix, hops, cost, update.route.epoch, &absolute)?; - } - Some((hops, cost)) => { - self.advertise(stream, suffix, hops, cost, update.route.epoch.clone(), &absolute)? + self.restart(stream, suffix, hops, cost, served(&update.route), &absolute)?; } + Some((hops, cost)) => self.advertise(stream, suffix, hops, cost, served(&update.route), &absolute)?, // The chain must not be forwarded (reflected, or full): retract // whatever the peer holds. None => self.retract(stream, suffix, &absolute)?, @@ -1544,8 +1584,8 @@ trait Request: Sized { /// The broadcast and track the request names. fn target(msg: &Self::Message) -> (&crate::Path, &str); - /// The publisher instance the request names, if any. - fn epoch(msg: &Self::Message) -> Option<&crate::Epoch>; + /// The identity the request pins, if any. + fn identity(msg: &Self::Message) -> Option<&crate::Identity>; /// Start answering, once the broadcast resolves. fn start(shared: &Shared, msg: Self::Message, broadcast: crate::broadcast::Consumer) -> Result; @@ -1732,7 +1772,7 @@ impl RequestServe { // errors when there is none. let origin = ready!(self.shared.poll_serving_origin(waiter)); let path = R::target(msg).0.clone(); - *requesting = Some(origin.lookup(path, R::epoch(msg)).into_inner()); + *requesting = Some(origin.lookup(path, R::identity(msg)).into_inner()); } let broadcast = ready!(requesting.as_ref().expect("requesting").poll_ok(waiter))?; // Any placeholder but `Decode`, so a failed start is answered on the stream. @@ -1873,8 +1913,8 @@ impl Request for TrackInfoServe { (&msg.broadcast, &msg.track) } - fn epoch(msg: &Self::Message) -> Option<&crate::Epoch> { - msg.epoch.as_ref() + fn identity(msg: &Self::Message) -> Option<&crate::Identity> { + msg.identity.as_ref() } fn start(_: &Shared, msg: Self::Message, broadcast: crate::broadcast::Consumer) -> Result { @@ -1931,8 +1971,8 @@ impl Request for TracksServe { (&msg.broadcast, "") } - fn epoch(msg: &Self::Message) -> Option<&crate::Epoch> { - msg.epoch.as_ref() + fn identity(msg: &Self::Message) -> Option<&crate::Identity> { + msg.identity.as_ref() } fn start(shared: &Shared, _: Self::Message, broadcast: crate::broadcast::Consumer) -> Result { @@ -2029,8 +2069,8 @@ impl Request for SubscribeServe { (&msg.broadcast, &msg.track) } - fn epoch(msg: &Self::Message) -> Option<&crate::Epoch> { - msg.epoch.as_ref() + fn identity(msg: &Self::Message) -> Option<&crate::Identity> { + msg.identity.as_ref() } fn start(shared: &Shared, msg: Self::Message, broadcast: crate::broadcast::Consumer) -> Result { @@ -2273,8 +2313,8 @@ impl Request for FetchServe { (&msg.broadcast, &msg.track) } - fn epoch(msg: &Self::Message) -> Option<&crate::Epoch> { - msg.epoch.as_ref() + fn identity(msg: &Self::Message) -> Option<&crate::Identity> { + msg.identity.as_ref() } fn start(_: &Shared, msg: Self::Message, broadcast: crate::broadcast::Consumer) -> Result { @@ -3190,8 +3230,8 @@ mod announce_test { } /// A new best route the wire cannot tell apart (another session, same chain and - /// cost) and without an epoch is another source, in either direction: an - /// ANNOUNCE_RESTART on lite-07, which may carry an epoch, and an end and a fresh + /// cost) under another identity is another source, in either direction: an + /// ANNOUNCE_RESTART on lite-07, carrying the identity, and an end and a fresh /// start before it. #[moq_clock::test] async fn source_flip_restarts() { @@ -3208,7 +3248,7 @@ mod announce_test { [ lite::AnnounceBroadcast::Restart { id: 0, - epoch: None, + identity: Some(_), cost, .. }, @@ -3237,39 +3277,39 @@ mod announce_test { } } - /// A newer epoch restarts the advertisement, carrying the epoch on lite-07. + /// Another identity restarts the advertisement, and so does a newer epoch of the same + /// one, which the peer must rank: lite-07 carries both. #[moq_clock::test] - async fn a_newer_epoch_restarts_with_it() { + async fn a_new_identity_or_epoch_restarts_with_it() { let mut h = harness_on(Version::Lite07).await; - let epoch = crate::Epoch::mint(); - let _newer = h - .origin - .announce( - "cam", - crate::origin::Route::default() - .with_epoch(epoch.clone()) - .with_hops(pub_hops()) - .with_cost(7), - ) - .unwrap(); + let identity = crate::Identity::random(); + let route = |epoch| { + crate::origin::Route::default() + .with_identity(identity.clone(), epoch) + .with_hops(pub_hops()) + .with_cost(7) + }; + let _first = h.origin.announce("cam", route(1)).unwrap(); + let _newer = h.origin.announce("cam", route(2)).unwrap(); moq_clock::sleep(crate::origin::Config::default().update_hold).await; settle().await; match h.wire.take_announces().as_slice() { [ lite::AnnounceBroadcast::Restart { id: 0, - epoch: Some(sent), + identity: Some(sent), + epoch: 2, .. }, - ] => assert_eq!(*sent, epoch), + ] => assert_eq!(*sent, identity), other => panic!("expected a restart, got {other:?}"), } h.assert_idle(); } - /// A grant that grows while a new instance replaces an advertised one still restarts it, - /// whether the instance carries a newer epoch or is an anonymous source: the regrant takes - /// in what the old cursor had pending rather than dropping it. + /// A grant that grows while another identity replaces an advertised one still restarts it, + /// whether the identity is named or minted: the regrant takes in what the old cursor had + /// pending rather than dropping it. #[moq_clock::test] async fn a_restart_pending_when_the_grant_grows_still_restarts() { // The peer's ceiling: what it may subscribe to is what we may announce to it. @@ -3288,18 +3328,23 @@ mod announce_test { // Same chain and cost, so only the instance changed. Both land before the loop polls. let route = crate::origin::Route::default().with_hops(pub_hops()).with_cost(7); - let epoch = (!anonymous).then(crate::Epoch::mint); - let route = match &epoch { - Some(epoch) => route.with_epoch(epoch.clone()), + let named = (!anonymous).then(crate::Identity::random); + let route = match &named { + Some(identity) => route.with_identity(identity.clone(), 0), None => route, }; - let _newer = h.origin.announce("cam", route).unwrap(); + let newer = h.origin.announce("cam", route).unwrap(); + let identity = newer.route().and_then(|route| route.identity); auth.handle().authorize(&grant(&["cam", "other"])); moq_clock::sleep(crate::origin::Config::default().update_hold).await; settle().await; match h.wire.take_announces().as_slice() { - [lite::AnnounceBroadcast::Restart { id: 0, epoch: sent, .. }] => { - assert_eq!(*sent, epoch, "anonymous={anonymous}") + [ + lite::AnnounceBroadcast::Restart { + id: 0, identity: sent, .. + }, + ] => { + assert_eq!(*sent, identity, "anonymous={anonymous}") } other => panic!("expected a restart (anonymous={anonymous}), got {other:?}"), } @@ -8095,7 +8140,7 @@ mod tests { for wait in [Wait::Broadcast, Wait::Track] { for close in [Close::Fin, Close::Reset] { let track = lite::Track { - epoch: None, + identity: None, broadcast: broadcast.clone(), track: "video".into(), }; @@ -8103,7 +8148,7 @@ mod tests { let subscribe = lite::Subscribe { order: crate::track::Order::Descending, - epoch: None, + identity: None, id: 0, broadcast: broadcast.clone(), track: "video".into(), @@ -8114,7 +8159,7 @@ mod tests { leave_request::(version, &subscribe, wait, close).await; let fetch = lite::Fetch { - epoch: None, + identity: None, broadcast: broadcast.clone(), track: "video".into(), priority: 0, @@ -8162,7 +8207,7 @@ mod tests { let mut script = Vec::new(); lite::Subscribe { order: crate::track::Order::Descending, - epoch: None, + identity: None, id: 0, broadcast: crate::Path::from_static("room"), track: "video".into(), @@ -8228,7 +8273,7 @@ mod tests { let mut script = Vec::new(); lite::Track { - epoch: None, + identity: None, broadcast: crate::Path::from_static("room"), track: "video".into(), } @@ -8324,7 +8369,7 @@ mod tests { let mut script = Vec::new(); lite::Subscribe { order: crate::track::Order::Descending, - epoch: None, + identity: None, id: 0, broadcast: crate::Path::from_static("room"), track: "video".into(), @@ -8443,7 +8488,7 @@ mod tests { let mut script = Vec::new(); lite::Subscribe { order: crate::track::Order::Descending, - epoch: None, + identity: None, id: 0, broadcast: crate::Path::from_static("room"), track: "video".into(), @@ -8692,7 +8737,7 @@ mod tests { let run = FetchServe::start( &publisher.shared, lite::Fetch { - epoch: None, + identity: None, broadcast: crate::Path::new("room").unwrap(), track: "video".into(), priority: 0, @@ -8740,7 +8785,7 @@ mod tests { }); let mut script = Vec::new(); lite::Fetch { - epoch: None, + identity: None, broadcast: crate::Path::from_static("room"), track: "video".into(), priority: 0, @@ -8863,7 +8908,7 @@ mod tests { broadcast.announce(Default::default()).unwrap(); let request = lite::Subscribe { order: crate::track::Order::Descending, - epoch: None, + identity: None, id: 0, broadcast: crate::Path::from_static("room"), track: "video".into(), @@ -8920,7 +8965,7 @@ mod tests { broadcast.announce(Default::default()).unwrap(); let request = lite::Tracks { broadcast: crate::Path::from_static("room"), - epoch: None, + identity: None, }; let request = request.encode_bytes(version).unwrap().to_vec(); let session = ScriptedSession::per_stream(vec![request.clone(), request]); @@ -8974,7 +9019,7 @@ mod tests { broadcast.announce(Default::default()).unwrap(); let request = lite::Subscribe { order: crate::track::Order::Descending, - epoch: None, + identity: None, id: 0, broadcast: crate::Path::from_static("room"), track: "video".into(), @@ -9111,7 +9156,7 @@ mod tests { let version = Version::Lite07; let msg = lite::Subscribe { order: crate::track::Order::Descending, - epoch: None, + identity: None, id: 0, broadcast: crate::Path::new(broadcast).unwrap(), track: track.into(), diff --git a/rs/moq-net/src/lite/subscribe.rs b/rs/moq-net/src/lite/subscribe.rs index 2eea2ece605..af65ec0e7f0 100644 --- a/rs/moq-net/src/lite/subscribe.rs +++ b/rs/moq-net/src/lite/subscribe.rs @@ -15,9 +15,9 @@ use crate::track::Order; pub struct Subscribe<'a> { pub id: u64, pub broadcast: Path, - /// The publisher instance the subscriber expects; see [`crate::origin::Route::epoch`]. + /// The identity the subscriber expects; see [`crate::origin::Route::identity`]. /// Lite07+ only. - pub epoch: Option, + pub identity: Option, pub track: Cow<'a, str>, pub priority: i8, pub max_delay: std::time::Duration, @@ -35,7 +35,7 @@ impl Subscribe<'_> { Subscribe { id: self.id, broadcast: self.broadcast, - epoch: self.epoch, + identity: self.identity, track: Cow::Owned(self.track.into_owned()), priority: self.priority, max_delay: self.max_delay, @@ -123,7 +123,7 @@ impl<'a> Message<'a> for Subscribe<'a> { fn decode_msg(r: &mut Decoder<'a>, version: Version) -> Result { let id = r.varint()?; let broadcast = Path::decode(r, version)?; - let epoch = super::epoch::decode_epoch(r, version)?; + let identity = super::identity::decode_pin(r, version)?; let track = Cow::Borrowed(r.str()?); let priority = super::priority::from_wire(r.u8()?); @@ -139,7 +139,7 @@ impl<'a> Message<'a> for Subscribe<'a> { Ok(Self { id, broadcast, - epoch, + identity, track, priority, max_delay, @@ -151,7 +151,7 @@ impl<'a> Message<'a> for Subscribe<'a> { fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { w.varint(self.id)?; self.broadcast.encode(w, version)?; - super::epoch::encode_epoch(w, version, self.epoch.as_ref())?; + super::identity::encode_pin(w, version, self.identity.as_ref())?; w.string(&self.track)?; w.u8(super::priority::to_wire(self.priority)); @@ -1032,7 +1032,7 @@ mod test { fn subscribe_sample() -> Subscribe<'static> { Subscribe { order: crate::track::Order::Descending, - epoch: None, + identity: None, id: 1, broadcast: Path::from_static("room"), track: Cow::Borrowed("video"), diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs index 0ce4ed44bb7..e11492bca44 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs @@ -90,6 +90,37 @@ pub(super) struct Subscriber { auth: crate::auth::Link, /// Broadcasts the peer may have announced at once (`session::Limits::announces`). pub(super) announces: crate::session::Slots, + /// The identities minted for the paths the peer advertises without one, shared by the + /// announce streams. + minted: Minted, +} + +/// The identity a session minted for each path a peer advertises without one (every +/// version before lite-07), shared by its announce streams: a peer serves one broadcast at +/// a path at a time, so the path advertised on two streams at once is one stream. Once +/// every advertisement of it is gone, the next one is a new stream with a fresh identity. +#[derive(Clone, Default)] +struct Minted(Lock>); + +impl Minted { + fn acquire(&self, path: &Path) -> crate::Identity { + let mut minted = self.0.lock(); + let (identity, holders) = minted + .entry(path.clone()) + .or_insert_with(|| (crate::Identity::minted(), 0)); + *holders += 1; + identity.clone() + } + + fn release(&self, path: &Path) { + let mut minted = self.0.lock(); + if let Some((_, holders)) = minted.get_mut(path) { + *holders -= 1; + if *holders == 0 { + minted.remove(path); + } + } + } } #[derive(Clone)] @@ -146,6 +177,7 @@ impl Subscriber { going_away: config.going_away, auth: config.auth, announces: Default::default(), + minted: Default::default(), } } @@ -202,6 +234,7 @@ impl Subscriber { match announce { lite::AnnounceBroadcast::Active { suffix, + identity, epoch, hops, cost, @@ -230,9 +263,11 @@ impl Subscriber { &mut run.announced, )?; } else { + // Before lite-07 an announcement names no identity, so the session mints one. + let identity = identity.map(|identity| (identity, epoch)); self.start_announce( path, - epoch, + identity, hops, cost, run.link_cost, @@ -267,11 +302,17 @@ impl Subscriber { &mut run.announced, )?; } - lite::AnnounceBroadcast::Restart { id, epoch, hops, cost } => { + lite::AnnounceBroadcast::Restart { + id, + identity, + epoch, + hops, + cost, + } => { // Resolved like an update: the id stays live. let (suffix, hops) = run.decoder.update(id, hops)?; let path = prefix.join(&suffix)?; - run.announced.restart(&path, epoch); + run.announced.restart(&path, identity.map(|identity| (identity, epoch))); self.restart_announce( path, hops, @@ -284,6 +325,7 @@ impl Subscriber { lite::AnnounceBroadcast::Takeover { predecessor, suffix, + identity, epoch, hops, cost, @@ -297,7 +339,7 @@ impl Subscriber { self.takeover_announce( previous, path, - epoch, + identity.map(|identity| (identity, epoch)), hops, cost, run.link_cost, @@ -319,8 +361,8 @@ impl Subscriber { fn start_announce( &mut self, path: Path, - // The publisher instance the peer announced, fixed until it retracts. - epoch: Option, + // The identity and epoch the peer announced, fixed until it restarts or retracts. + identity: Option<(crate::Identity, u64)>, hops: crate::Hops, // The route cost off the wire, i.e. as the peer advertised it. // [`Cost::UNKNOWN`] before lite-06, leaving the hop chain as the only @@ -344,7 +386,7 @@ impl Subscriber { // The peer holds this prefix now. Everything below either accepts the announcement, // replacing this, or declines it and leaves it exactly as reserved. Past the // session's cap this refuses the announce stream instead. - announced.reserve(path.clone(), epoch)?; + announced.reserve(path.clone(), identity)?; let Some(hops) = self.chain(&path, hops, responder_origin) else { return Ok(false); @@ -375,9 +417,9 @@ impl Subscriber { &mut self, previous: Path, path: Path, - epoch: Option, - hops: crate::Hops, // See `start_announce`. + identity: Option<(crate::Identity, u64)>, + hops: crate::Hops, cost: crate::origin::Cost, link_cost: u64, responder_origin: Option, @@ -388,13 +430,13 @@ impl Subscriber { return Err(Error::ProtocolViolation); } if path == previous { - announced.restart(&path, epoch); + announced.restart(&path, identity); } else { // Like a start: one current advertisement per prefix. if announced.contains(&path) { return Err(Error::ProtocolViolation); } - announced.reserve(path.clone(), epoch)?; + announced.reserve(path.clone(), identity)?; } let Some(hops) = self.chain(&path, hops, responder_origin) else { @@ -416,8 +458,8 @@ impl Subscriber { /// including a restart of one already attached: a connection on its way out must /// not win selection, however good the path it advertises looks. /// - /// The epoch is the one the peer announced at `path`: an update carries none, - /// since it never changes the content. + /// The identity and epoch are the ones the peer announced at `path`: an update + /// carries neither, since it never changes the stream. fn announced_route( &self, path: &Path, @@ -427,9 +469,9 @@ impl Subscriber { responder: Option, announced: &Announced, ) -> crate::origin::Route { - let mut route = crate::origin::Route { - epoch: announced.epoch(path), - ..Default::default() + let mut route = match announced.identity(path) { + Some((identity, epoch)) => crate::origin::Route::default().with_identity(identity, epoch), + None => crate::origin::Route::default(), } .with_hops(hops) .with_cost(cost.charged(link_cost)) @@ -495,8 +537,8 @@ impl Subscriber { Ok(announced.offer(self, path, metadata, None)) } - /// Handle an ANNOUNCE_RESTART: another publisher instance replaces a live - /// advertisement, under the epoch the caller recorded. + /// Handle an ANNOUNCE_RESTART: a live advertisement serves another identity, or a + /// newer epoch of its own, as the caller recorded. /// /// The replacement enters the origin as a fresh route before the old one leaves, so /// local consumers see one restart rather than an end and a start, and nothing @@ -522,7 +564,7 @@ impl Subscriber { tracing::debug!(route = %self.log_path(&path), hops = hops.len(), "restart"); let route = self.announced_route(&path, hops, cost, link_cost, responder_origin, announced); - // Held to the limit like a start: a restart outside it is withheld, dropping the old instance. + // Held to the limit like a start: a restart outside it is withheld, dropping the old route. Ok(announced.offer(self, path, route, None)) } @@ -1704,7 +1746,7 @@ impl AnnouncePrefix { let mut run: PrefixRun = PrefixRun { responder_origin, link_cost, - announced: Announced::new(self.subscriber.announces.clone()), + announced: Announced::new(self.subscriber.announces.clone(), self.subscriber.minted.clone()), decoder: lite::AnnounceDecoder::default(), auth_epoch: 0, initial, @@ -1817,8 +1859,8 @@ impl AnnouncePrefix { /// A source the announce half minted for one requested path, for the driver to serve. struct MintedSource { path: Path, - /// The publisher instance the route announced, asked for on every request. - epoch: Option, + /// The identity the route announced, asked for on every request. + identity: Option, source: crate::model::broadcast::SourceGuard, /// The source's track handler, registered before its requester could ask for a track. dynamic: crate::broadcast::Requests, @@ -1835,7 +1877,7 @@ struct MintedSource { struct SourceServe { subscriber: Subscriber, path: Path, - epoch: Option, + identity: Option, source: crate::model::broadcast::SourceGuard, dynamic: crate::broadcast::Requests, route: kio::Consumer<()>, @@ -1866,9 +1908,9 @@ struct TracksRecv { gate: crate::auth::Gate, /// The gate let the listing through, and this hop synced its readers. admitted: bool, - /// Until the peer answers, what the source must still hold: the selected instance + /// Until the peer answers, what the source must still hold: the selected identity /// until the request is out, then no replacement, since a TRACKS request without an - /// epoch lists whichever instance won the path when it arrived. + /// identity lists whichever one won the path when it arrived. unanswered: Option, } @@ -1914,7 +1956,7 @@ impl TracksRecv { stream.writer.buffer(&lite::ControlType::Tracks)?; stream.writer.buffer(&lite::Tracks { broadcast: source.path.clone(), - epoch: source.epoch.clone(), + identity: source.identity.clone(), })?; self.stream.insert(stream) } @@ -1978,7 +2020,7 @@ impl SourceServe { listing: None, subscriber, path: minted.path, - epoch: minted.epoch, + identity: minted.identity, source: minted.source, dynamic: minted.dynamic, route: minted.route, @@ -2070,7 +2112,7 @@ impl kio::Task for SourceServe { route: Some(self.route.clone()), subscriber: self.subscriber.clone(), path: self.path.clone(), - epoch: self.epoch.clone(), + identity: self.identity.clone(), name: request.name().to_string(), }; // One machine per track serves its lone subscription and any number @@ -3592,7 +3634,7 @@ mod tests { route: None, subscriber, path: Path::from_static("room"), - epoch: None, + identity: None, name: "video".to_string(), }; let broadcast = crate::broadcast::Info::new().produce(); @@ -3629,7 +3671,7 @@ mod tests { } } - /// Answer a SUBSCRIBE without an epoch with a finished, empty track, but only read the + /// Answer a SUBSCRIBE under another identity with a finished, empty track, but only read the /// answer after `retire` changed its source's selection, then return what the reader got. async fn answer_after( version: Version, @@ -3654,7 +3696,7 @@ mod tests { route: None, subscriber, path: Path::from_static("room"), - epoch: None, + identity: None, name: "video".to_string(), }; let broadcast = crate::broadcast::Info::new().produce(); @@ -3758,7 +3800,7 @@ mod tests { route: None, subscriber, path: Path::from_static("room"), - epoch: None, + identity: None, name: "video".to_string(), }; let broadcast = crate::broadcast::Info::new().produce(); @@ -3828,7 +3870,7 @@ mod tests { route: None, subscriber, path: Path::from_static("room"), - epoch: None, + identity: None, name: "video".to_string(), }; let broadcast = crate::broadcast::Info::new().produce(); @@ -3900,7 +3942,7 @@ mod tests { route: None, subscriber, path: Path::from_static("room"), - epoch: None, + identity: None, name: "video".to_string(), }; let broadcast = crate::broadcast::Info::new().produce(); @@ -4098,7 +4140,7 @@ mod tests { route: None, subscriber: subscriber.clone(), path: Path::from_static("room"), - epoch: None, + identity: None, name: "video".to_string(), }, request, @@ -4172,7 +4214,7 @@ mod tests { route: None, subscriber, path: Path::from_static("room"), - epoch: None, + identity: None, name: "video".into(), }; let info = track::Info::default().with_timescale(None); @@ -4245,7 +4287,7 @@ mod tests { route: None, subscriber, path: Path::from_static("room"), - epoch: None, + identity: None, name: "video".to_string(), }, request, @@ -4291,7 +4333,7 @@ mod tests { subscriber, MintedSource { path: Path::from_static("room"), - epoch: None, + identity: None, source: crate::model::broadcast::SourceGuard::new(broadcast), dynamic, route: route.consume(), @@ -4328,7 +4370,7 @@ mod tests { } /// An instance retired while its listing waits for a token lists nothing: a TRACKS - /// request without an epoch would list its replacement. The source ends with it. + /// request under another identity would list its replacement. The source ends with it. #[moq_clock::test] async fn a_listing_of_a_retired_instance_never_reaches_the_wire() { let auth = crate::auth::Link::new(true); @@ -4443,7 +4485,7 @@ mod tests { route: None, subscriber, path: Path::from_static("room"), - epoch: None, + identity: None, name: "video".to_string(), }, request, @@ -4494,7 +4536,7 @@ mod tests { route: None, subscriber, path: Path::from_static("room/host"), - epoch: None, + identity: None, name: "catalog.json".to_string(), }; @@ -4561,7 +4603,7 @@ mod tests { route: None, subscriber, path: Path::from_static("room/host"), - epoch: None, + identity: None, name: "audio".to_string(), }; @@ -4630,7 +4672,7 @@ mod tests { route: None, subscriber, path: Path::from_static("room/host"), - epoch: None, + identity: None, name: "catalog.json".to_string(), }, session, @@ -4696,7 +4738,7 @@ mod tests { route: None, subscriber, path: Path::from_static("room"), - epoch: None, + identity: None, name: "video".into(), }; let broadcast = crate::broadcast::Info::new().produce(); @@ -4751,7 +4793,7 @@ mod tests { route: None, subscriber, path: Path::from_static("room"), - epoch: None, + identity: None, name: "video".into(), }; let broadcast = crate::broadcast::Info::new().produce(); @@ -6015,7 +6057,8 @@ mod tests { async fn an_unknown_announce_type_keeps_the_stream() { const VERSION: Version = Version::Lite06; let start = |suffix| lite::AnnounceBroadcast::Active { - epoch: None, + identity: None, + epoch: 0, suffix: lite::PathRef::literal(Path::new(suffix).unwrap()), hops: lite::HopsRef::literal(crate::Hops::new()), cost: crate::origin::Cost::default(), @@ -6089,7 +6132,8 @@ mod tests { fn announce_start(version: Version, suffix: &'static str) -> impl Fn(&mut crate::coding::Encoder<'_>) { move |w| { lite::AnnounceBroadcast::Active { - epoch: None, + identity: version.has_identity().then(crate::Identity::random), + epoch: 0, suffix: lite::PathRef::literal(Path::new(suffix).unwrap()), hops: lite::HopsRef::literal(crate::Hops::new()), cost: crate::origin::Cost::default(), @@ -6336,7 +6380,8 @@ mod tests { async fn announces_past_the_cap_close_the_session() { const VERSION: Version = Version::Lite06; let start = |suffix| lite::AnnounceBroadcast::Active { - epoch: None, + identity: None, + epoch: 0, suffix: lite::PathRef::literal(Path::new(suffix).unwrap()), hops: lite::HopsRef::literal(crate::Hops::new()), cost: crate::origin::Cost::default(), @@ -6577,8 +6622,8 @@ struct SubStream { /// The refresh join's wanted group, restated in every SUBSCRIBE_UPDATE. wanted: Option, /// Until the publisher's first response, what the source must still hold: a SUBSCRIBE - /// without an epoch resolves whichever instance wins the path when it arrives, so one - /// still unanswered once another instance takes the path may be answered by it. + /// without an identity resolves whichever one wins the path when it arrives, so one + /// still unanswered once another identity takes the path may be answered by it. unanswered: Option, /// Whether this stream ever asked for bounded ranges alone, which a lite-07 publisher /// FINs without SUBSCRIBE_END once it covers them. @@ -6660,9 +6705,11 @@ struct Announced { /// The session's announce cap, and one slot taken from it per entry in `routes`. slots: crate::session::Slots, held: Vec, - /// The epoch each advertisement named, kept while it stands even when declined, - /// so a restart that attaches it later serves the same broadcast. - epochs: HashMap, + /// The identity and epoch each advertisement serves, kept while it stands even when + /// declined, so a restart that attaches it later serves the same stream, and whether + /// the session minted it. + identities: HashMap, + minted: Minted, /// Attached routes whose request queue woke since the last serve pass. The /// driver wakes for every group the session carries, so a pass must cost what /// was requested, not every route the peer announced. @@ -6683,17 +6730,28 @@ enum Held { #[cfg(test)] impl Default for Announced { fn default() -> Self { - Self::new(Default::default()) + Self::new(Default::default(), Default::default()) + } +} + +impl Drop for Announced { + fn drop(&mut self) { + for (path, (_, minted)) in std::mem::take(&mut self.identities) { + if minted { + self.minted.release(&path); + } + } } } impl Announced { - fn new(slots: crate::session::Slots) -> Self { + fn new(slots: crate::session::Slots, minted: Minted) -> Self { Self { routes: HashMap::new(), slots, held: Vec::new(), - epochs: HashMap::new(), + identities: HashMap::new(), + minted, ready: Default::default(), } } @@ -6771,27 +6829,37 @@ impl Announced { /// with [`Self::contains`]. Overwriting an attached route is [`Self::declined`]'s job. /// /// Fails with [`Error::TooManyRequests`] once the session holds as many as it allows. - fn reserve(&mut self, path: Path, epoch: Option) -> Result<(), Error> { + fn reserve(&mut self, path: Path, identity: Option<(crate::Identity, u64)>) -> Result<(), Error> { debug_assert!(!self.routes.contains_key(&path), "reserved a prefix already advertised"); self.held.push(self.slots.acquire()?); - if let Some(epoch) = epoch { - self.epochs.insert(path.clone(), epoch); - } + let identity = match identity { + Some(identity) => (identity, false), + None => ((self.minted.acquire(&path), 0), true), + }; + self.forget(&path); + self.identities.insert(path.clone(), identity); self.routes.insert(path, Held::Declined); Ok(()) } - /// The epoch the advertisement at `path` named, if any. - fn epoch(&self, path: &Path) -> Option { - self.epochs.get(path).cloned() + /// The identity and epoch the advertisement at `path` serves. + fn identity(&self, path: &Path) -> Option<(crate::Identity, u64)> { + self.identities.get(path).map(|(identity, _)| identity.clone()) } - /// Record the epoch a restart of the live advertisement at `path` names. - fn restart(&mut self, path: &Path, epoch: Option) { - match epoch { - Some(epoch) => self.epochs.insert(path.clone(), epoch), - None => self.epochs.remove(path), - }; + /// Record the identity and epoch a restart of the live advertisement at `path` names. + fn restart(&mut self, path: &Path, identity: Option<(crate::Identity, u64)>) { + self.forget(path); + if let Some(identity) = identity { + self.identities.insert(path.clone(), (identity, false)); + } + } + + /// Drop the identity recorded for `path`, releasing it if the session minted it. + fn forget(&mut self, path: &Path) { + if let Some((_, true)) = self.identities.remove(path) { + self.minted.release(path); + } } fn attached(&mut self, path: &Path) -> Option<&mut AnnouncedRoute> { @@ -6804,7 +6872,7 @@ impl Announced { /// Retire this session's advertisement without invalidating another live /// session from the same peer. Dropping its sources closes their requests. fn withdraw(&mut self, path: &Path) { - self.epochs.remove(path); + self.forget(path); let Some(held) = self.routes.remove(path) else { return; }; @@ -6836,9 +6904,10 @@ impl Announced { continue; }; let path = path.clone(); - // A request naming the epoch reaches only that instance, so only a source - // without one must stop once the peer no longer selects what minted it. - let validity = if entry.route.epoch.is_some() { + // A request naming the identity reaches only that stream, so only a source on + // a wire that cannot name one must stop once the peer no longer selects what + // minted it. + let validity = if subscriber.version.has_identity() { Default::default() } else { crate::model::broadcast::RequestValidity::for_source(&entry.dynamic, request.path()) @@ -6852,7 +6921,7 @@ impl Announced { request.accept(&source); let _ = subscriber.sources.try_push(MintedSource { path, - epoch: entry.route.epoch.clone(), + identity: entry.route.identity.clone(), source: crate::model::broadcast::SourceGuard::new(source), dynamic, route: entry.live.consume(), @@ -6983,8 +7052,8 @@ struct TrackServe { route: Option>, subscriber: Subscriber, path: Path, - /// The publisher instance to ask for; the peer refuses a request for another. - epoch: Option, + /// The identity to ask for; the peer refuses a request for another. + identity: Option, name: String, } @@ -7283,7 +7352,7 @@ impl Establish { waiter ))?; let msg = lite::Subscribe { - epoch: self.serve.epoch.clone(), + identity: self.serve.identity.clone(), id: self.id, broadcast: self.serve.path.clone(), track: self.serve.name.as_str().into(), @@ -7606,7 +7675,7 @@ impl TrackInfoFetch { .with_stats(serve.subscriber.origin.stats()); stream.writer.buffer(&lite::ControlType::Track)?; stream.writer.buffer(&lite::Track { - epoch: serve.epoch.clone(), + identity: serve.identity.clone(), broadcast: serve.path.clone(), track: serve.name.as_str().into(), })?; @@ -8607,7 +8676,7 @@ impl kio::Task for FetchServeRun { }; let msg = lite::Fetch { - epoch: self.serve.epoch.clone(), + identity: self.serve.identity.clone(), broadcast: self.serve.path.clone(), track: self.serve.name.as_str().into(), priority: request.priority(), diff --git a/rs/moq-net/src/lite/track.rs b/rs/moq-net/src/lite/track.rs index 43ecbd2951b..6a95eacad33 100644 --- a/rs/moq-net/src/lite/track.rs +++ b/rs/moq-net/src/lite/track.rs @@ -17,9 +17,9 @@ const LEGACY_UNLIMITED: u64 = (1u64 << 53) - 1; #[derive(Clone, Debug)] pub struct Track<'a> { pub broadcast: Path, - /// The publisher instance the subscriber expects; see [`crate::origin::Route::epoch`]. + /// The identity the subscriber expects; see [`crate::origin::Route::identity`]. /// Lite07+ only. - pub epoch: Option, + pub identity: Option, pub track: Cow<'a, str>, } @@ -28,7 +28,7 @@ impl Track<'_> { pub fn into_owned(self) -> Track<'static> { Track { broadcast: self.broadcast, - epoch: self.epoch, + identity: self.identity, track: Cow::Owned(self.track.into_owned()), } } @@ -42,12 +42,12 @@ impl<'a> Message<'a> for Track<'a> { } let broadcast = Path::decode(r, version)?; - let epoch = super::epoch::decode_epoch(r, version)?; + let identity = super::identity::decode_pin(r, version)?; let track = Cow::Borrowed(r.str()?); Ok(Self { broadcast, - epoch, + identity, track, }) } @@ -58,7 +58,7 @@ impl<'a> Message<'a> for Track<'a> { } self.broadcast.encode(w, version)?; - super::epoch::encode_epoch(w, version, self.epoch.as_ref())?; + super::identity::encode_pin(w, version, self.identity.as_ref())?; w.string(&self.track)?; Ok(()) } @@ -493,7 +493,7 @@ mod test { #[test] fn track_request_roundtrips_on_lite05() { let msg = Track { - epoch: None, + identity: None, broadcast: Path::from_static("room"), track: Cow::Borrowed("video"), }; @@ -509,7 +509,7 @@ mod test { #[test] fn track_request_errors_before_lite05() { let msg = Track { - epoch: None, + identity: None, broadcast: Path::from_static("room"), track: Cow::Borrowed("video"), }; diff --git a/rs/moq-net/src/lite/tracks.rs b/rs/moq-net/src/lite/tracks.rs index 2b8151ddf43..4c9c67fb839 100644 --- a/rs/moq-net/src/lite/tracks.rs +++ b/rs/moq-net/src/lite/tracks.rs @@ -19,8 +19,8 @@ const SYNCED: u64 = 0x4; #[derive(Clone, Debug)] pub struct Tracks { pub broadcast: Path, - /// The publisher instance the subscriber expects; see [`crate::origin::Route::epoch`]. - pub epoch: Option, + /// The identity the subscriber expects; see [`crate::origin::Route::identity`]. + pub identity: Option, } impl<'a> Message<'a> for Tracks { @@ -30,8 +30,8 @@ impl<'a> Message<'a> for Tracks { return Err(DecodeError::Version); } let broadcast = Path::decode(r, version)?; - let epoch = super::epoch::decode_epoch(r, version)?; - Ok(Self { broadcast, epoch }) + let identity = super::identity::decode_pin(r, version)?; + Ok(Self { broadcast, identity }) } fn encode_msg(&self, w: &mut Encoder<'_>, version: Version) -> Result<(), EncodeError> { @@ -39,7 +39,7 @@ impl<'a> Message<'a> for Tracks { return Err(EncodeError::Version); } self.broadcast.encode(w, version)?; - super::epoch::encode_epoch(w, version, self.epoch.as_ref()) + super::identity::encode_pin(w, version, self.identity.as_ref()) } } @@ -206,22 +206,22 @@ mod test { #[test] fn the_request_roundtrips() { - let epoch: crate::Epoch = "01900000-0000-7000-8000-000000000001".parse().unwrap(); + let identity: crate::Identity = "alice".parse().unwrap(); let msg = Tracks { broadcast: Path::from_static("room/alice"), - epoch: Some(epoch.clone()), + identity: Some(identity.clone()), }; let buf = msg.encode_bytes(Version::Lite07).unwrap(); let (got, _) = Tracks::decode_slice(&buf, Version::Lite07).unwrap(); assert_eq!(got.broadcast, Path::from_static("room/alice")); - assert_eq!(got.epoch, Some(epoch)); + assert_eq!(got.identity, Some(identity)); } #[test] fn older_versions_have_no_tracks_stream() { let msg = Tracks { broadcast: Path::from_static("room"), - epoch: None, + identity: None, }; assert!(msg.encode_bytes(Version::Lite06).is_err()); assert!(TracksEvent::Synced.encode_bytes(Version::Lite06).is_err()); diff --git a/rs/moq-net/src/lite/version.rs b/rs/moq-net/src/lite/version.rs index 9aff12f52d4..0e247627d03 100644 --- a/rs/moq-net/src/lite/version.rs +++ b/rs/moq-net/src/lite/version.rs @@ -291,11 +291,10 @@ impl Version { ) } - /// Whether announcements and track requests carry the publisher epoch, the - /// identity that says two routes serve the same bytes. Added in lite-07. - /// Older versions carry nothing, so a received route has no epoch and is - /// never resumed through another route. - pub fn has_epoch(self) -> bool { + /// Whether announcements carry their identity and epoch, and track requests the + /// identity they pin. Added in lite-07. Older versions carry nothing, so the + /// receiving session mints an identity for each announcement. + pub fn has_identity(self) -> bool { !matches!( self, Self::Lite01 | Self::Lite02 | Self::Lite03 | Self::Lite04 | Self::Lite05 | Self::Lite06 diff --git a/rs/moq-net/src/model/broadcast.rs b/rs/moq-net/src/model/broadcast.rs index c5448c264f5..fda38e0496c 100644 --- a/rs/moq-net/src/model/broadcast.rs +++ b/rs/moq-net/src/model/broadcast.rs @@ -61,12 +61,12 @@ pub struct Info { /// root: any `..` reference escapes. pub path: crate::Path, - /// The epoch of the route a [`Consumer`] was resolved through, naming the publisher - /// instance it reads. + /// The identity of the route a [`Consumer`] was resolved through, naming the stream + /// it reads. /// /// Stamped on every [`Consumer`] an origin resolves, like [`Self::path`]. `None` for a - /// route without an epoch and for a handle not resolved through an origin. - pub epoch: Option, + /// handle not resolved through an origin. + pub identity: Option, // What the frames this broadcast's groups are still receiving may allocate up front, // inherited from the origin handle that created it. @@ -79,7 +79,7 @@ impl Default for Info { pool: cache::Pool::new(cache::Config::default().with_expiry(cache::DEFAULT_EXPIRY)), cache_duration: std::time::Duration::MAX, path: crate::Path::default(), - epoch: None, + identity: None, frames: Default::default(), } } @@ -280,10 +280,9 @@ impl Producer { } /// Advertise this broadcast's exact path as a route, or replace the standing - /// advertisement's route. The route is taken as given, epoch included: another - /// epoch, or none, announces a new broadcast. To re-price the same instance, - /// start from the current route: - /// `broadcast.announce(broadcast.route().unwrap_or_default().with_cost(cost))`. + /// advertisement's route. A route naming no identity keeps the broadcast's, so + /// `broadcast.announce(Route::default().with_cost(cost))` re-prices it; one naming + /// another identity announces a new stream. /// /// Until this is called the broadcast exists for nobody: announce cursors do /// not list it and requests for its path fail with [`Error::Unroutable`], for @@ -617,6 +616,8 @@ pub(crate) enum RequestValidity { Period( kio::Consumer<()>, kio::Consumer<()>, + // Keeps the selection registered, which is what keeps the two periods open. + #[expect(dead_code, reason = "held only for its registration")] std::sync::Arc, ), } @@ -646,29 +647,19 @@ impl RequestValidity { pub(crate) fn check(&self, waiter: &kio::Waiter) -> Result<(), crate::Error> { match self { Self::Route(route) if route.poll_closed(waiter).is_ready() => Err(crate::Error::Unroutable), - // A replacement retires the period only once its held `Restart` is released. - Self::Period(route, _, selected) - if route.poll_closed(waiter).is_ready() || selected.poll_retired(waiter).is_ready() => - { - Err(crate::Error::Unroutable) - } + Self::Period(route, _, _) if route.poll_closed(waiter).is_ready() => Err(crate::Error::Unroutable), Self::Selected(_) => self.start().check(waiter), _ => Ok(()), } } /// [`Self::check`] for a request already sent but not yet answered: only another - /// instance taking the path refuses it, since without an epoch that instance may be - /// the one answering. A withdrawal leaves it to its answer, as moq-lite has the - /// publisher conclude in-flight requests. + /// identity taking the path refuses it. A withdrawal leaves it to its answer, as + /// moq-lite has the publisher conclude in-flight requests. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(crate) fn check_unanswered(&self, waiter: &kio::Waiter) -> Result<(), crate::Error> { match self { - Self::Period(_, answering, selected) - if answering.poll_closed(waiter).is_ready() || selected.poll_retired(waiter).is_ready() => - { - Err(crate::Error::Unroutable) - } + Self::Period(_, answering, _) if answering.poll_closed(waiter).is_ready() => Err(crate::Error::Unroutable), Self::Selected(_) => self.start().check_unanswered(waiter), _ => Ok(()), } @@ -943,11 +934,12 @@ impl Consumer { self } - /// Stamp the epoch of the route this handle was resolved through, overriding [`Info::epoch`]. - pub(super) fn with_epoch(mut self, epoch: Option) -> Self { - if self.info.epoch != epoch { + /// Stamp the identity of the route this handle was resolved through, overriding + /// [`Info::identity`]. + pub(super) fn with_identity(mut self, identity: Option) -> Self { + if self.info.identity != identity { let mut info = (*self.info).clone(); - info.epoch = epoch; + info.identity = identity; self.info = Arc::new(info); } self diff --git a/rs/moq-net/src/model/follow.rs b/rs/moq-net/src/model/follow.rs index 687e5f7c6fa..89321b22a4a 100644 --- a/rs/moq-net/src/model/follow.rs +++ b/rs/moq-net/src/model/follow.rs @@ -2,7 +2,7 @@ use std::task::{Poll, ready}; use super::WeakEntry; use super::origin_impl::{Announce, AnnounceConsumer, AnnounceEvent, Consumer, Requesting}; -use crate::{Epoch, Error, Path, broadcast}; +use crate::{Error, Identity, Path, broadcast}; /// The announcements covering one path, as one broadcast coming online, being replaced, and /// going offline. Created by [`Consumer::routes`]. @@ -10,7 +10,7 @@ use crate::{Epoch, Error, Path, broadcast}; /// Several routes can cover the path at once, such as the path itself and a prefix above it, /// and a request resolves through the most specific one, so only that route's events come /// through. Another route taking over is a [`Restart`](AnnounceEvent::Restart), unless both -/// carry the same epoch: those serve the same bytes, so it is an +/// carry the same identity: those serve one continuous stream, so it is an /// [`Update`](AnnounceEvent::Update). One announced as a /// [`Takeover`](AnnounceEvent::Takeover) of the serving route continues it, and comes through /// as one. Routes beneath the path serve other broadcasts and are ignored. @@ -183,7 +183,7 @@ impl Follow { }) } (Some((before, _)), Some((after, _))) if before.prefix != after.prefix => { - let same = before.route.epoch.is_some() && before.route.epoch == after.route.epoch; + let same = before.route.identity == after.route.identity; Some(match same { true => AnnounceEvent::Update(after), false => AnnounceEvent::Restart(after), @@ -197,29 +197,29 @@ impl Follow { } } -/// Each publisher instance serving one path, handed out as it takes over. Created by +/// Each identity serving one path, handed out as it takes over. Created by /// [`Consumer::follow`](crate::origin::Consumer::follow). pub struct Following { consumer: Consumer, path: Path, - /// The only instance handed out, if pinned. - epoch: Option, + /// The only identity handed out, if pinned. + identity: Option, follow: Follow, - /// An instance serves the path that was not handed out yet. + /// An identity serves the path that was not handed out yet. unseen: bool, /// The resolution in flight, kept across a dropped wait so a retry picks it up. resolving: Option, - /// The broadcast handed out last, until the path goes offline. Weak, so it never keeps an - /// instance the caller let go of from retiring. + /// The broadcast handed out last, until the path goes offline or the broadcast ends. Weak, + /// so it never keeps one the caller let go of from retiring. last: Option, } impl Following { - pub(super) fn new(consumer: Consumer, path: Path, epoch: Option, follow: Follow) -> Self { + pub(super) fn new(consumer: Consumer, path: Path, identity: Option, follow: Follow) -> Self { Self { consumer, path, - epoch, + identity, follow, unseen: false, resolving: None, @@ -227,7 +227,7 @@ impl Following { } } - /// Wait for the next publisher instance serving the path; see + /// Wait for the next identity serving the path; see /// [`Consumer::follow`](crate::origin::Consumer::follow). /// /// Returns [`Error::Closed`] once the origin closes, and any other resolution failure @@ -237,7 +237,7 @@ impl Following { kio::wait(|waiter| self.poll_available(waiter)).await } - /// Poll for the next publisher instance serving the path, registering `waiter` when there + /// Poll for the next identity serving the path, registering `waiter` when there /// is none yet. pub fn poll_available(&mut self, waiter: &kio::Waiter) -> Poll> { loop { @@ -248,13 +248,25 @@ impl Following { if self.settle(waiter)? { self.resolving = None; } + // The broadcast handed out ended while the path is still served: its identity's + // routes went and another identity's, beneath the same announcement, took the + // path over. Whatever serves it now is next. + if !self.unseen + && self.follow.serving().is_some() + && self + .last + .as_ref() + .is_some_and(|last| last.consume().poll_closed(waiter).is_ready()) + { + self.unseen = true; + } if !self.unseen { return Poll::Pending; } let resolving = self .resolving - .get_or_insert_with(|| self.consumer.routing(self.path.clone(), self.epoch.clone())); + .get_or_insert_with(|| self.consumer.routing(self.path.clone(), self.identity.clone())); let resolved = ready!(kio::Task::poll(resolving, waiter)); self.resolving = None; let broadcast = resolved?; @@ -283,7 +295,16 @@ impl Following { /// Apply one change to the path, returning whether it is one. fn apply(&mut self, event: AnnounceEvent) -> bool { match event { - // A pinned resolution refuses any other instance, waiting until its own serves again. + // Another identity taking over leaves a pinned follower's own stream as it was. + AnnounceEvent::Restart(announce) + if self + .identity + .as_ref() + .is_some_and(|pin| announce.route.identity.as_ref() != Some(pin)) => + { + return false; + } + // A pinned resolution refuses any other identity, waiting until its own serves again. AnnounceEvent::Start(_) | AnnounceEvent::Restart(_) => self.unseen = true, // An end is a gap that ended every request on the old route, so whatever serves the // path next is handed out, even the same instance coming back. @@ -306,7 +327,7 @@ mod tests { use super::super::ProduceTest; use super::*; use crate::{ - Epoch, Error, Hop, Pattern, Patterns, + Error, Hop, Identity, Pattern, Patterns, origin::{Cost, Route}, }; @@ -332,15 +353,15 @@ mod tests { } } - fn route(epoch: &Epoch) -> Route { - Route::default().with_epoch(epoch.clone()) + fn route(epoch: &Identity) -> Route { + Route::default().with_identity(epoch.clone(), 0) } #[moq_clock::test] async fn follow_reports_the_route_serving_the_path() { let origin = Hop::new(1).unwrap().produce(); let mut follow = origin.consume().routes("pool/job").unwrap(); - let epoch = Epoch::mint(); + let epoch = Identity::random(); // A prefix above the path covers it. let pool = origin.requests_route("pool", route(&epoch), None).unwrap(); @@ -359,7 +380,7 @@ mod tests { quiet(&mut follow).await; // Another instance at the path. - exact.announce(route(&Epoch::mint())).unwrap(); + exact.announce(route(&Identity::random())).unwrap(); assert_eq!(followed(&mut follow).await, ("restart", "pool/job".into())); // It goes, so the prefix serves the path again: another instance. @@ -387,11 +408,11 @@ mod tests { } /// A gap with nothing serving the path ends any request on it, so a covering route that - /// arrives after it is a start of its own, even under the same epoch. + /// arrives after it is a start of its own, even under the same identity. #[moq_clock::test] async fn follow_keeps_a_gap_between_routes_of_one_epoch() { let origin = Hop::new(1).unwrap().produce(); - let epoch = Epoch::mint(); + let epoch = Identity::random(); let exact = origin.publish("pool/job", route(&epoch)).unwrap(); let mut follow = origin.consume().routes("pool/job").unwrap(); assert_eq!(followed(&mut follow).await, ("start", "pool/job".into())); @@ -415,11 +436,11 @@ mod tests { #[moq_clock::test] async fn follow_hands_over_to_a_prefix_that_started_before_the_last_change() { let origin = Hop::new(1).unwrap().produce(); - let exact = origin.publish("pool/job", route(&Epoch::mint())).unwrap(); + let exact = origin.publish("pool/job", route(&Identity::random())).unwrap(); let mut follow = origin.consume().routes("pool/job").unwrap(); assert_eq!(followed(&mut follow).await, ("start", "pool/job".into())); - let epoch = Epoch::mint(); + let epoch = Identity::random(); let _pool = origin.requests_route("pool", route(&epoch), None).unwrap(); exact.announce(route(&epoch)).unwrap(); assert_eq!(followed(&mut follow).await, ("restart", "pool/job".into())); @@ -428,7 +449,7 @@ mod tests { assert_eq!(followed(&mut follow).await, ("update", "pool".into())); } - /// Without an epoch nothing says two routes serve the same bytes. + /// Under another identity nothing says two routes serve the same bytes. #[moq_clock::test] async fn follow_restarts_onto_a_more_specific_route_without_an_epoch() { let origin = Hop::new(1).unwrap().produce(); @@ -491,7 +512,7 @@ mod tests { if let Ok(result) = moq_clock::timeout(hold, announced.available()).await { panic!( "expected nothing, got {:?}", - result.map(|broadcast| broadcast.info().epoch.clone()) + result.map(|broadcast| broadcast.info().identity.clone()) ); } } @@ -512,7 +533,7 @@ mod tests { let dynamic = origin.requests("live").unwrap(); let mut announced = origin.consume().follow("live", None).unwrap(); - // The wait is on the dynamic route's answer when the exact path takes over without an epoch. + // The wait is on the dynamic route's answer when the exact path takes over under another identity. let (_takeover, _publish) = futures::join!(available(&mut announced), async { let _unanswered = requested(&dynamic).await; origin.publish("live", Route::default()).unwrap() @@ -520,25 +541,6 @@ mod tests { none(&mut announced).await; } - /// The follower holds what it handed out weakly: a takeover the caller let go of retires, - /// and the restart the origin held for it hands out a fresh front. - #[moq_clock::test] - async fn announced_hands_out_a_dropped_takeover_again() { - let origin = Hop::new(1).unwrap().produce(); - let dynamic = origin.requests("live").unwrap(); - let mut announced = origin.consume().follow("live", None).unwrap(); - - let (dropped, _publish) = futures::join!(available(&mut announced), async { - let _unanswered = requested(&dynamic).await; - origin.publish("live", Route::default()).unwrap() - }); - let front = dropped.weak(); - drop(dropped); - let again = available(&mut announced).await; - assert!(front.is_closed(), "the follower kept the dropped front alive"); - assert!(!again.weak().same_channel(&front)); - } - /// The route a wait resolves through leaving, and another instance arriving before it /// resolves, reach the follower as an end and a start alongside the answer. That start must /// not hand the newcomer out again. @@ -558,12 +560,12 @@ mod tests { } /// An end is a gap that ended every request on the old route, even when a route above the - /// path with the same epoch carries the instance on. So the instance serving the path + /// path with the same identity carries the instance on. So the instance serving the path /// afterwards is handed out again, though it is the one the caller still holds. #[moq_clock::test] async fn announced_hands_out_the_instance_again_after_an_end() { let origin = Hop::new(1).unwrap().produce(); - let epoch = Epoch::mint(); + let epoch = Identity::random(); let mut announced = origin.consume().follow("live", None).unwrap(); let exact = origin.publish("live", route(&epoch)).unwrap(); let first = available(&mut announced).await; @@ -577,37 +579,37 @@ mod tests { assert!(again.is_clone(&first), "the route above did not carry the instance on"); } - /// An announced request that pins an epoch refuses another instance taking over instead of + /// An announced request that pins an identity refuses another instance taking over instead of /// following it. #[moq_clock::test] async fn announced_pinned_refuses_another_instance() { let origin = Hop::new(1).unwrap().produce(); - let epoch = Epoch::mint(); + let epoch = Identity::random(); let _first = origin.publish("live", route(&epoch)).unwrap(); let mut announced = origin.consume().follow("live", epoch.clone()).unwrap(); let pinned = available(&mut announced).await; - assert_eq!(pinned.info().epoch.as_ref(), Some(&epoch)); + assert_eq!(pinned.info().identity.as_ref(), Some(&epoch)); - let _second = origin.publish("live", route(&Epoch::mint())).unwrap(); + let _second = origin.publish("live", route(&Identity::random())).unwrap(); none(&mut announced).await; // Unpinned, the same takeover is followed. let mut unpinned = origin.consume().follow("live", None).unwrap(); let current = available(&mut unpinned).await; - assert_ne!(current.info().epoch.as_ref(), Some(&epoch)); + assert_ne!(current.info().identity.as_ref(), Some(&epoch)); } #[moq_clock::test] async fn announced_pinned_hands_out_its_instance_after_a_gap() { let origin = Hop::new(1).unwrap().produce(); - let epoch = Epoch::mint(); + let epoch = Identity::random(); let first = origin.publish("live", route(&epoch)).unwrap(); let mut announced = origin.consume().follow("live", epoch.clone()).unwrap(); - assert_eq!(available(&mut announced).await.info().epoch.as_ref(), Some(&epoch)); + assert_eq!(available(&mut announced).await.info().identity.as_ref(), Some(&epoch)); first.unannounce(); none(&mut announced).await; first.announce(route(&epoch)).unwrap(); - assert_eq!(available(&mut announced).await.info().epoch.as_ref(), Some(&epoch)); + assert_eq!(available(&mut announced).await.info().identity.as_ref(), Some(&epoch)); } #[moq_clock::test] @@ -618,7 +620,7 @@ mod tests { let (result, ()) = futures::join!( moq_clock::timeout(Duration::from_secs(1), announced.available()), async { - let prepared = requested(&claim).await.prepare(Epoch::mint()).unwrap(); + let prepared = requested(&claim).await.prepare().unwrap(); drop(prepared); } ); diff --git a/rs/moq-net/src/model/front.rs b/rs/moq-net/src/model/front.rs index f87ebf6f844..aaa709bd88e 100644 --- a/rs/moq-net/src/model/front.rs +++ b/rs/moq-net/src/model/front.rs @@ -9,14 +9,14 @@ //! sources, the tracks, the clock) and executes the actions; see //! `origin::run_front`. //! -//! The driver only offers a front routes that serve its broadcast: those with the -//! epoch it first resolved, whose tracks resume where they stopped, or its first -//! route alone when that had no epoch, since nothing says another serves the -//! same bytes. Even that route can answer a re-request with another instance, so -//! without an epoch a track whose copy fails ends with the error instead of -//! re-splicing. Another instance winning the path leaves the front alone: its -//! readers stay until they leave or its routes go, and new requests get a fresh -//! front for the winner. +//! The driver only offers a front routes that serve its broadcast: those of the +//! identity it first resolved, at its newest epoch, whose tracks resume where they +//! stopped since the identity promises one continuous sequence space. An identity a +//! session minted, for a wire that carries none, promises nothing past its route: +//! that route can answer a re-request with another stream, so a track whose copy +//! fails there ends with the error instead of re-splicing. Another +//! identity winning the path leaves the front alone: its readers stay until they +//! leave or its routes go, and new requests get a fresh front for the winner. //! //! A front nothing needs retires: once every track is forgotten (after the linger, //! so a returning reader still finds the cache) and no consumer holds its broadcast, @@ -35,15 +35,16 @@ use std::{ use crate::{Error, time::Instant, track}; /// A route the table selected for the front: the entry id, whether it is a -/// broadcast published on this origin, and whether it carries an epoch. +/// broadcast published on this origin, and whether its identity was carried rather +/// than minted by a session. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) struct Candidate { pub route: u64, pub local: bool, /// Whether a track whose copy failed may be asked for again through the route: - /// the epoch says the answer is the same broadcast. Without one, the route could - /// answer with whatever instance now wins upstream. - pub epoch: bool, + /// a carried identity says the answer is the same stream. A minted one names the + /// announcement, and the route could answer with whatever stream now wins upstream. + pub carried: bool, } /// Why an upstream request through a route did not produce a source. @@ -494,8 +495,8 @@ impl Front { return; } let verdict = match result { - // A same-epoch copy claims identical content, so any difference in - // its metadata is refused rather than reinterpreted. + // A copy of the same identity claims identical content, so any difference + // in its metadata is refused rather than reinterpreted. Ok(info) => match self.info.get(&name) { Some(expected) if *expected != info => Err(Error::Unsupported), Some(_) => Ok(()), @@ -558,17 +559,17 @@ impl Front { track.ended = true; actions.push(Action::Finish { track: name }); } - // Died mid-serve after delivering from the same instance: normal + // Died mid-serve after delivering from the same identity: normal // failover, re-splice from the serving source at the first missing group. Err(err) if delivered && super::resume::route_failed(&err) - && self.serving.is_some_and(|(_, candidate)| candidate.epoch) => + && self.serving.is_some_and(|(_, candidate)| candidate.carried) => { track.state = TrackState::Idle; self.redispatch(name, actions); } - // A content verdict survives every copy. Without an epoch, even a route + // A content verdict survives every copy. Under a minted identity, even a route // failure cannot be retried against the same content. Err(err) if delivered => { track.state = TrackState::Idle; @@ -717,7 +718,7 @@ mod tests { Candidate { route, local: false, - epoch: true, + carried: true, } } @@ -725,7 +726,7 @@ mod tests { Candidate { route, local: true, - epoch: true, + carried: true, } } @@ -1090,7 +1091,7 @@ mod tests { } #[test] - fn an_epoch_copy_verdict_aborts_without_querying_again() { + fn a_copy_verdict_aborts_without_querying_again() { for candidate in [remote(1), local(1)] { for err in [ Error::from(crate::StreamError::FrameTooLarge), @@ -1118,16 +1119,16 @@ mod tests { } } - /// Without an epoch a re-request could reach another instance, so the track ends + /// Under a minted identity a re-request could reach another stream, so the track ends /// with the error instead, whether the route is a peer's or a local broadcast. #[test] - fn a_copy_dying_without_an_epoch_ends_the_track() { + fn a_copy_dying_under_a_minted_identity_ends_the_track() { for (local, closing) in [(false, false), (false, true), (true, false)] { let mut front = serving( Candidate { route: 1, local, - epoch: false, + carried: false, }, 100, ); @@ -1184,7 +1185,7 @@ mod tests { #[test] fn incompatible_copy_is_refused() { - // A same-epoch copy claims identical content, so every field counts, `refresh` too. + // A copy of the same identity claims identical content, so every field counts, `refresh` too. let differing = [ track::Info { max_age: Some(Duration::from_secs(1)), diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index ce60949c0e9..864f62b51a2 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -18,7 +18,7 @@ use super::{ front::{Action, Candidate, Event, Front, Refusal}, }; use crate::{ - Epoch, Error, InvalidPattern, Path, Pattern, Patterns, + Error, Identity, InvalidPattern, Path, Pattern, Patterns, coding::{BoundsExceeded, Decode, DecodeError, Decoder, Encode, EncodeError, Encoder, Form, varint}, path::{self, Segment}, time::{Deadlines, Instant}, @@ -133,26 +133,19 @@ pub struct Config { /// How long an announce cursor holds a changed route before delivering it. /// - /// A new route, a removed one, and a newer epoch are delivered at once; only an - /// update, or a restart onto a route without an epoch (a prefix whose best route - /// changes while it stays reachable), waits, and a newer change during the wait - /// replaces it. When a publisher withdraws, a - /// relay that loses its best route but still holds routes derived from the - /// withdrawn one sends nothing while the withdrawal wave removes them, then one + /// A new route, a removed one, and another identity are delivered at once; only an + /// update waits, and a newer change during the wait replaces it. When a publisher + /// withdraws, a relay that loses its best route but still holds routes derived from + /// the withdrawn one sends nothing while the withdrawal wave removes them, then one /// retraction, instead of advertising each stale path in turn. It must outlast /// the wave's spread across the mesh. Defaults to 300 ms, longer than a withdrawal /// takes to cross a global mesh, with margin; zero delivers updates at once. /// - /// A broadcast resolved through a peer's route keeps serving new requests while - /// the peer's replacement of it is held, and refuses them with - /// [`Error::Unroutable`] once the restart is delivered, so a holder following - /// restarts never sees that refusal first. - /// /// A relay further out can still pass a stale path to a cursor that already /// retracted the prefix, which would announce it anew. So for two seconds after - /// a retraction, an announcement from the same publisher (first hop and epoch) - /// on a strictly worse route waits. A republish ranks the same and a new - /// instance carries a new epoch, so neither waits. Zero disables this too. + /// a retraction, an announcement from the same publisher (first hop, identity and + /// epoch) on a strictly worse route waits. A republish ranks the same and a restart + /// carries a new identity or epoch, so neither waits. Zero disables this too. pub update_hold: Duration, } @@ -468,7 +461,7 @@ impl From for Preference { } } -/// The path a route took through the mesh and what using it costs. +/// The stream a route serves, the path it took through the mesh, and what using it costs. /// /// The metadata half of an advertisement: [`Producer::requests`] pairs it with /// the prefix it covers, [`Publication::announce`] with the @@ -481,13 +474,19 @@ impl From for Preference { #[derive(Clone, Debug, PartialEq, Eq)] #[non_exhaustive] pub struct Route { - /// The publisher instance the route serves, if known: routes with the same - /// epoch serve the same bytes, so a subscription resumes from one to another. - /// Among routes at one prefix the newest epoch wins, and a route without one - /// ranks last and never resumes: a track that fails through it ends with the - /// error, since a re-request could reach another instance. Changing it on a - /// standing advertisement (including dropping it) is an [`AnnounceEvent::Restart`]. - pub epoch: Option, + /// The stream the route serves: routes with one identity serve one continuous + /// sequence space, so a subscription resumes from one to another. Changing it on a + /// standing advertisement is an [`AnnounceEvent::Restart`]. + /// + /// Always set on a route the origin delivers. Given to an announcing handle, `None` + /// keeps the identity its route already has: a fresh random one, minted when the + /// handle was made, unless [`Self::with_identity`] set another. + pub identity: Option, + + /// Orders the incarnations of one identity: among its routes at one prefix only the + /// highest epoch is eligible. Never compared across identities, and never shown to + /// readers. + pub(crate) epoch: u64, /// The chain of origins the route has traversed, oldest first. Each relay /// appends its own [`crate::Hop`] when forwarding; used for loop detection @@ -497,9 +496,9 @@ pub struct Route { /// What pulling content via this route costs, accumulated per link: lower wins /// among routes of the same anonymity, with ties broken by a broadcast published - /// on this origin, then hop length, then a deterministic hash (except for a route - /// that entered here, at its own path), and finally the most recently announced - /// route. See [`Cost`]. + /// on this origin, then hop length, then a hash of the identity and the requested + /// path (except for a route that entered here, at its own path), and finally the + /// most recently announced route. See [`Cost`]. pub cost: Cost, /// The credential the request advertising this route carries, from @@ -528,7 +527,8 @@ pub struct Route { impl Default for Route { fn default() -> Self { Self { - epoch: None, + identity: None, + epoch: 0, hops: Hops::new(), cost: Cost::default(), auth: None, @@ -554,9 +554,16 @@ pub enum Source { } impl Route { - /// Set the publisher instance the route serves. - pub fn with_epoch(mut self, epoch: crate::Epoch) -> Self { - self.epoch = Some(epoch); + /// Serve a stable `identity` at `epoch`, in place of the one the handle minted. + /// + /// Only for a publisher that continues the identity's sequence space across restarts, + /// announcing a higher `epoch` each time, such as a counter persisted on disk. Readers + /// stitch every route of one identity together, so a publisher that restarts its + /// group sequences under an old identity splices unrelated groups into their tracks. + /// The highest epoch wins over every older one of the identity, cheaper or not. + pub fn with_identity(mut self, identity: Identity, epoch: u64) -> Self { + self.identity = Some(identity); + self.epoch = epoch; self } @@ -619,91 +626,88 @@ impl ConsumerId { } } -/// FNV-1a over a path and a sequence of origin ids. +/// FNV-1a over a requested path and then `bytes`. /// /// FNV-1a, not the std hasher: its output is fixed across Rust versions and /// builds, which matters when nodes run mismatched binaries during a rolling /// deploy and still need to agree on the same route. SEED is a custom basis /// (any nonzero u64 works, the textbook one is just as arbitrary); FNV_PRIME is /// the standard FNV-64 prime and should stay put. Mixing the path in spreads -/// equal routes across different upstreams rather than funneling onto one. -fn fnv_key(name: &str, origins: impl IntoIterator) -> u64 { +/// equal routes across different paths rather than funneling onto one. +fn fnv_key(path: &str, bytes: impl IntoIterator) -> u64 { const SEED: u64 = 0x420C0DECB00B; // 420 C0DEC B00B const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; let mut hash = SEED; - for &byte in name.as_bytes() { + for byte in path.as_bytes().iter().copied().chain(bytes) { hash = (hash ^ u64::from(byte)).wrapping_mul(FNV_PRIME); } - for origin in origins { - for &byte in &origin.id().to_le_bytes() { - hash = (hash ^ u64::from(byte)).wrapping_mul(FNV_PRIME); - } - } - hash } -/// Ordering key for a route entry resolving `path`. Lower wins: the newest epoch -/// first, since an older one is a publisher that was replaced, and a route with no -/// epoch last. Then the higher link [`Preference`], so metrics from different -/// operators are never compared. Then an identified chain (no 0) outranks an anonymous one regardless of cost, then the cheapest -/// cost, then a broadcast published on this origin (it serves what is here, not a -/// claim that has to ask), then the shortest hop chain, then a deterministic hash -/// of `path` and the chain so every node converges on the same winner, and finally -/// the newest announcement, so a reconnect wins the moment it lands instead of after -/// the transport retires the old session. +/// The hash that spreads a pool's identities over `path`s: the identity, unlike the hop +/// chain, reads the same on every node, so every relay picks the same one. +fn identity_key(path: &str, identity: &Identity) -> u64 { + fnv_key(path, identity.as_str().bytes()) +} + +/// The hash that settles among the routes of one identity: their hop chains, so a node +/// converges on one path rather than following each newer announcement of an equal one. +fn chain_key(path: &str, hops: &Hops) -> u64 { + fnv_key(path, hops.iter().flat_map(|hop| hop.id().to_le_bytes())) +} + +/// Whether a candidate among `candidates` carries `entry`'s identity at a higher epoch +/// on the same prefix: an older incarnation of an identity is never eligible, however +/// cheap, since its publisher was replaced. +fn superseded<'a>(entry: &RouteEntry, mut candidates: impl Iterator) -> bool { + candidates + .any(|other| other.epoch > entry.epoch && other.identity == entry.identity && other.prefix == entry.prefix) +} + +/// Ordering key for an eligible route entry resolving `path` (see [`superseded`]). +/// Lower wins: the higher link [`Preference`], so metrics from different operators are +/// never compared. Then an identified chain (no 0) outranks an anonymous one regardless +/// of cost, then the cheapest cost, then a broadcast published on this origin (it serves +/// what is here, not a claim that has to ask), then the shortest hop chain, then a hash +/// of the identity and `path`, so every node converges on the same winner, then, among +/// the routes of that identity, a hash of the chain, and finally the newest announcement, +/// so a reconnect wins the moment it lands instead of after the transport retires the +/// old session. /// /// `path` is what is being resolved: the requested path for a request, the prefix /// itself for an advertisement. Keying the hash on the requested path is what -/// spreads equal-cost advertisers of one prefix: each path picks its own winner -/// from the pool, rather than every path under the prefix hashing alike. +/// spreads a pool of identities at one prefix: each path picks its own winner, rather +/// than every path under the prefix hashing alike. /// /// A route that entered here for exactly `path` is not hashed: no other node ranks /// it, so convergence does not need the hash, and a restarted publisher announces -/// under a new first hop, so a hash would keep its dead session winning half the -/// time until the transport times out. -#[expect( - clippy::type_complexity, - reason = "the tuple is the sort key; naming it would only move the type" -)] -fn route_order<'a>( - path: &Path, - entry: &'a RouteEntry, -) -> ( - Reverse>, - Reverse, - bool, - Cost, - bool, - usize, - u64, - Reverse, -) { +/// under a new identity, so a hash would keep its dead session winning half the time +/// until the transport times out. +fn route_order(path: &Path, entry: &RouteEntry) -> RouteOrder { ( - Reverse(entry.epoch.as_ref()), Reverse(entry.preference), entry.is_anonymous(), entry.cost, !entry.local, entry.hops.len(), match entry.link == Link::Local && entry.prefix == *path { - true => 0, - false => fnv_key(path.as_str(), entry.hops.iter().copied()), + true => (0, 0), + false => ( + identity_key(path.as_str(), &entry.identity), + chain_key(path.as_str(), &entry.hops), + ), }, Reverse(entry.id), ) } -/// The `(hops, cost, source, epoch, auth)` metadata an announce cursor delivers alongside a -/// prefix. -type RouteMeta = ( - Hops, - Cost, - Source, - Option, - Option, -); +/// The key [`route_order`] ranks an entry by: lower wins. +type RouteOrder = (Reverse, bool, Cost, bool, usize, (u64, u64), Reverse); + +/// The `(hops, cost, source, identity, epoch, auth)` metadata an announce cursor delivers +/// alongside a prefix. +type RouteMeta = (Hops, Cost, Source, Identity, u64, Option); /// The earlier of two optional deadlines. fn earliest(a: Option, b: Option) -> Option { @@ -715,22 +719,23 @@ fn earliest(a: Option, b: Option) -> Option { /// A retraction's quarantine, keyed by prefix in its [`AnnounceConsumer`]: the /// retracted route's publisher (none for an anonymous route, which holds -/// nothing), epoch, and rank, and when the quarantine ends. +/// nothing), identity, epoch, and rank, and when the quarantine ends. #[derive(Clone)] struct Quarantine { held: Held, publisher: Option, - epoch: Option, + instance: (Identity, u64), rank: (Cost, usize), } impl Quarantine { /// Whether `meta` could be a stale path to the retracted publisher: the same - /// first hop and epoch, ranked strictly worse. A republish ranks the same and a - /// new instance carries a new epoch, so neither waits. + /// first hop, identity and epoch, ranked strictly worse. A republish ranks the same + /// and a restart carries a new identity or epoch, so neither waits. fn holds(&self, meta: &RouteMeta) -> bool { self.publisher.is_some_and(|hop| meta.0.iter().next() == Some(&hop)) - && meta.3 == self.epoch + && meta.3 == self.instance.0 + && meta.4 == self.instance.1 && (meta.1, meta.0.len()) > self.rank } } @@ -747,97 +752,6 @@ enum Held { Until(Instant), } -/// When one table change's held restart ends: the announce cursors deliver its -/// `Restart`, and the request handles it replaced retire. Shared by both, so a -/// handle never retires before its holder could see the `Restart` to follow. -/// -/// Anchored like [`Held`], on the first clock reading after the change. -#[derive(Clone)] -struct Release(Arc>); - -struct ReleaseState { - held: Held, - hold: Duration, - clock: Deadlines, - #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] - deadline: crate::time::Deadline, -} - -impl Release { - /// A release `hold` after a change made now, or `None` when nothing is held: the - /// hold is zero. - fn new(clock: &Deadlines, hold: Duration) -> Option { - if hold.is_zero() { - return None; - } - let now = clock.now(); - Some(Self(Arc::new(std::sync::Mutex::new(ReleaseState { - held: Held::Anchoring(now), - hold, - clock: clock.clone(), - deadline: crate::time::Deadline::new(clock), - })))) - } - - /// When this releases, as far as `now` can tell: past a stale anchor, it is - /// still waiting for a fresh reading to count from. - fn until(&self, now: Instant) -> Instant { - self.0.lock().unwrap().until(now) - } - - /// Whether the clock has reached the release. - fn released(&self) -> bool { - let mut state = self.0.lock().unwrap(); - let now = state.clock.now(); - state.until(now) <= now - } - - /// `Ready` once released, registering `waiter` until then. - #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] - fn poll(&self, waiter: &kio::Waiter) -> Poll<()> { - let mut state = self.0.lock().unwrap(); - let now = state.clock.now(); - let until = state.until(now); - state.deadline.set(Some(until)); - state.deadline.poll(waiter) - } -} - -impl ReleaseState { - fn until(&mut self, now: Instant) -> Instant { - match self.held { - Held::Anchoring(seen) if now <= seen => seen + Duration::from_nanos(1), - Held::Anchoring(_) => { - let until = now + self.hold; - self.held = Held::Until(until); - until - } - Held::Until(until) => until, - } - } -} - -/// The [`Release`] one table change's held restarts share, made on first use. -struct Hold<'a> { - clock: &'a Deadlines, - hold: Duration, - release: std::cell::OnceCell>, -} - -impl<'a> Hold<'a> { - fn new(clock: &'a Deadlines, hold: Duration) -> Self { - Self { - clock, - hold, - release: std::cell::OnceCell::new(), - } - } - - fn release(&self) -> Option { - self.release.get_or_init(|| Release::new(self.clock, self.hold)).clone() - } -} - /// One coalesced update queued for an `AnnounceConsumer`. /// /// At most one entry exists per prefix, so a slow consumer's pending set is @@ -849,8 +763,7 @@ type AnnounceMeta = (RouteMeta, Option>); enum PendingUpdate { Announce(AnnounceMeta), - /// Held until its [`Release`], if any. - Restart(AnnounceMeta, Option), + Restart(AnnounceMeta), /// An announce continuing the delivered `predecessor`, delivered as one only while /// the consumer still holds the advertisement [`OriginConsumerState::seen`] stamped /// `stamp`; otherwise as a plain start or restart. @@ -860,10 +773,7 @@ enum PendingUpdate { stamp: u64, }, Unannounce(AnnounceMeta), - UnannounceAnnounce { - old: AnnounceMeta, - new: AnnounceMeta, - }, + UnannounceAnnounce { old: AnnounceMeta, new: AnnounceMeta }, } // An update is taken only while collapsing it under the cursor state lock. @@ -1000,7 +910,7 @@ impl OriginConsumerState { // First announce, a stale announce being replaced, or a metadata update. None | Some(PendingUpdate::Announce(_)) => PendingUpdate::Announce(meta), // The consumer still owes the restart; this only moves its metadata. - Some(PendingUpdate::Restart(_, release)) => PendingUpdate::Restart(meta, release), + Some(PendingUpdate::Restart(_)) => PendingUpdate::Restart(meta), // Likewise the takeover. Some(PendingUpdate::Takeover { predecessor, stamp, .. }) => PendingUpdate::Takeover { meta, @@ -1015,25 +925,16 @@ impl OriginConsumerState { self.put(prefix, new); } - /// Another instance replaces the route at `prefix`, or announces it afresh when - /// the consumer never saw the old one. A restart owed already keeps its release, - /// unless this one goes at once. - fn apply_restart( - &mut self, - prefix: Path, - meta: RouteMeta, - captures: Option>, - release: Option, - ) { + /// Another identity replaces the route at `prefix`, or announces it afresh when + /// the consumer never saw the old one. + fn apply_restart(&mut self, prefix: Path, meta: RouteMeta, captures: Option>) { let meta = (meta, captures); let new = match self.pending.get_mut(&prefix).and_then(|pending| pending.update.take()) { Some(PendingUpdate::Unannounce(old) | PendingUpdate::UnannounceAnnounce { old, .. }) => { PendingUpdate::UnannounceAnnounce { old, new: meta } } - Some(PendingUpdate::Restart(_, owed)) => { - PendingUpdate::Restart(meta, release.map(|new| owed.unwrap_or(new))) - } - _ if self.delivered.contains(&prefix) => PendingUpdate::Restart(meta, release), + Some(PendingUpdate::Restart(_)) => PendingUpdate::Restart(meta), + _ if self.delivered.contains(&prefix) => PendingUpdate::Restart(meta), // The consumer never saw the old instance, so this is its first announce. _ => PendingUpdate::Announce(meta), }; @@ -1053,7 +954,7 @@ impl OriginConsumerState { let stamp = self.seen.get(&predecessor).copied().filter(|_| settled); let Some(stamp) = stamp else { return match prefix == predecessor { - true => self.apply_restart(prefix, meta, captures, None), + true => self.apply_restart(prefix, meta, captures), false => self.apply_announce(prefix, meta, captures), }; }; @@ -1067,7 +968,7 @@ impl OriginConsumerState { Some(PendingUpdate::Unannounce(old) | PendingUpdate::UnannounceAnnounce { old, .. }) => { PendingUpdate::UnannounceAnnounce { old, new: meta } } - Some(PendingUpdate::Restart(_, owed)) => PendingUpdate::Restart(meta, owed), + Some(PendingUpdate::Restart(_)) => PendingUpdate::Restart(meta), Some(PendingUpdate::Announce(_)) => PendingUpdate::Announce(meta), }; self.put(prefix, new); @@ -1129,11 +1030,8 @@ impl OriginConsumerState { /// /// A metadata change to a route the consumer holds waits out the update hold /// (see [`Config::update_hold`]), which starts the first time a poll sees it. A - /// restart to a route without an epoch waits too, since a withdrawal wave's stale - /// path looks just like one, but on its [`Release`]: the handles it replaces - /// retire on that one. A newer epoch is a publisher's own restart and goes at - /// once. A stale path to a retracted publisher waits out its quarantine in - /// `retracted`, settled against `now`. + /// restart onto another identity goes at once. A stale path to a retracted + /// publisher waits out its quarantine in `retracted`, settled against `now`. fn scan( &mut self, retracted: &HashMap, @@ -1158,7 +1056,6 @@ impl OriginConsumerState { } let pending = self.pending.get_mut(prefix).unwrap(); let wake = match pending.update.as_ref().unwrap() { - PendingUpdate::Restart(_, Some(release)) => Some(release.until(now)).filter(|until| *until > now), PendingUpdate::Announce(_) if self.delivered.contains(prefix) => { // The clock reads the driver's last advance, which may be stale: anchor // the hold on the first advance after the update arrived, by waking the @@ -1235,7 +1132,7 @@ impl OriginConsumerState { }; (meta, kind) } - PendingUpdate::Restart(meta, _) => { + PendingUpdate::Restart(meta) => { self.delivered.insert(prefix.clone()); (meta, Kind::Restart) } @@ -1283,8 +1180,9 @@ impl OriginConsumerState { cost: meta.1, via: Hop::UNKNOWN, source: meta.2, - epoch: meta.3, - auth: meta.4, + identity: Some(meta.3), + epoch: meta.4, + auth: meta.5, }, }; let event = match kind { @@ -1298,43 +1196,13 @@ impl OriginConsumerState { } } -/// The publisher instance a route serves, as far as this origin can tell. -/// -/// An epoch names one, shared by every route that serves it. A route without one -/// vouches only for itself, so its entry stands in: a generation, renewed when a -/// path beneath it moves to another route (see [`OriginState::renew`]). -#[derive(Clone, Debug, PartialEq, Eq)] -enum Instance { - Epoch(crate::Epoch), - Route(u64), -} - -impl Instance { - /// Whether `entry` serves this instance, without building its [`RouteEntry::instance`]. - fn is(&self, entry: &RouteEntry) -> bool { - match (self, &entry.epoch) { - (Self::Epoch(epoch), Some(other)) => epoch == other, - (Self::Route(generation), None) => *generation == entry.generation, - _ => false, - } - } - - /// The epoch, when the instance has one. - fn epoch(&self) -> Option { - match self { - Self::Epoch(epoch) => Some(epoch.clone()), - Self::Route(_) => None, - } - } -} - /// One announced route in the origin's table, absolute prefix. struct RouteEntry { id: u64, - /// The publisher instance this route serves; see [`Route::epoch`]. - epoch: Option, - /// Names the content behind the route when it has no epoch; see [`Instance`]. - generation: u64, + /// The stream this route serves; see [`Route::identity`]. + identity: Identity, + /// Which incarnation of the identity; see [`Route::epoch`]. + epoch: u64, prefix: Path, /// The absolute patterns the announcing producer may serve. The prefix is /// only the wire-visible covering claim; this scope remains authoritative. @@ -1385,7 +1253,7 @@ struct RouteEntry { } /// A route's predecessor as the route took it over: what was resolved through that -/// incarnation beneath the route's prefix, before then, is the route's instance now. +/// route beneath its prefix, before then, is the route's identity now. /// /// A one-time continuity reference, never a lifetime dependency: either route ends /// on its own, and only what had resolved by [`Self::at`] is continued. @@ -1393,8 +1261,8 @@ struct RouteEntry { struct Takeover { /// The predecessor's entry id. route: u64, - /// The instance the predecessor served then. - instance: Instance, + /// The identity the predecessor served then. + identity: Identity, /// The predecessor's absolute prefix. prefix: Path, /// [`OriginState::next_route`] once the route took over. @@ -1402,14 +1270,6 @@ struct Takeover { } impl RouteEntry { - /// The publisher instance the route serves. - fn instance(&self) -> Instance { - match &self.epoch { - Some(epoch) => Instance::Epoch(epoch.clone()), - None => Instance::Route(self.generation), - } - } - /// Whether cursors see the entry and requests resolve through it. fn live(&self) -> bool { self.advertised && !self.stale @@ -1433,17 +1293,19 @@ impl RouteEntry { self.hops.clone(), self.cost, self.entered(), - self.epoch.clone(), + self.identity.clone(), + self.epoch, self.auth.clone(), ) } /// Whether [`Self::meta`] would equal `meta`, without building it. fn delivers(&self, meta: &RouteMeta) -> bool { - let (hops, cost, source, epoch, auth) = meta; + let (hops, cost, source, identity, epoch, auth) = meta; *hops == self.hops && *cost == self.cost && *source == self.entered() + && *identity == self.identity && *epoch == self.epoch && *auth == self.auth } @@ -1516,7 +1378,7 @@ pub(super) struct ServeState { // long-lived origin serving many distinct one-shot paths stays bounded by the live count. served: WeakCache, - // How many times the route changed instance: a front compares it to the count when + // How many times the route changed identity: a front compares it to the count when // it asked to tell a released request from an answered one without taking this lock. renewals: Arc, @@ -1530,8 +1392,11 @@ pub(super) struct ServeState { /// route passes through get a front of their own, so its failover never adopts /// a route flowing back through one of its readers, nor a local view a peer's /// route. Every other requester shares the plain front, so fronts scale with -/// the peers in the path's route chains rather than with viewer sessions. -type FrontKey = (Path, Horizon); +/// the peers in the path's route chains rather than with viewer sessions. A +/// request naming an identity other than the path's winner, such as a relay +/// forwarding toward the pool member its own route names, follows that +/// identity's routes on a front of its own. +type FrontKey = (Path, Horizon, Option); /// Which routes a reader sees: the split-horizon exclusion and the local-only view. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] @@ -1615,14 +1480,12 @@ struct RemoteFront { /// The front's broadcast, weak: dead once the front ends, so a /// later request re-creates the front instead of joining a corpse. broadcast: broadcast::WeakConsumer, - /// [`OriginState::next_route`] as a requester last joined; see [`Resolution::moved`]. - joined: Arc, } /// The last route a cursor observed at a presented prefix. struct CursorRoute { - /// The publisher instance it serves. - instance: Instance, + /// The stream it serves. + identity: Identity, /// What the cursor delivered with it. meta: RouteMeta, /// Whether it serves requests: part of the dedupe key, but never delivered. @@ -1631,8 +1494,6 @@ struct CursorRoute { prefix: Path, /// What the cursor's scope captured from `prefix`. captures: Option>, - /// [`OriginState::next_route`] as it started. - started: u64, } impl WeakEntry for RemoteFront { @@ -1747,7 +1608,7 @@ impl TableCursor { } /// Where this cursor presents the predecessor `takeover` continues, when it presents - /// exactly that incarnation and every route it sees at the predecessor's prefix, + /// exactly that identity and every route it sees at the predecessor's prefix, /// other than the successor `id` itself, serves it: a path beneath could resolve /// through any of them, so another one there would make the continuity claim false /// for what it served. @@ -1755,19 +1616,19 @@ impl TableCursor { let claim = prefix_claim(&takeover.prefix).ok()?; let presented = self.presented(&takeover.prefix, &claim)?; let current = self.current.get(&presented)?; - if current.instance != takeover.instance || current.prefix != takeover.prefix { + if current.identity != takeover.identity || current.prefix != takeover.prefix { return None; } routes .at(&takeover.prefix) .filter(|entry| entry.id != id && self.visible(entry)) - .all(|entry| takeover.instance.is(entry)) + .all(|entry| entry.identity == takeover.identity) .then_some(presented) } /// Deliver a changed winner at this presented prefix, as of `now`, the /// [`OriginState::next_route`]. - fn update(&mut self, presented: &Path, best: Option<&RouteEntry>, now: u64, hold: &Hold, routes: &RouteTable) { + fn update(&mut self, presented: &Path, best: Option<&RouteEntry>, now: u64, routes: &RouteTable) { let Some(entry) = best else { if let Some(last) = self.current.remove(presented) && let Ok(mut state) = self.state.write() @@ -1781,7 +1642,7 @@ impl TableCursor { // Compared in place first: most visits find the winner unchanged, and building // what it delivers allocates. Captures follow from the prefix. if previous.is_some_and(|previous| { - previous.instance.is(entry) + previous.identity == entry.identity && previous.prefix == entry.prefix && previous.served == served && entry.delivers(&previous.meta) @@ -1791,7 +1652,6 @@ impl TableCursor { let meta = entry.meta(); let captures = self.captures(&entry.prefix); - let instance = entry.instance(); // Only as the route takes over: one this cursor sees later continues nothing it // can vouch for. let continued = entry @@ -1799,19 +1659,14 @@ impl TableCursor { .as_ref() .filter(|takeover| takeover.at == now) .and_then(|takeover| self.continued(entry.id, takeover, routes)); - let started = match previous { - Some(previous) if previous.instance == instance && previous.captures == captures => previous.started, - _ => now, - }; let previous = self.current.insert( presented.clone(), CursorRoute { - instance: instance.clone(), + identity: entry.identity.clone(), meta: meta.clone(), served, prefix: entry.prefix.clone(), captures: captures.clone(), - started, }, ); match previous { @@ -1839,16 +1694,15 @@ impl TableCursor { state.apply_announce(self.under.rejoin(presented), meta, captures); } } - // Another instance: whatever was resolved under the prefix is stale. - Some(previous) if previous.instance != instance => { - let release = meta.3.is_none().then(|| hold.release()).flatten(); + // Another identity: whatever was resolved under the prefix is another stream. + Some(previous) if previous.identity != entry.identity => { if let Ok(mut state) = self.state.write() { - state.apply_restart(self.under.rejoin(presented), meta, captures, release); + state.apply_restart(self.under.rejoin(presented), meta, captures); } } // Unchanged metadata and servability: nothing the consumer could // act on, even if the winning entry itself changed (a failover - // between routes with one epoch is invisible, which is the point). + // between routes of one identity and epoch is invisible, which is the point). // A servability flip is delivered: a request that failed Unroutable // under an advertise-only route retries on the update, and hiding // it would park that waiter forever. @@ -1862,10 +1716,12 @@ impl TableCursor { } /// The visible entry among `candidates` that presents here: the longest prefix, - /// then the first in [`route_order`]. - fn best<'a>(&self, candidates: impl Iterator) -> Option<&'a RouteEntry> { - candidates - .filter(|entry| self.visible(entry)) + /// then the first eligible one in [`route_order`]. + fn best<'a>(&self, candidates: impl Iterator + Clone) -> Option<&'a RouteEntry> { + let visible = candidates.filter(|entry| self.visible(entry)); + visible + .clone() + .filter(|entry| !superseded(entry, visible.clone())) .min_by_key(|entry| (Reverse(entry.prefix.len()), route_order(&entry.prefix, entry))) } @@ -2108,16 +1964,17 @@ fn hides(heads: &[Path], prefix: &Path) -> bool { pub enum AnnounceEvent { /// A route now covers the prefix; the cursor had none there. Start(Announce), - /// The route covering the prefix changed hops or cost; it is delivered in place. + /// The route covering the prefix changed hops, cost or epoch, with the same identity; + /// it is delivered in place. Update(Announce), /// No route covers the prefix any more. Carries its last advertised route. End(Announce), - /// Another publisher instance now serves the prefix: a newer epoch, or, on a - /// route without one, another route, or a path beneath it moving to one. + /// Another [`Identity`] now serves the prefix: another stream, such as a publisher + /// that restarted, a redundant ingest switching, or a pool failing over. /// - /// Whatever was resolved under the prefix before is the old instance: drop it - /// and request afresh to follow the new one. Subscriptions already open stay on - /// the old instance until dropped or its route goes. + /// Whatever was resolved under the prefix before is the old stream: drop it and + /// request afresh to follow the new one. Subscriptions already open stay on the old + /// one until dropped or its route goes. Restart(Announce), /// A route now covers the prefix and continues what the route at `predecessor`, /// a prefix at or above it, served beneath it. @@ -2215,7 +2072,7 @@ impl Producer { let (tasks, set) = TaskSet::new(); let scope = OriginScope::default(); let timers = Deadlines::new(Instant::ZERO); - let shared = SendCell::new(kio::Shared::new(OriginState::new(config.update_hold, timers.clone()))); + let shared = SendCell::new(kio::Shared::new(OriginState::new(config.update_hold))); let pool = config.pool.clone(); let producer = Self { hop: config.hop, @@ -2283,8 +2140,8 @@ impl Producer { /// higher wins, and every handle starts at the neutral [`Preference::default`]. /// /// Applies to what [`Self::requests`] advertises, which is how a session lands its - /// peer's routes, not to a broadcast published here. A route with a newer epoch still wins, - /// but among the rest preference decides before cost, so links to different + /// peer's routes, not to a broadcast published here. Only an identity's newest epoch is + /// eligible, but among eligible routes preference decides before cost, so links to different /// operators rank by this instead of comparing their metrics. Hand each CDN's /// session its own rank to use the primary while it has a route and fail over to /// the next. @@ -2329,10 +2186,7 @@ impl Producer { scope: OriginScope::empty(), max_hops: HopLimit::default(), root: Path::default(), - shared: SendCell::new(kio::Shared::new(OriginState::new( - DEFAULT_UPDATE_HOLD, - Deadlines::new(Instant::ZERO), - ))), + shared: SendCell::new(kio::Shared::new(OriginState::new(DEFAULT_UPDATE_HOLD))), pool: cache::Pool::default(), cache_duration: Duration::MAX, stats: stats::Session::default(), @@ -2348,11 +2202,11 @@ impl Producer { /// /// This is how local content enters an origin. The returned [`Publication`] /// controls its advertisement; [`Publication::broadcast`] borrows the fill producer. - /// Announce it with a [`Route::epoch`] - /// ([`Epoch::mint`](crate::Epoch::mint) per run, or a replica's shared one) so a - /// restart replaces it rather than resuming into different content, and so - /// routes with that epoch resume across each other. Without one it never - /// resumes elsewhere. + /// It serves a fresh random [`Route::identity`], kept across announce, unannounce + /// and re-announce since its sequences continue, so a new publication is a new + /// stream that never splices into an old one. A publisher that continues one + /// sequence space across restarts may name a stable identity instead + /// ([`Route::with_identity`]). /// /// The broadcast exists for nobody until [`Publication::announce`]: /// until then no announce cursor lists it and a request for its path fails @@ -2423,7 +2277,7 @@ impl Producer { pool: self.pool.clone(), cache_duration: self.cache_duration, path: full, - epoch: None, + identity: None, frames: self.frames.clone(), }; let source = info.produce().with_stats(ingress.clone()); @@ -2470,7 +2324,7 @@ impl Producer { pool: self.pool.clone(), cache_duration: self.cache_duration, path: full, - epoch: None, + identity: None, frames: self.frames.clone(), } .produce() @@ -2579,9 +2433,9 @@ impl Producer { /// sessions for as long as the returned [`Requests`] (and every clone) lives. /// A consumer resolving a path under it through this route is handed to the /// handler as a [`Request`] to materialize on demand: [`Request::prepare`] - /// under a new epoch, fill, then [`Prepared::publish`] the exact path. Each - /// answer is its own publisher instance, so restarting one output never - /// splices into a copy of the last. This is how a service answers a whole + /// under a new identity, fill, then [`Prepared::publish`] the exact path. Each + /// answer is its own stream, so restarting one output never splices into a copy + /// of the last. The route itself serves a random identity, minted here. This is how a service answers a whole /// subtree without publishing each path ahead of time; a publisher that /// knows its broadcasts advertises each one's exact path with /// [`Publication::announce`] instead, so subscribers can enumerate @@ -2818,6 +2672,8 @@ impl Announcing { return Err(Error::Closed); } + // A handle that names no identity serves one of its own. + let identity = route.identity.clone().unwrap_or_else(Identity::random); let mut entries = Vec::with_capacity(self.prefixes.len()); for (prefix, claim) in &self.prefixes { shared.reannounced(prefix, &route.hops); @@ -2827,8 +2683,8 @@ impl Announcing { let takeover = predecessor.and_then(|server| shared.takeover(prefix, server)); shared.routes.insert(RouteEntry { id, - epoch: route.epoch.clone(), - generation: id, + identity: identity.clone(), + epoch: route.epoch, prefix: prefix.clone(), scope: self.scope.clone(), hops: route.hops.clone(), @@ -2979,7 +2835,8 @@ pub(crate) struct AnnounceProducer { } impl AnnounceProducer { - /// Replace the route in place, epoch included; see [`Requests::update_route`]. + /// Replace the route in place, identity included when it names one; see + /// [`Requests::update_route`]. /// /// The prefix is fixed at announce time and a [`Route`] cannot name one: to /// move an advertisement, drop this and announce again. Fails with @@ -3015,44 +2872,41 @@ impl AnnounceProducer { if !admit() { return Ok(false); } - // The requests an epoch change released, refused once the table lock is gone: the + // The requests an identity change released, refused once the table lock is gone: the // refusal wakes their fronts, which read the table. let mut released = Vec::new(); for (prefix, id) in &self.entries { shared.reannounced(prefix, &route.hops); let stale = shared.withdrawn_through(prefix, &route.hops); - // Read before borrowing the entry; consumed only when the epoch changes. - let generation = shared.next_route; // Each entry keeps its advertised prefix; only the metadata moves. let Some(entry) = shared.routes.entry_mut(prefix, *id) else { return Err(Error::Closed); }; // Only advertising takes over: a re-price continues nothing new. let advertising = !entry.advertised; - // Another publisher instance: what its server was asked, or answered, for the - // old one is not its own. Under the table lock, so no front sees the new epoch - // while an old answer can still land. Losing the epoch names a new instance - // too, so the generation moves rather than reviving the one before the epoch. - let renewed = entry.epoch != route.epoch; - if renewed { - entry.generation = generation; - // The new instance continues nothing the old one took over. - entry.takeover = None; - if let Some(server) = &entry.server { + if let Some(identity) = &route.identity { + // Another stream continues nothing the old one took over. + if *identity != entry.identity { + entry.takeover = None; + } + // Another stream: what its server was asked, or answered, for the old one is + // not its own. Under the table lock, so no front sees the new identity while + // an old answer can still land. A higher epoch of the same one continues it. + if *identity != entry.identity + && let Some(server) = &entry.server + { released.extend(server.lock().renew()); } + entry.identity = identity.clone(); + entry.epoch = route.epoch; } entry.hops = route.hops.clone(); - entry.epoch = route.epoch.clone(); entry.stale = stale; entry.cost = route.cost; entry.auth = route.auth.clone(); entry.via = route.via; entry.advertised = true; let claim = entry.claim.clone(); - if renewed { - shared.next_route += 1; - } if advertising { // Past everything asked so far, so only that is continued. shared.next_route += 1; @@ -3074,12 +2928,13 @@ impl AnnounceProducer { } /// The route as last given, while advertised: every entry carries the same one. - fn route(&self) -> Option { + pub(crate) fn route(&self) -> Option { let shared = self.shared.lock(); let (prefix, id) = self.entries.first()?; let entry = shared.routes.entry(prefix, *id).filter(|entry| entry.advertised)?; Some(Route { - epoch: entry.epoch.clone(), + identity: Some(entry.identity.clone()), + epoch: entry.epoch, hops: entry.hops.clone(), cost: entry.cost, auth: entry.auth.clone(), @@ -3319,12 +3174,13 @@ struct FrontTask { path: Path, /// The requesters' horizon, applied to every (re)selection. horizon: Horizon, + /// The identity the front serves from the start, for requests naming another one than + /// the path's winner; see [`FrontKey`]. + pin: Option, /// Wakes the front when a route covering its path changes. watch: Watch, /// Resolves the requesters parked on the front's channel. request: kio::Producer, - /// See [`RemoteFront::joined`]. - joined: Arc, timers: Deadlines, } @@ -3501,36 +3357,22 @@ async fn run_front(task: FrontTask, origin: TasksWeak) { .await; } -/// The route a front may serve from. `resolved` is what the front first resolved, -/// once it has. Routes with its epoch serve the same bytes, so any of them may take -/// over; a front resolved without one stays on its resolution's route, the only one -/// known to serve its bytes (its first, or the [`Takeover`] it adopted), while a -/// source serves it, and never re-requests a failed track through it, since it may -/// resolve that to another instance. Either way the front -/// stays until those routes go, even once another instance wins the path: its -/// subscriptions are sticky, and new requests resolve the winner on a fresh front. +/// The route a front may serve from. `resolved` is the identity the front first +/// resolved, once it has. Its routes serve one continuous sequence space, so any of them +/// at its newest epoch may take over. The front stays until those routes go, even once +/// another identity wins the path: its subscriptions are sticky, and new requests +/// resolve the winner on a fresh front. fn pick<'a>( table: &'a OriginState, path: &Path, horizon: Horizon, front: &Front, - resolved: Option<&Resolution>, + resolved: Option<&Identity>, ) -> Option<&'a RouteEntry> { let excluded = front.excluded_routes(); - let Some(resolved) = resolved else { - return table.best_route(path, horizon, excluded, |_| true); - }; - match &resolved.instance { - Instance::Epoch(epoch) => { - table.best_route(path, horizon, excluded, |entry| entry.epoch.as_ref() == Some(epoch)) - } - // A closed source took the bytes with it: nothing else is known to serve them. - Instance::Route(_) => front.serving().and_then(|_| { - table - .routes - .covering(path) - .find(|entry| entry.id == resolved.route && entry.live() && horizon.admits(entry)) - }), + match resolved { + None => table.best_route(path, horizon, excluded, |_| true), + Some(identity) => table.best_route(path, horizon, excluded, |entry| entry.identity == *identity), } } @@ -3544,9 +3386,9 @@ async fn serve_front(task: FrontTask) -> Vec { broadcast, path, horizon, + pin, watch, request, - joined, timers, } = task; @@ -3579,15 +3421,15 @@ async fn serve_front(task: FrontTask) -> Vec { let mut listing: Option = None; let mut front = Front::new(track::IDLE_LINGER); let mut sources: HashMap = HashMap::new(); - // The instance each source's route served when it was asked, and the table's clock + // The identity each source's route served when it was asked, and the table's clock // then: what its content is from. - let mut instances: HashMap = HashMap::new(); + let mut instances: HashMap = HashMap::new(); let mut next_source = 0u64; /// The in-flight upstream request. struct Upstream { route: u64, - /// The route's instance when asked. - asked: Instance, + /// The route's identity when asked. + asked: Identity, /// [`OriginState::next_route`] when asked. asked_at: u64, pending: kio::Consumer, @@ -3603,7 +3445,7 @@ async fn serve_front(task: FrontTask) -> Vec { // The watch generation the last selection saw. let mut seen = 0; // What the front first resolved; see [`pick`]. - let mut resolved: Option = None; + let mut resolved: Option = None; // Whether a consumer held the broadcast as of the last edge fed to the machine, // starting with the request that minted the front. let mut held = true; @@ -3617,9 +3459,9 @@ async fn serve_front(task: FrontTask) -> Vec { // the serving source is on its way out. Also what the watch wakes for. let select = |front: &mut Front, sources: &HashMap, - instances: &mut HashMap, + instances: &mut HashMap, seen: &mut u64, - resolved: &mut Option, + resolved: &mut Option, discovered: &mut Option| -> Event { let table = shared.read(); @@ -3629,70 +3471,48 @@ async fn serve_front(task: FrontTask) -> Vec { // Read alongside the decision, under the lock a poke takes first. *seen = watch.seen(); front.retain_routes(|route| table.routes.covers(&path, route)); - // A route that took over the instance the front resolved continues it. The table + // A route that took over the identity the front resolved continues it. The table // carries the front's verdict forward as each takeover lands; this also catches // one that landed before the front wrote its first, or won the path only later. - if let Some(resolution) = resolved.as_mut() - && let Some((instance, route)) = request.write().ok().and_then(|mut pending| { + if let Some(identity) = resolved.as_mut() + && let Some(next) = request.write().ok().and_then(|mut pending| { if let Some(best) = table.best_route(&path, horizon, &HashSet::new(), |_| true) { PendingFront::continue_with(&mut pending, best); } - match (&pending.resolved, pending.via) { - (Some(Ok(instance)), Some((route, _))) if route != resolution.route => { - Some((instance.clone(), route)) - } + match &pending.resolved { + Some(Ok(next)) if next != identity => Some(next.clone()), _ => None, } }) { for (asked, _) in instances.values_mut() { - if *asked == resolution.instance { - *asked = instance.clone(); + if asked == identity { + *asked = next.clone(); } } - resolution.instance = instance; - resolution.route = route; - resolution.prefix = table - .routes - .covering(&path) - .find(|entry| entry.id == route) - .map(|entry| entry.prefix.clone()); - resolution.settled = None; + *identity = next; } - let best = pick(&table, &path, horizon, front, resolved.as_ref()); + let best = pick(&table, &path, horizon, front, resolved.as_ref().or(pin.as_ref())); // Under the table's lock, which a request reads it under too: a request that // sees another winner from here on mints its own front rather than join this one. if resolved.is_none() && let Some(entry) = best && let Ok(mut pending) = request.write() + && pending.selected != entry.identity { - let selected = entry.instance(); - if pending.selected != selected { - pending.selected = selected; - } + pending.selected = entry.identity.clone(); } // Only a received route carries a hop chain: a local one needs no discovery. let received = best.is_some_and(|entry| !entry.hops.is_empty()); let best = best.map(|entry| Candidate { route: entry.id, local: entry.local, - epoch: entry.epoch.is_some(), + carried: !entry.identity.is_minted(), }); let serving_closing = front .serving() .and_then(|id| sources.get(&id)) .is_some_and(|source| source.is_closing()); - let joined = joined.load(Ordering::Acquire); - let renew = resolved - .as_mut() - .and_then(|resolved| resolved.moved(&table, &path, horizon, joined)); drop(table); - if let Some(prefix) = renew { - // Checked under the lock that renews: a restart after the read covers the move. - let mut table = shared.lock(); - if !table.restarted_since(&prefix, joined) { - table.renew(&prefix); - } - } if received && discovered.is_none() { let mut table = shared.lock(); *discovered = Some(Interest::new(&mut table, &shared, path.clone())); @@ -3703,27 +3523,25 @@ async fn serve_front(task: FrontTask) -> Vec { // Whether a route's refusal is the answer: only the current winner speaks for the // path. A retraction resolves like a rejection, and a route beaten while its // request was pending speaks for nobody, so either one re-selects instead. - let standing = |front: &Front, route: u64, resolved: &Option| { + let standing = |front: &Front, route: u64, resolved: &Option| { let table = shared.read(); - pick(&table, &path, horizon, front, resolved.as_ref()).is_some_and(|best| best.id == route) + pick(&table, &path, horizon, front, resolved.as_ref().or(pin.as_ref())).is_some_and(|best| best.id == route) }; - // Whether an answer `route` gave for `asked` would serve another instance than the + // Whether an answer `route` gave for `asked` would serve another identity than the // front's: the route has since moved off it, or, before the front resolved, another - // instance won the path while the request was in flight. - let stale = |front: &Front, route: u64, asked: &Instance, resolved: &Option| { + // identity won the path while the request was in flight. + let stale = |front: &Front, route: u64, asked: &Identity, resolved: &Option| { let table = shared.read(); let Some(entry) = table.routes.covering(&path).find(|entry| entry.id == route) else { return false; }; - if entry.instance() != *asked { + if entry.identity != *asked { return true; } match resolved { - Some(resolved) => resolved.instance != *asked, - None => table - .best_route(&path, horizon, front.excluded_routes(), |_| true) - .is_some_and(|best| best.instance() != *asked), + Some(resolved) => resolved != asked, + None => pick(&table, &path, horizon, front, pin.as_ref()).is_some_and(|best| best.identity != *asked), } }; @@ -3768,8 +3586,8 @@ async fn serve_front(task: FrontTask) -> Vec { .covering(&path) .find(|entry| entry.id == route && entry.live()) .map(|entry| { - let instance = (entry.instance(), table.next_route); - (entry.source.clone(), entry.server.clone(), instance) + let identity = (entry.identity.clone(), table.next_route); + (entry.source.clone(), entry.server.clone(), identity) }) }; let Some((source, server, instance)) = found else { @@ -3885,30 +3703,19 @@ async fn serve_front(task: FrontTask) -> Vec { } } Action::Resolve => { - // The broadcast is whatever its first source serves, from the instance + // The broadcast is whatever its first source serves, from the identity // its route had when asked. let (Some(source), Some(route)) = (front.serving(), front.serving_route()) else { continue; }; - let Some((instance, asked)) = instances.get(&source).cloned() else { + let Some((identity, asked)) = instances.get(&source).cloned() else { continue; }; - let prefix = shared - .read() - .routes - .covering(&path) - .find(|entry| entry.id == route) - .map(|entry| entry.prefix.clone()); - resolved = Some(Resolution { - instance: instance.clone(), - route, - prefix, - settled: None, - }); + resolved = Some(identity.clone()); if let Ok(mut pending) = request.write() && pending.resolved.is_none() { - pending.resolved = Some(Ok(instance)); + pending.resolved = Some(Ok(identity)); pending.via = Some((route, asked)); } } @@ -4017,7 +3824,9 @@ async fn serve_front(task: FrontTask) -> Vec { shared .lock() .fronts - .remove_if(&(path.clone(), horizon), |front| front.request.same_channel(&request)); + .remove_if(&(path.clone(), horizon, pin.clone()), |front| { + front.request.same_channel(&request) + }); if let Ok(mut pending) = request.write() { pending.refused = refused; pending.resolved.get_or_insert(Err(err.clone())); @@ -4230,11 +4039,11 @@ async fn serve_front(task: FrontTask) -> Vec { }; // Counted rather than read off the request, which an answer just before // the change already took out of the queue, and rather than compared by - // epoch, which may have come back since. + // identity, which may have come back since. let released = renewals.load(Ordering::Acquire) != asked_renewals; match result { - // An epoch change released the request, the route moved to another - // instance, or another won the path, while the request was in flight: + // An identity change released the request, the route moved to another + // identity, or another won the path, while the request was in flight: // ask again rather than resolve requesters onto a broadcast that is // already replaced. _ if released => Event::Resolved { @@ -4504,102 +4313,74 @@ enum WatchProducer { struct Selected { path: Path, via: Hop, - instance: Instance, - /// The prefix of the route the instance came through. + identity: Identity, + /// The prefix of the route the identity came through. prefix: Path, /// [`OriginState::next_route`] when selected: only a [`Takeover`] after that - /// continues the instance. + /// continues the identity. since: u64, - /// The selection period, closed when the peer stops selecting the instance. + /// The selection period, closed when the peer stops selecting the identity. current: Option>, - /// Open until another instance of the peer takes the path, which may answer a + /// Open until another identity of the peer takes the path, which may answer a /// request still unanswered. A withdrawal alone leaves it open, and a period the - /// instance wins again keeps it. + /// identity wins again keeps it. answering: Option>, - /// The [`Release`] that ends `current`: the peer replaced the instance with a - /// route without an epoch, whose `Restart` is held until then. - retiring: Option, } /// How a [`Selected`] period changes. enum Change { /// A new period: nothing refused in the last one revives. Start, - /// The instance won again before its replacement was released. - Continue, - /// Retire the period once the replacement's held restart is released. - Retire(Release), - /// The peer no longer selects the instance, effective at once. + /// The peer no longer selects the identity. End, - /// Another instance of the peer took the path, effective at once. + /// Another identity of the peer took the path. Replace, } impl Selected { - /// Follow `best` when it took over the selected instance: what the peer served - /// through it is the new instance now, so the current period carries on. A period + /// Follow `best` when it took over the selected identity: what the peer served + /// through it is the new identity now, so the current period carries on. A period /// already over stays over. fn continue_with(&mut self, best: &RouteEntry) { if self.current.is_some() && let Some(takeover) = &best.takeover - && takeover.instance == self.instance + && takeover.identity == self.identity && takeover.prefix == self.prefix && self.since < takeover.at { - self.instance = best.instance(); + self.identity = best.identity.clone(); self.prefix = best.prefix.clone(); } } /// How the period changes now that the peer's best route is `best`, if it does. - fn change(&self, best: Option<&RouteEntry>, hold: &Hold) -> Option { - let replaced = self.retiring.as_ref().is_some_and(Release::released); + fn change(&self, best: Option<&RouteEntry>) -> Option { let Some(best) = best else { - // A released replacement already took the path before this withdrawal. self.current.as_ref()?; - return Some(if replaced { Change::Replace } else { Change::End }); + return Some(Change::End); }; - if best.instance() == self.instance { - return match (&self.current, &self.retiring) { - (Some(_), None) => None, - (Some(_), Some(_)) if !replaced => Some(Change::Continue), - _ => Some(Change::Start), - }; + if best.identity == self.identity { + return self.current.is_none().then_some(Change::Start); } if self.current.is_none() { - // Withdrawn earlier: the instance taking the path now may answer what is left. + // Withdrawn earlier: the identity taking the path now may answer what is left. return self.answering.is_some().then_some(Change::Replace); } - // Announce cursors hold a restart at the same prefix onto a route without an - // epoch, so the period lasts as long. Anything else they deliver at once. - let held = best.epoch.is_none() && best.prefix == self.prefix; - match (held, &self.retiring) { - (true, Some(_)) => None, - (true, None) => Some(hold.release().map_or(Change::Replace, Change::Retire)), - (false, _) => Some(Change::Replace), - } + Some(Change::Replace) } fn apply(&mut self, change: Change) { match change { Change::Start => { - // Past a released replacement, the instance that answered in between - // was the replacement. - if self.retiring.take().is_some() || self.answering.is_none() { + if self.answering.is_none() { self.answering = Some(kio::Producer::default()); } self.current = Some(kio::Producer::default()); } - Change::Continue => self.retiring = None, - Change::Retire(release) => self.retiring = Some(release), - Change::End => { - self.current = None; - self.retiring = None; - } + Change::End => self.current = None, Change::Replace => { self.current = None; self.answering = None; - self.retiring = None; } } } @@ -4625,7 +4406,7 @@ impl Selection { .unwrap_or_else(|| kio::Producer::new(()).consume()) } - /// Closed once another instance takes the path, for a request already sent. + /// Closed once another identity takes the path, for a request already sent. #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] pub(super) fn answering(&self) -> kio::Consumer<()> { self.signal @@ -4635,19 +4416,6 @@ impl Selection { .map(kio::Producer::consume) .unwrap_or_else(|| kio::Producer::new(()).consume()) } - - /// `Ready` once the current period's replacement is released, registering - /// `waiter` until then. - #[cfg_attr(not(feature = "async"), allow(dead_code, reason = "async-only"))] - pub(super) fn poll_retired(&self, waiter: &kio::Waiter) -> Poll<()> { - match self.signal.poll(waiter, |selected| match &selected.retiring { - Some(release) => release.poll(waiter), - None => Poll::Pending, - }) { - Poll::Pending => Poll::Pending, - Poll::Ready(_) => Poll::Ready(()), - } - } } impl Drop for Selection { @@ -4934,14 +4702,14 @@ impl RouteNode { Some(result) } - /// Wake route watchers, and revoke only leases whose selected instance changed. + /// Wake route watchers, and revoke only leases whose selected identity changed. /// `taken` lists the routes taken over by the change: only a front resolved through /// one of them can follow it. - fn poke(&self, routes: Option<(&RouteTable, &Hold, &[u64])>) { + fn poke(&self, routes: Option<(&RouteTable, &[u64])>) { for (_, watch) in &self.watches { match watch { WatchProducer::Changes(watch, front) => { - if let (Some(front), Some((routes, _, taken))) = (front, routes) { + if let (Some(front), Some((routes, taken))) = (front, routes) { front.follow(routes, taken); } if let Ok(mut watched) = watch.write() { @@ -4951,7 +4719,7 @@ impl RouteNode { WatchProducer::Selection(watch) => { if let Ok(mut selected) = watch.write() { let change = match routes { - Some((routes, hold, _)) => { + Some((routes, _)) => { let best = routes.best_route(&selected.path, Horizon::default(), &HashSet::new(), |entry| { entry.via == selected.via @@ -4959,7 +4727,7 @@ impl RouteNode { if let Some(best) = best { selected.continue_with(best); } - selected.change(best, hold) + selected.change(best) } None => selected.current.is_some().then_some(Change::End), }; @@ -4974,13 +4742,13 @@ impl RouteNode { } /// Wake only the watched subtrees covered by this route. - fn poke_below(&self, routes: &RouteTable, hold: &Hold, taken: &[u64]) { + fn poke_below(&self, routes: &RouteTable, taken: &[u64]) { if self.watches_below == 0 { return; } - self.poke(Some((routes, hold, taken))); + self.poke(Some((routes, taken))); for child in self.children.values() { - child.poke_below(routes, hold, taken); + child.poke_below(routes, taken); } } @@ -5055,7 +4823,9 @@ impl RouteTable { .peekable(); if candidates.peek().is_some() { best = candidates + .clone() .filter(|entry| entry.serves(path)) + .filter(|entry| !superseded(entry, candidates.clone())) .min_by_key(|entry| route_order(path, entry)); } } @@ -5078,7 +4848,7 @@ impl RouteTable { } /// The routes covering `path`: those announced at it and at every prefix of it. - fn covering(&self, path: &Path) -> impl Iterator { + fn covering(&self, path: &Path) -> impl Iterator + Clone { // Walked lazily rather than through [`Self::split`], which allocates: the // announce sync asks this per cursor presenting a prefix as its root. let mut parts = path.parts(); @@ -5097,7 +4867,7 @@ impl RouteTable { } /// The routes announced exactly at `prefix`. - fn at(&self, prefix: &Path) -> impl Iterator { + fn at(&self, prefix: &Path) -> impl Iterator + Clone { self.root .find(prefix.parts()) .into_iter() @@ -5218,7 +4988,7 @@ impl RouteTable { /// Wake the watches of every path a route at `prefix` covers. /// A route at `prefix` that took over as of `now` carries the fronts resolved through /// its predecessor along; see [`FrontWatch`]. - fn poke_below(&self, prefix: &Path, hold: &Hold, now: u64) { + fn poke_below(&self, prefix: &Path, now: u64) { if let (_, Some(node)) = self.split(prefix) { let taken: Vec = node .entries @@ -5227,7 +4997,7 @@ impl RouteTable { .filter(|takeover| takeover.at == now) .map(|takeover| takeover.route) .collect(); - node.poke_below(self, hold, &taken); + node.poke_below(self, &taken); } } @@ -5262,14 +5032,11 @@ struct OriginState { // See [`Config::update_hold`]. No `Default`: every origin takes it from its // config, so no path silently disables the hold. update_hold: Duration, - // The driver's clock, which a held restart's [`Release`] counts on. - timers: Deadlines, // The announced routes, keyed by prefix. The table holds one entry per live // advertisement, not one per broadcast consumer. routes: RouteTable, - // Route ids and generations, and the clock a cursor's start and a front's join - // read. A join takes a value of its own, so a start before it reads older. + // Route ids. next_route: u64, next_watch: u64, next_snapshot: u64, @@ -5300,10 +5067,9 @@ struct OriginState { } impl OriginState { - fn new(update_hold: Duration, timers: Deadlines) -> Self { + fn new(update_hold: Duration) -> Self { Self { update_hold, - timers, routes: RouteTable::default(), next_route: 0, next_watch: 0, @@ -5391,7 +5157,6 @@ impl OriginState { // Split borrows: the recompute reads `routes` while mutating a cursor. let routes = &self.routes; let now = self.next_route; - let hold = Hold::new(&self.timers, self.update_hold); let mut candidates = None; for id in routes.cursors_touching(prefix) { let Some(cursor) = self.cursors.get_mut(&id) else { @@ -5400,10 +5165,10 @@ impl OriginState { if let Some(presented) = cursor.presented(prefix, claim) { if presented.is_empty() { // A cursor root can collapse several covering prefixes into one. - Self::sync_cursor(routes, cursor, &presented, now, &hold); + Self::sync_cursor(routes, cursor, &presented, now); } else { // Every non-root presentation refers to this exact prefix. Rank - // its routes once, then take each cursor's first visible entry. + // its routes once, then take each cursor's first eligible visible entry. let candidates = candidates.get_or_insert_with(|| { let mut entries: Vec<_> = routes .at(prefix) @@ -5412,34 +5177,17 @@ impl OriginState { entries.sort_unstable_by_key(|(order, _)| *order); entries }); - let best = candidates + let visible = candidates .iter() .map(|(_, entry)| *entry) - .find(|entry| cursor.visible(entry)); - cursor.update(&presented, best, now, &hold, routes); + .filter(|entry| cursor.visible(entry)); + let best = visible.clone().find(|entry| !superseded(entry, visible.clone())); + cursor.update(&presented, best, now, routes); } } } // The fronts and requesters under the prefix re-select from the table. - routes.poke_below(prefix, &hold, now); - } - - /// A path beneath `prefix` moved to another of its routes without an epoch, while - /// the prefix's own winner may not have: give each such route there a fresh - /// generation, so the cursors presenting the prefix restart it, and nothing - /// resolved through it before is joined again. - fn renew(&mut self, prefix: &Path) { - let mut claim = None; - for entry in self.routes.at_mut(prefix) { - if entry.epoch.is_none() { - entry.generation = self.next_route; - self.next_route += 1; - claim = Some(entry.claim.clone()); - } - } - if let Some(claim) = claim { - self.sync_route(prefix, &claim); - } + routes.poke_below(prefix, now); } /// Start a [`Snapshot`] at the absolute `prefix` for the routes arriving on `link`. @@ -5577,8 +5325,8 @@ impl OriginState { } /// Recompute the best visible route presenting at `presented` (relative) for - /// one cursor and deliver the change, if any, as of `now`. - fn sync_cursor(routes: &RouteTable, cursor: &mut TableCursor, presented: &Path, now: u64, hold: &Hold) { + /// one cursor and deliver the change, if any. + fn sync_cursor(routes: &RouteTable, cursor: &mut TableCursor, presented: &Path, now: u64) { // The entries presenting here are the ones announced at the absolute // prefix, or, for the cursor's own root, at the root and every prefix // above it (all of which present as the empty path). Among them, the @@ -5592,7 +5340,7 @@ impl OriginState { } }; - cursor.update(presented, best, now, hold, routes); + cursor.update(presented, best, now, routes); } /// Register a cursor and replay the current best route per presented prefix. @@ -5625,10 +5373,8 @@ impl OriginState { } } } - // A cursor's first sight of a route is no restart, so nothing is held. - let hold = Hold::new(&self.timers, Duration::ZERO); for p in &presented { - Self::sync_cursor(&self.routes, &mut cursor, p, self.next_route, &hold); + Self::sync_cursor(&self.routes, &mut cursor, p, self.next_route); } for head in &cursor.heads { self.routes.add_cursor(head, id); @@ -5744,38 +5490,11 @@ impl OriginState { } Some(Takeover { route: entry.id, - instance: entry.instance(), + identity: entry.identity.clone(), prefix: entry.prefix.clone(), at: self.next_route, }) } - - /// Whether every cursor presenting `prefix` restarted it since - /// [`next_route`](Self::next_route) read `since`: none still presents an instance - /// without an epoch it started before, which is what a [renewal](Self::renew) - /// would restart. - fn restarted_since(&self, prefix: &Path, since: u64) -> bool { - // Without a route there from before, no cursor can present one: the common - // case once a renewal moved every generation, and cheaper than the cursors. - let Some(claim) = self - .routes - .at(prefix) - .find(|entry| matches!(entry.instance(), Instance::Route(generation) if generation < since)) - .map(|entry| &entry.claim) - else { - return true; - }; - self.routes.cursors_touching(prefix).into_iter().all(|id| { - let Some(cursor) = self.cursors.get(&id) else { - return true; - }; - let current = cursor - .presented(prefix, claim) - .and_then(|presented| cursor.current.get(&presented)); - // An older route the cursor switched to since is a restart all the same. - !current.is_some_and(|current| matches!(current.instance, Instance::Route(_)) && current.started < since) - }) - } } /// One-shot result of a dynamic broadcast request. @@ -5789,44 +5508,44 @@ struct PendingBroadcast { resolved: Option>, } -/// A front's verdict, which its requesters wait on: the instance it resolved, or the -/// error that ended it unresolved. A request joins the front only while that instance +/// A front's verdict, which its requesters wait on: the identity it resolved, or the +/// error that ended it unresolved. A request joins the front only while that identity /// still wins, or before then, while the one it selected does. Each requester holds the /// front's broadcast from the moment it joins, so this carries no handle that would keep /// the front held. struct PendingFront { - resolved: Option>, + resolved: Option>, /// The route the front resolved through, and [`OriginState::next_route`] when it /// asked: only a [`Takeover`] after that continues it. Each route that takes the /// front over carries this forward; see [`Self::continue_with`]. via: Option<(u64, u64)>, - /// The instance the front resolves on unless the table moves first: the winner it + /// The identity the front resolves on unless the table moves first: the winner it /// was minted for, then each one it selects. Written under the table's lock, so a - /// request never joins a front that picked an instance the request saw beaten. - selected: Instance, + /// request never joins a front that picked an identity the request saw beaten. + selected: Identity, /// The selected serving handler refused, rather than the route disappearing. refused: bool, } impl PendingFront { - /// Whether a route that took over by `takeover` continues the instance the front + /// Whether a route that took over by `takeover` continues the identity the front /// resolved: through the route it resolved that through, after the front asked. fn continued_by(&self, takeover: Option<&Takeover>) -> bool { let (Some(Ok(resolved)), Some((route, asked)), Some(takeover)) = (&self.resolved, self.via, takeover) else { return false; }; - takeover.route == route && takeover.instance == *resolved && asked < takeover.at + takeover.route == route && takeover.identity == *resolved && asked < takeover.at } /// Follow `best` when it took over what the front resolved: the front, its sources, - /// and the requests joining it carry the new instance. Under the table's lock as each + /// and the requests joining it carry the new identity. Under the table's lock as each /// route takes over (see [`FrontWatch`]), so a chain of takeovers carries the front to /// its end, however many land before the front's task looks. fn continue_with(this: &mut kio::Mut<'_, Self>, best: &RouteEntry) { // Read first: a write through the guard wakes every requester. if this.continued_by(best.takeover.as_ref()) { let asked = this.via.map(|(_, asked)| asked); - this.resolved = Some(Ok(best.instance())); + this.resolved = Some(Ok(best.identity.clone())); this.via = asked.map(|asked| (best.id, asked)); } } @@ -5860,46 +5579,6 @@ impl FrontWatch { } } -/// What a front resolved: the instance its first source serves, and the route that -/// source came through. -struct Resolution { - instance: Instance, - route: u64, - /// That route's prefix, when it was still in the table as the front resolved. - prefix: Option, - /// The [`RemoteFront::joined`] the prefix last restarted for the path's move, by - /// a renewal or on its own; see [`Self::moved`]. - settled: Option, -} - -impl Resolution { - /// The prefix to [renew](OriginState::renew) unless it already - /// [restarted](OriginState::restarted_since) since the last requester `joined` - /// the front: on a route without an epoch, a request for the path, beneath the - /// prefix, now resolves through another route there. The prefix's own winner may - /// be unchanged, so nothing else tells its announce cursors that what they - /// resolved under it moved. - /// - /// Settled once per join: a renewal leaves the front's instance behind, so - /// nobody joins it after. - fn moved(&mut self, table: &OriginState, path: &Path, horizon: Horizon, joined: u64) -> Option { - if self.settled == Some(joined) || !matches!(self.instance, Instance::Route(_)) { - return None; - } - let prefix = self.prefix.as_ref()?; - // The prefix's own winner, which its cursors follow on their own. - if *prefix == *path { - return None; - } - let best = table.best_route(path, horizon, &HashSet::new(), |_| true)?; - if best.id == self.route || best.prefix != *prefix || best.epoch.is_some() { - return None; - } - self.settled = Some(joined); - Some(prefix.clone()) - } -} - /// A served route, from [`Producer::requests`]: advertises a path prefix and /// answers the [`Consumer::request`] calls beneath it. /// @@ -5942,20 +5621,19 @@ impl Requests { .is_some_and(|server| server.same_channel(&self.state)) })?; let via = entry.via; - let instance = entry.instance(); + let identity = entry.identity.clone(); let prefix = entry.prefix.clone(); let active = table .best_route(path, Horizon::default(), &HashSet::new(), |entry| entry.via == via) - .is_some_and(|entry| entry.instance() == instance); + .is_some_and(|entry| entry.identity == identity); let producer = kio::Producer::new(Selected { path: path.clone(), via, - instance, + identity, prefix, since: table.next_route, current: active.then(kio::Producer::default), answering: active.then(kio::Producer::default), - retiring: None, }); let signal = producer.consume(); let id = table.next_watch; @@ -5990,20 +5668,16 @@ impl Requests { /// Replace the route in place, taken as given. /// - /// At the same [`Route::epoch`] this re-prices: consumers observe an update and - /// every handle survives. Another epoch, or none, names another publisher - /// instance: consumers see an [`AnnounceEvent::Restart`], and a re-request never - /// joins what was resolved under the old one. Requests still waiting on this - /// handle carry over: the handler is asked again under the new epoch, and its - /// answer to a request asked before the change is dropped, never served under the - /// new epoch. A request pinned to the old epoch is refused with - /// [`Error::Unroutable`]. The answers already served are forgotten, so the next - /// request for one of those paths asks the handler again too. The broadcasts it - /// served keep running for the subscriptions already on them; close them to end - /// those too. Route selection still applies: another route still at the old epoch - /// outranks one without. - /// To re-price, start from the current route: - /// `dynamic.update_route(dynamic.route().with_cost(cost))`. + /// A route naming no [`Route::identity`], or the same one, re-prices: consumers observe + /// an update and every handle survives. Another identity is another stream: consumers + /// see an [`AnnounceEvent::Restart`], and a re-request never joins what was resolved + /// under the old one. Requests still waiting on this handle carry over: the handler is + /// asked again under the new identity, and its answer to a request asked before the + /// change is dropped, never served under the new one. A request pinned to the old + /// identity is refused with [`Error::Unroutable`]. The answers already served are + /// forgotten, so the next request for one of those paths asks the handler again too. + /// The broadcasts it served keep running for the subscriptions already on them; close + /// them to end those too. /// /// The prefix is fixed at announce time: to move a route, drop this and call /// [`Producer::requests`] again. Fails with [`Error::Closed`] once the origin's @@ -6096,7 +5770,7 @@ impl ServeState { if self.closed { return None; } - // Released already (the route changed instance): the answer was for the old one, + // Released already (the route changed identity): the answer was for the old one, // so nothing resolves or caches it. self.requests.remove_if(path, |p| p.same_channel(producer))?; let state = self; @@ -6125,7 +5799,7 @@ impl ServeState { } /// Release every request, queued or handed to a handler, and forget the answers served, - /// because the route now names another publisher instance. Returns the released + /// because the route now names another identity. Returns the released /// requests for the caller to refuse once it holds no lock, since the refusal wakes /// their fronts. Those ask again under the new instance; the handler's answer to a /// released request is dropped. @@ -6180,15 +5854,14 @@ impl Request { &self.path } - /// Start an answer under a new publisher `epoch`, hidden until it is published. - pub fn prepare(self, epoch: Epoch) -> Result { + /// Start an answer under a fresh random identity, hidden until it is published. + pub fn prepare(self) -> Result { let publisher = self.publisher.clone().ok_or(Error::Unroutable)?; let (broadcast, announcer) = publisher.create_withheld(self.path.clone())?; Ok(Prepared { request: self, broadcast, announcer, - epoch, }) } @@ -6213,9 +5886,8 @@ pub struct Prepared { request: Request, broadcast: broadcast::Producer, // Withheld from the broadcast until `publish`, so the answer cannot announce - // itself under another epoch or skip the request's admission. + // itself under another identity or skip the request's admission. announcer: Announcer, - epoch: Epoch, } impl Prepared { @@ -6234,9 +5906,8 @@ impl Prepared { request, broadcast, announcer, - epoch, } = self; - let route = Route::default().with_epoch(epoch); + let route = Route::default(); let answer = broadcast.consume(); let mut resolved = None; // Answered under the table lock that every release takes first, so the route @@ -6283,9 +5954,9 @@ pub(crate) struct Lookup { // Egress scope applied to the resolved broadcast, so its reads are attributed. // Empty (no-op) for an untagged consumer. stats: stats::Scope, - // The publisher instance the requester named, if any: the front may resolve on - // another one when the route changes while the request is in flight. - epoch: Option, + // The identity the requester named, if any: the front may resolve on another one + // when the route changes while the request is in flight. + identity: Option, } enum RequestState { @@ -6314,13 +5985,13 @@ impl Lookup { inner, path: Path::default(), stats: stats::Scope::default(), - epoch: None, + identity: None, } } - /// Refuse a resolution on any epoch but `epoch`. - fn with_epoch(mut self, epoch: Option) -> Self { - self.epoch = epoch; + /// Refuse a resolution on any identity but `identity`. + fn with_identity(mut self, identity: Option) -> Self { + self.identity = identity; self } @@ -6336,12 +6007,12 @@ impl Lookup { } /// Stamp a resolved broadcast with the path this cursor asked for, its egress scope, - /// and the epoch of the route it resolved through. - fn hand_out(&self, broadcast: broadcast::Consumer, epoch: Option) -> broadcast::Consumer { + /// and the identity of the route it resolved through. + fn hand_out(&self, broadcast: broadcast::Consumer, identity: Identity) -> broadcast::Consumer { broadcast .with_path(self.path.clone()) .with_stats(self.stats.clone()) - .with_epoch(epoch) + .with_identity(Some(identity)) } /// Poll for the requested broadcast without blocking. @@ -6353,11 +6024,11 @@ impl Lookup { Some(result) => Poll::Ready(result.clone()), None => Poll::Pending, })) { - // Another instance than the one named: never hand it out. - Ok(Ok(instance)) if self.epoch.is_some() && instance.epoch() != self.epoch => { + // Another identity than the one named: never hand it out. + Ok(Ok(identity)) if self.identity.as_ref().is_some_and(|named| *named != identity) => { Err(Error::Unroutable) } - Ok(Ok(instance)) => Ok(self.hand_out(broadcast.clone(), instance.epoch())), + Ok(Ok(identity)) => Ok(self.hand_out(broadcast.clone(), identity)), Ok(Err(err)) => Err(err), // The front's driver went away without a verdict: nobody could route it. Err(_closed) => Err(Error::Unroutable), @@ -6385,8 +6056,8 @@ pub struct Requesting { watch: Option<(Watch, u64)>, // The ask in flight, dropped once it fails `Unroutable` until the routes move. request: Option, - // The publisher instance every ask is pinned to, if any. - epoch: Option, + // The identity every ask is pinned to, if any. + identity: Option, // A finite request owns discovery and can conclude absence once its initial set lands. discovery: Option, routed: bool, @@ -6406,7 +6077,7 @@ impl Requesting { | AnnounceEvent::Update(announce) | AnnounceEvent::Takeover { announce, .. }, )) => { - self.routed = self.epoch.is_none() || announce.route.epoch == self.epoch; + self.routed = self.identity.is_none() || announce.route.identity == self.identity; } Poll::Ready(None) => return Poll::Ready(Err(Error::Closed)), Poll::Pending => break, @@ -6474,7 +6145,7 @@ impl Requesting { self.watch = Some((watch, seen)); self.request = Some( self.consumer - .lookup(self.path.clone(), self.epoch.as_ref()) + .lookup(self.path.clone(), self.identity.as_ref()) .into_inner(), ); } @@ -6938,7 +6609,7 @@ impl Consumer { } /// Resolve a routed instance, returning a selected handler's refusal directly. - pub(super) fn routing(&self, path: Path, epoch: Option) -> Requesting { + pub(super) fn routing(&self, path: Path, identity: Option) -> Requesting { // The scope keeps narrower permissions intact: if the whole path is not // reachable, no route can ever cover it, so bail rather than loop forever. let request = match self.root.join(&path) { @@ -6952,33 +6623,32 @@ impl Consumer { interest: None, watch: None, request, - epoch, + identity, discovery: None, routed: false, } } - /// Follow the broadcast at `path` across the publisher instances that serve it. + /// Follow the broadcast at `path` across the identities that serve it. /// - /// The first [`available`](Following::poll_available) resolves the instance serving the + /// The first [`available`](Following::poll_available) resolves the identity serving the /// path once one is announced, retrying route churn but returning a selected handler's - /// refusal. Each later call waits for another instance to take over (an + /// refusal. Each later call waits for another identity to take over (an /// [`AnnounceEvent::Restart`]), or for the path to be served again after an - /// [`AnnounceEvent::End`], even by the same instance. Drop what was read from the - /// previous instance then: its tracks end with an error once its routes go. + /// [`AnnounceEvent::End`], even by the same identity. Drop what was read from the + /// previous broadcast then: its tracks end with an error once its routes go. /// - /// Pass an owned [`crate::Epoch`] to follow only that instance: another one taking - /// over is never handed out, and the instance is handed out again if it returns after - /// an end. Fails with [`Error::Unauthorized`] when this consumer's scope can never + /// Pass an owned [`Identity`] to follow only that stream: another one taking over is + /// never handed out, and the identity is handed out again if it returns after an end. Fails with [`Error::Unauthorized`] when this consumer's scope can never /// cover the path, and with [`Error::InvalidPath`] when no pattern can spell it. pub fn follow( &self, path: impl TryInto>, - epoch: impl Into>, + identity: impl Into>, ) -> Result { let path = path::convert(path)?; let routes = self.routes(&path)?; - Ok(Following::new(self.clone(), path, epoch.into(), routes)) + Ok(Following::new(self.clone(), path, identity.into(), routes)) } /// Returns a consumer rooted at `root` and restricted to matching `patterns`. @@ -6998,40 +6668,39 @@ impl Consumer { }) } - /// Resolve a broadcast by exact path, optionally pinning its publisher epoch. + /// Resolve a broadcast by exact path, optionally pinning its [`Identity`]. /// /// Returns a [`kio::Pending`] future, mirroring /// [`track::Consumer::fetch_group`](track::Consumer::fetch_group). Every /// path resolves through a front the origin's [`Driver`] runs: the request /// mints one or joins the one already serving the path, and the front picks - /// the best announced route covering it (the most specific prefix, then the - /// newest [`Route::epoch`], then the preferred link ([`Producer::with_preference`]), - /// then the cheapest, a broadcast published on this - /// origin winning ties) and materializes it, from the broadcast itself or from - /// the peer that announced the route. + /// one identity's route covering it and materializes it, from the broadcast itself + /// or from the peer that announced the route. The order is the most specific prefix, + /// then, within one identity, only its highest epoch is eligible, cheaper or not. + /// Among identities it is the preferred link ([`Producer::with_preference`]), then + /// the cheapest, then a broadcast published on this origin, then the shortest hop + /// chain, then a hash of the identity and the path, which every relay agrees on and + /// which spreads a pool's paths over its hosts. /// - /// When its serving source dies or a better route with the same epoch appears, - /// the front switches to it, invisibly to subscribers: the epoch says both serve - /// the same bytes. A route without an epoch is never swapped for another, nor - /// asked again after a track fails, since it may now resolve another instance: - /// the track ends with the error. Its route retracting with no + /// When its serving source dies or a better route of the same identity appears, + /// including a higher epoch, the front switches to it, invisibly to subscribers: the + /// identity promises one continuous sequence space. Its routes retracting with no /// replacement ends the broadcast, and the next request re-serves the path; /// tracks already in flight carry on to their own end. The broadcast also ends /// once no handle holds it and none of its tracks has been read for a linger, /// and the next request re-serves the path then too. /// - /// Another instance winning the path (a newer epoch, or another route without - /// one) leaves the broadcast to its readers until its routes go, while the next - /// request resolves the winner on a fresh broadcast. [`Consumer::announced`] - /// reports that as [`AnnounceEvent::Restart`], so readers know to request again. + /// Another identity winning the path leaves the broadcast to its readers until its + /// routes go, while the next request resolves the winner on a fresh broadcast. + /// [`Consumer::announced`] reports that as [`AnnounceEvent::Restart`], and + /// [`Self::follow`] yields the next broadcast. /// - /// Pass an owned [`crate::Epoch`] to refuse a different publisher instance, both - /// when requesting and when an asynchronous answer resolves. `None` leaves lookup - /// unpinned. A pinned request never resumes on a route whose epoch disappeared. + /// Pass an owned [`Identity`] to refuse a different stream, both when requesting + /// and when an asynchronous answer resolves. `None` leaves lookup unpinned. /// /// The request owns discovery for its path and retries when its covering routes change. /// It reports [`Error::Unroutable`] once discovery has synced without a covering route - /// for the requested instance. A selected handler's explicit refusal is returned as-is. + /// for the requested identity. A selected handler's explicit refusal is returned as-is. /// A broadcast resolved through a discovered route keeps /// its path watched until dropped; no separate announcement watcher is needed. /// A route claims capability, not inventory: resolving a covered path @@ -7040,13 +6709,13 @@ impl Consumer { pub fn request( &self, path: impl TryInto>, - epoch: impl Into>, + identity: impl Into>, ) -> kio::Pending { let path = match path::convert(path) { Ok(path) => path, Err(err) => return kio::Pending::new(self.failed(err)), }; - let mut routing = self.routing(path.clone(), epoch.into()); + let mut routing = self.routing(path.clone(), identity.into()); match self.routes(&path) { Ok(discovery) => { routing.discovery = Some(discovery); @@ -7058,7 +6727,7 @@ impl Consumer { routing.watch = Some((watch, seen)); } // A requests handler hears an already-routed request at creation time. - routing.request = Some(self.lookup(path, routing.epoch.as_ref()).into_inner()); + routing.request = Some(self.lookup(path, routing.identity.as_ref()).into_inner()); } Err(err) => routing.request = Some(Lookup::failed(err)), } @@ -7067,8 +6736,8 @@ impl Consumer { /// The routes as they stand now, without discovery or retry: what a session answers a /// peer's request with. Refused with [`Error::Unroutable`] unless the winning route serves - /// `epoch`: a peer asking for one publisher instance must not be handed another. - pub(crate) fn lookup(&self, path: Path, epoch: Option<&crate::Epoch>) -> kio::Pending { + /// `identity`: a peer asking for one stream must not be handed another. + pub(crate) fn lookup(&self, path: Path, identity: Option<&Identity>) -> kio::Pending { // The path as this handle names it, absolute: what its scope authorizes and // what its egress is counted under, so a read through a mount bills where // the reader asked. @@ -7105,11 +6774,27 @@ impl Consumer { // Nothing serves the path: no announced broadcast and no served route. // Checked before joining a front, so a front still draining after its // route retracted takes no newcomers. - let (best, takeover) = match state.best_route(&absolute, horizon, &HashSet::new(), |_| true) { - Some(best) if epoch.is_none_or(|epoch| best.epoch.as_ref() == Some(epoch)) => { - (best.instance(), best.takeover.clone()) + let Some((winner, takeover)) = state + .best_route(&absolute, horizon, &HashSet::new(), |_| true) + .map(|best| (best.identity.clone(), best.takeover.clone())) + else { + return kio::Pending::new(Lookup::failed(Error::Unroutable)); + }; + // A request naming another identity than the winner follows that identity's own + // routes: every hop resolves the identity the first one chose. + let pin = identity.filter(|identity| **identity != winner).cloned(); + // Only the winner's takeover can continue a front this request would join. + let takeover = takeover.filter(|_| pin.is_none()); + let best = match &pin { + None => winner, + Some(pin) + if state + .best_route(&absolute, horizon, &HashSet::new(), |entry| entry.identity == *pin) + .is_some() => + { + pin.clone() } - _ => return kio::Pending::new(Lookup::failed(Error::Unroutable)), + Some(_) => return kio::Pending::new(Lookup::failed(Error::Unroutable)), }; // Join the live front for this path and horizon, if any: its watcher @@ -7119,7 +6804,7 @@ impl Consumer { // request mints a fresh front for the winner rather than join the old one. So // does a front still resolving one the table has since beaten: it may already // hold that instance's source, and hand this request an instance it saw lose. - let key = (absolute.clone(), horizon); + let key = (absolute.clone(), horizon, pin.clone()); if let Some(front) = state.fronts.get(&key) { let current = { let pending = front.request.read(); @@ -7138,12 +6823,10 @@ impl Consumer { .then(|| front.broadcast.consume()) .filter(|held| !held.is_closed()); if let Some(held) = held { - state.next_route += 1; - front.joined.store(state.next_route, Ordering::Release); let pending = Lookup::queued(front.request.consume(), held) .with_path(requested) .with_stats(scope) - .with_epoch(epoch.cloned()); + .with_identity(identity.cloned()); return kio::Pending::new(pending); } state.fronts.remove(&key); @@ -7157,7 +6840,7 @@ impl Consumer { pool: self.pool.clone(), cache_duration: self.cache_duration, path: absolute.clone(), - epoch: None, + identity: None, // A front reads its routes' copies, so it writes no frames of its own. frames: Default::default(), }); @@ -7171,14 +6854,11 @@ impl Consumer { // The front starts held by this request; see [`Front::new`]. let held = broadcast.consume(); let watch = state.watch_front(&self.shared, &absolute, horizon, request.clone()); - state.next_route += 1; - let joined = Arc::new(AtomicU64::new(state.next_route)); state.fronts.insert( key, RemoteFront { request: request.clone(), broadcast: held.weak(), - joined: joined.clone(), }, ); // Released before the push: a set whose handles are gone drops the task, @@ -7190,9 +6870,9 @@ impl Consumer { broadcast, path: absolute, horizon, + pin, watch, request, - joined, timers: self.timers.clone(), }, self.tasks.clone(), @@ -7201,7 +6881,7 @@ impl Consumer { Lookup::queued(consumer, held) .with_path(requested) .with_stats(scope) - .with_epoch(epoch.cloned()), + .with_identity(identity.cloned()), ) } @@ -7386,7 +7066,14 @@ impl AnnounceConsumer { let quarantine = Quarantine { held: Held::Anchoring(now), publisher, - epoch: update.route.epoch.clone(), + instance: ( + update + .route + .identity + .clone() + .expect("a delivered route has an identity"), + update.route.epoch, + ), rank: (update.route.cost, update.route.hops.len()), }; let replaced = self.retracted.insert(update.prefix.clone(), quarantine); @@ -7848,7 +7535,7 @@ mod tests { } /// One publisher instance, shared by every route in a test that resumes across them. - fn epoch() -> crate::Epoch { + fn epoch() -> crate::Identity { "01900000-0000-7000-8000-000000000001".parse().unwrap() } @@ -8785,8 +8472,10 @@ mod tests { /// Pinned so `spreadHash` in `js/net` picks the same pool member for a path. #[moq_clock::test] async fn spread_hash_matches_js() { - assert_eq!(fnv_key("pool/job-0", [origin(10)]), 0xefb5e20a66101c32); - assert_eq!(fnv_key("pool/job-0", [origin(11)]), 0x0eb0a91370ff6653); + assert_eq!(identity_key("pool/job-0", &"a".parse().unwrap()), 0x3df8f75a44f8eefb); + assert_eq!(identity_key("pool/job-0", &"b".parse().unwrap()), 0x3df8f85a44f8f0ae); + assert_eq!(chain_key("pool/job-0", &hops(&[10])), 0xefb5e20a66101c32); + assert_eq!(chain_key("pool/job-0", &hops(&[11])), 0x0eb0a91370ff6653); } /// Equal-cost advertisers of one prefix share its paths: a set of requested @@ -8805,7 +8494,13 @@ mod tests { let _pool: Vec = order .map(|id| { producer - .requests_route("pool", Route::default().with_hops(hops(&[id])).with_cost(3), None) + .requests_route( + "pool", + Route::default() + .with_identity(format!("worker-{id}").parse().unwrap(), 0) + .with_hops(hops(&[id])) + .with_cost(3), None, + ) .unwrap() }) .collect(); @@ -8842,24 +8537,24 @@ mod tests { } } - /// An identical route from a fresh announcement (a reconnect) wins at once. Without - /// an epoch nothing says it serves the same bytes, so consumers restart; with the - /// same epoch it is the same instance, and nothing is delivered. Retracting the stale - /// twin is quiet either way. + /// An identical route from a fresh announcement (a reconnect) wins at once. One that + /// mints its own identity is another stream, so consumers restart; one with the same + /// identity is the same stream, and nothing is delivered. Retracting the stale twin is + /// quiet either way. #[moq_clock::test] - async fn identical_reannounce_restarts_without_an_epoch() { - for epoch in [None, Some(epoch())] { + async fn identical_reannounce_restarts_under_another_identity() { + for identity in [None, Some(epoch())] { let producer = origin(1).produce(); let mut announced = producer.consume().announced(); let mut route = Route::default().with_hops(hops(&[10])); - route.epoch = epoch.clone(); + route.identity = identity.clone(); let old = producer.announce("room", route.clone()).unwrap(); let first = announced.expect_next_active("room").await; assert_eq!(first.hops.as_slice(), hops(&[10]).as_slice()); let _new = producer.announce("room", route).unwrap(); - match epoch { + match identity { None => assert_eq!(announced.expect_next_restarted("room").await.hops, hops(&[10])), Some(_) => announced.assert_next_wait(), } @@ -8909,14 +8604,14 @@ mod tests { } } - /// An epochless replacement from a cluster peer at equal cost and chain length, on a - /// chain whose hash loses, competes with the lingering old route: nothing restarts and + /// A replacement under another identity from a cluster peer at equal cost and chain + /// length, whose hash loses, competes with the lingering old route: nothing restarts and /// requests stay on the old route until it is withdrawn, then consumers restart and a /// re-request lands on the replacement. Every node ranks peer routes alike. #[moq_clock::test] #[cfg(feature = "async")] async fn an_equal_cost_peer_replacement_waits_for_the_old_route() { - // Pinned in `spread_hash_matches_js`: hop 11 hashes below hop 10 for this path. + // Pinned in `spread_hash_matches_js`: identity `a` hashes below `b` for this path. // Restarts are delivered at once: the hold is not what this checks. let producer = Config { update_hold: Duration::ZERO, @@ -8927,12 +8622,22 @@ mod tests { let mut announced = consumer.announced(); let peer = producer.clone().with_link(Link::Peer); let old = peer - .requests_route("pool/job-0", Route::default().with_hops(hops(&[11])), None) + .requests_route( + "pool/job-0", + Route::default() + .with_identity("a".parse().unwrap(), 0) + .with_hops(hops(&[11])), None, + ) .unwrap(); announced.expect_next_active("pool/job-0").await; let new = peer - .requests_route("pool/job-0", Route::default().with_hops(hops(&[10])), None) + .requests_route( + "pool/job-0", + Route::default() + .with_identity("b".parse().unwrap(), 0) + .with_hops(hops(&[10])), None, + ) .unwrap(); announced.assert_next_wait(); @@ -9026,91 +8731,6 @@ mod tests { announced.assert_next_wait(); } - /// Let the fronts react, then expect a restart of `prefix` on `announced`. - #[cfg(feature = "async")] - async fn restarted(announced: &mut AnnounceConsumer, prefix: &str) -> Route { - for _ in 0..20 { - moq_clock::sim::yield_now().await; - } - announced.expect_next_restarted(prefix).await - } - - /// A held path moving restarts a scoped cursor whose own winner stayed, even - /// though the route the cursor cannot see left and so changed the prefix's - /// overall winner. - #[moq_clock::test] - #[cfg(feature = "async")] - async fn a_moved_path_restarts_a_scoped_cursor() { - let (producer, claim) = scoped_pool(); - let consumer = producer.consume(); - let _a = claim("room", "room/video/a", 10, 5); - let b = claim("room", "room/video/b", 11, 9); - let chat = claim("room", "room/chat", 12, 1); - let mut video = consumer - .clone() - .scope("", &scopes(&["room/video"])) - .unwrap() - .covering() - .announced(); - assert_eq!(video.expect_next_active("room").await.cost, Cost::new(5)); - let _sticky = hold(&consumer, "room/video/b", &b).await; - - drop(chat); - let _d = claim("room", "room/video/b", 13, 8); - assert_eq!(restarted(&mut video, "room").await.cost, Cost::new(5)); - quiet(&mut video).await; - } - - /// A requester joining the front after its cursor restarted is not covered by - /// that restart: the path moving afterwards restarts the prefix again. - #[moq_clock::test] - #[cfg(feature = "async")] - async fn a_moved_path_restarts_a_requester_that_joined_after_a_restart() { - let (producer, claim) = scoped_pool(); - let consumer = producer.consume(); - let _w = claim("pool", "pool/other", 10, 2); - let r = claim("pool", "pool/job", 11, 5); - let mut announced = consumer.announced(); - assert_eq!(announced.expect_next_active("pool").await.cost, Cost::new(2)); - let (_answer, sticky) = hold(&consumer, "pool/job", &r).await; - - let _y = claim("pool", "pool/other", 12, 1); - assert_eq!(announced.expect_next_restarted("pool").await.cost, Cost::new(1)); - quiet(&mut announced).await; - // The path still resolves through the front's route, so the re-request joins it. - let joined = consumer.request("pool/job", None).await.unwrap(); - assert!(joined.is_clone(&sticky)); - - let _z = claim("pool", "pool/job", 13, 3); - assert_eq!(restarted(&mut announced, "pool").await.cost, Cost::new(1)); - quiet(&mut announced).await; - } - - /// A path that moved with the prefix's own restart is settled: the prefix's winner - /// moving back to a route the front saw restarts it once, and the front adds none. - #[moq_clock::test] - #[cfg(feature = "async")] - async fn a_moved_path_settled_by_a_restart_stays_settled() { - let (producer, claim) = scoped_pool(); - let consumer = producer.consume(); - let a = claim("room", "room/job", 10, 5); - let _b = claim("room", "room/other", 11, 2); - let mut announced = consumer.announced(); - assert_eq!(announced.expect_next_active("room").await.cost, Cost::new(2)); - let _sticky = hold(&consumer, "room/job", &a).await; - - let c = producer - .requests_route("room", Route::default().with_hops(hops(&[12])).with_cost(1), None) - .unwrap(); - assert_eq!(announced.expect_next_restarted("room").await.cost, Cost::new(1)); - quiet(&mut announced).await; - - c.update_route(Route::default().with_hops(hops(&[12])).with_cost(3)) - .unwrap(); - assert_eq!(announced.expect_next_restarted("room").await.cost, Cost::new(2)); - quiet(&mut announced).await; - } - /// A route from before the request repricing to win both the prefix and the held /// path restarts the prefix once: the cursor's own switch covers the move. #[moq_clock::test] @@ -9292,23 +8912,23 @@ mod tests { let mut announced = producer.consume().announced(); let dynamic = producer - .requests_route("room", Route::default().with_epoch(epoch()).with_cost(5), None) + .requests_route("room", Route::default().with_identity(epoch(), 0).with_cost(5), None) .unwrap(); announced.expect_next_active("room").await; dynamic.update_route(dynamic.route().with_cost(1)).unwrap(); let route = announced.expect_next_active("room").await; - assert_eq!((route.epoch, route.cost), (Some(epoch()), Cost::new(1))); + assert_eq!((route.identity, route.cost), (Some(epoch()), Cost::new(1))); assert_eq!(dynamic.route().cost, Cost::new(1)); let broadcast = producer.create_broadcast("lobby").unwrap(); assert!(broadcast.route().is_none(), "not announced yet"); broadcast - .announce(Route::default().with_epoch(epoch()).with_cost(5)) + .announce(Route::default().with_identity(epoch(), 0).with_cost(5)) .unwrap(); announced.expect_next_active("lobby").await; broadcast.announce(broadcast.route().unwrap().with_cost(1)).unwrap(); let route = announced.expect_next_active("lobby").await; - assert_eq!((route.epoch, route.cost), (Some(epoch()), Cost::new(1))); + assert_eq!((route.identity, route.cost), (Some(epoch()), Cost::new(1))); broadcast.unannounce(); assert!(broadcast.route().is_none(), "withdrawn"); } @@ -9424,15 +9044,17 @@ mod tests { let mut announced = consumer.announced(); let _local = producer - .publish("room/alice", Route::default().with_epoch(epoch()).with_cost(5)) + .publish("room/alice", Route::default().with_identity(epoch(), 0).with_cost(5)) .unwrap(); assert_eq!(announced.expect_next_active("room/alice").await.cost, Cost::new(5)); let server = producer .requests_route( "room/alice", - Route::default().with_epoch(epoch()).with_hops(hops(&[10])).with_cost(1), - None, + Route::default() + .with_identity(epoch(), 0) + .with_hops(hops(&[10])) + .with_cost(1), None, ) .unwrap(); let route = announced.expect_next_active("room/alice").await; @@ -9447,27 +9069,29 @@ mod tests { pending.await.expect("resolves through the cheaper route"); } - /// A resolved broadcast names the epoch of the route it came through, and none - /// when that route has none. + /// A resolved broadcast names the identity of the route it came through: a stable + /// one, or the one its publication minted. #[moq_clock::test] #[cfg(feature = "async")] - async fn a_resolved_broadcast_carries_its_route_epoch() { + async fn a_resolved_broadcast_carries_its_route_identity() { let producer = origin(1).produce(); let consumer = producer.consume(); - let _epoched = producer - .publish("room/alice", Route::default().with_epoch(epoch())) + let _stable = producer + .publish("room/alice", Route::default().with_identity(epoch(), 0)) .unwrap(); - let _plain = producer.publish("room/bob", Route::default()).unwrap(); + let minted = producer.publish("room/bob", Route::default()).unwrap(); let alice = consumer.request("room/alice", None).await.unwrap(); - assert_eq!(alice.info().epoch, Some(epoch())); + assert_eq!(alice.info().identity, Some(epoch())); let bob = consumer.request("room/bob", None).await.unwrap(); - assert_eq!(bob.info().epoch, None); + let identity = minted.route().unwrap().identity; + assert!(identity.is_some()); + assert_eq!(bob.info().identity, identity); } /// A cheaper route that appears after a front was minted takes the front over: it - /// carries the same epoch, so a newcomer joins the front. + /// carries the same identity, so a newcomer joins the front. #[moq_clock::test] #[cfg(feature = "async")] async fn cheaper_route_after_a_front_takes_it_over() { @@ -9475,15 +9099,17 @@ mod tests { let consumer = producer.consume(); let _local = producer - .publish("room/alice", Route::default().with_epoch(epoch()).with_cost(5)) + .publish("room/alice", Route::default().with_identity(epoch(), 0).with_cost(5)) .unwrap(); let first = consumer.request("room/alice", None).await.expect("resolves locally"); let server = producer .requests_route( "room/alice", - Route::default().with_epoch(epoch()).with_hops(hops(&[10])).with_cost(1), - None, + Route::default() + .with_identity(epoch(), 0) + .with_hops(hops(&[10])) + .with_cost(1), None, ) .unwrap(); let request = queued(&server).await; @@ -9586,7 +9212,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let local = producer - .publish("room/alice", Route::default().with_epoch(epoch()).with_cost(5)) + .publish("room/alice", Route::default().with_identity(epoch(), 0).with_cost(5)) .unwrap(); let _a = local.create_track("a", None).unwrap(); let _b = local.create_track("b", None).unwrap(); @@ -9600,8 +9226,10 @@ mod tests { let server = producer .requests_route( "room/alice", - Route::default().with_epoch(epoch()).with_hops(hops(&[10])).with_cost(1), - None, + Route::default() + .with_identity(epoch(), 0) + .with_hops(hops(&[10])) + .with_cost(1), None, ) .unwrap(); let upstream = broadcast::Info::new().produce(); @@ -9666,7 +9294,7 @@ mod tests { let route = |link: u64| { let route = Route::default().with_hops(hops(&[10])); match epochs { - true => route.with_epoch(format!("01900000-0000-7000-8000-{link:012x}").parse().unwrap()), + true => route.with_identity(format!("link-{link}").parse().unwrap(), 0), false => route, } }; @@ -9711,7 +9339,7 @@ mod tests { let route = |link: u64| { Route::default() .with_hops(hops(&[10])) - .with_epoch(format!("01900000-0000-7000-8000-{link:012x}").parse().unwrap()) + .with_identity(format!("link-{link}").parse().unwrap(), 0) }; let producer = origin(1).produce(); let consumer = producer.consume(); @@ -9733,7 +9361,7 @@ mod tests { .expect("a fresh front asked the renewed route") .accept(&renewed); let again = pending.await.unwrap(); - assert_eq!(again.info().epoch, route(2).epoch); + assert_eq!(again.info().identity, route(2).identity); assert!( !again.is_clone(&resolved), "the renewed instance joined the first one's front" @@ -9827,13 +9455,15 @@ mod tests { let server = producer .requests_route( "room/alice", - Route::default().with_epoch(epoch()).with_hops(hops(&[10])).with_cost(2), - None, + Route::default() + .with_identity(epoch(), 0) + .with_hops(hops(&[10])) + .with_cost(2), None, ) .unwrap(); announced.expect_next_active("room/alice").await; let _local = producer - .publish("room/alice", Route::default().with_epoch(epoch()).with_cost(2)) + .publish("room/alice", Route::default().with_identity(epoch(), 0).with_cost(2)) .unwrap(); assert!(announced.expect_next_active("room/alice").await.hops.is_empty()); @@ -10170,15 +9800,15 @@ mod tests { broadcast: broadcast.clone(), path, horizon: Horizon::default(), + pin: None, watch, - // Nothing joins this front, and its first selection overwrites the instance. + // Nothing joins this front, and its first selection overwrites the identity. request: kio::Producer::new(PendingFront { resolved: None, via: None, - selected: Instance::Route(0), + selected: epoch(), refused: false, }), - joined: Arc::default(), timers: consumer.timers.clone(), }; let mut front = std::pin::pin!(serve_front(task)); @@ -10241,8 +9871,7 @@ mod tests { let incumbent = producer .requests_route( "room", - Route::default().with_epoch(epoch()).with_hops(hops(&[10])), - None, + Route::default().with_identity(epoch(), 0).with_hops(hops(&[10])), None, ) .unwrap(); view.expect_next_active("room").await; @@ -10256,8 +9885,7 @@ mod tests { let echo = producer .requests_route( "room", - Route::default().with_epoch(epoch()).with_hops(hops(&[10, 7])), - None, + Route::default().with_identity(epoch(), 0).with_hops(hops(&[10, 7])), None, ) .unwrap(); drop(incumbent); @@ -10289,8 +9917,10 @@ mod tests { let first_server = producer .requests_route( "room/alice", - Route::default().with_epoch(epoch()).with_hops(hops(&[10])).with_cost(5), - None, + Route::default() + .with_identity(epoch(), 0) + .with_hops(hops(&[10])) + .with_cost(5), None, ) .unwrap(); let pending = producer.consume().request("room/alice", None); @@ -10346,7 +9976,7 @@ mod tests { .requests_route( "room/alice", Route::default() - .with_epoch(epoch()) + .with_identity(epoch(), 0) .with_hops(hops(&[10])) .with_cost(cost), None, @@ -10601,7 +10231,7 @@ mod tests { assert_eq!(update.route.cost, Cost::new(1)); announced.assert_next_wait(); - // Without an epoch the survivor is another source. + // Under another identity the survivor is another source. drop(second); assert_eq!(announced.expect_next_restarted("live").await.cost, Cost::new(3)); @@ -11035,7 +10665,7 @@ mod tests { // One epoch, so the winner moving between routes is an update, not a restart. let route = |chain: &[u64], via| { Route::default() - .with_epoch(epoch()) + .with_identity(epoch(), 0) .with_hops(hops(chain)) .with_via(origin(via)) }; @@ -11070,7 +10700,7 @@ mod tests { // One epoch, so the winner moving between routes is an update, not a restart. let route = |chain: &[u64], via| { Route::default() - .with_epoch(epoch()) + .with_identity(epoch(), 0) .with_hops(hops(chain)) .with_via(origin(via)) }; @@ -11125,7 +10755,7 @@ mod tests { // One epoch, so the winner moving between routes is an update, not a restart. let route = |chain: &[u64], via| { Route::default() - .with_epoch(epoch()) + .with_identity(epoch(), 0) .with_hops(hops(chain)) .with_via(origin(via)) }; @@ -11160,7 +10790,13 @@ mod tests { let mut announced = producer.consume().announced(); moq_clock::sleep(Duration::from_millis(1)).await; - let route = |chain: &[u64], via| Route::default().with_hops(hops(chain)).with_via(origin(via)); + // Every path is to one publisher, so to one identity. + let route = |chain: &[u64], via| { + Route::default() + .with_identity(epoch(), 0) + .with_hops(hops(chain)) + .with_via(origin(via)) + }; let near = peer.requests_route("named", route(&[9, 2], 2), None).unwrap(); announced.expect_next_active("named").await; @@ -11246,28 +10882,25 @@ mod tests { assert!(producer.shared.lock().withdrawn.is_empty()); } - /// Overlapping sessions are independent claims, regardless of announcement order. - /// The fallback to the older session is another source, so it restarts. + /// Overlapping sessions carrying one identity are independent claims, regardless of + /// announcement order, and falling back from one to the other is the same stream. #[moq_clock::test] async fn old_session_withdrawal_keeps_newer_route() { - for restart in [false, true] { + for old_first in [false, true] { let producer = origin(1).produce(); let peer = producer.clone().with_link(Link::Peer); let mut announced = producer.consume().announced(); - let route = Route::default().with_hops(hops(&[9, 2])); + let route = Route::default().with_identity(epoch(), 0).with_hops(hops(&[9, 2])); let first = peer.requests_route("room", route.clone(), None).unwrap(); let second = peer.requests_route("room", route.clone(), None).unwrap(); announced.expect_next_active("room").await; announced.assert_next_wait(); - let remaining = if restart { - // The older table entry has the newest advertisement after a restart. - first.update_route(route).unwrap(); - second.withdrawn(); - announced.expect_next_restarted("room").await; - first - } else { + let remaining = if old_first { first.withdrawn(); second + } else { + second.withdrawn(); + first }; announced.assert_next_wait(); assert!(producer.shared.lock().withdrawn.is_empty()); @@ -11312,7 +10945,7 @@ mod tests { let mut announced = producer.consume().announced(); let route = Route::default() - .with_epoch(epoch()) + .with_identity(epoch(), 0) .with_hops(hops(&[7])) .with_via(origin(7)); let _forwarded = peer.requests_route("room", route.clone(), None).unwrap(); @@ -11478,28 +11111,32 @@ mod tests { #[moq_clock::test] #[cfg(feature = "async")] - async fn claim_answer_is_hidden_until_published_with_its_epoch() { + async fn claim_answer_is_hidden_until_published_with_its_identity() { let producer = origin(1).produce(); let consumer = producer.consume(); let claim = producer.requests("room").unwrap(); - let epoch = crate::Epoch::mint(); let mut pending = Box::pin(consumer.request("room/alice", None)); assert!((&mut pending).now_or_never().is_none()); - let prepared = queued(&claim).await.prepare(epoch.clone()).unwrap(); + let prepared = queued(&claim).await.prepare().unwrap(); let _track = prepared .broadcast() .create_track("video", crate::track::Info::timed()) .unwrap(); assert!((&mut pending).now_or_never().is_none()); - let _published = prepared.publish().unwrap(); + let published = prepared.publish().unwrap(); let resolved = pending.await.unwrap(); - assert_eq!(resolved.info().epoch.as_ref(), Some(&epoch)); + assert_eq!(resolved.info().identity, published.route().unwrap().identity); + assert_ne!( + resolved.info().identity, + claim.route().identity, + "a job is a stream of its own" + ); assert!(resolved.track("video").is_ok()); } - /// Only `publish` announces a prepared answer, under its epoch and after admission. + /// Only `publish` announces a prepared answer, under its identity and after admission. #[moq_clock::test] #[cfg(feature = "async")] async fn a_prepared_answer_cannot_announce_itself() { @@ -11508,18 +11145,18 @@ mod tests { let claim = producer.requests("room").unwrap(); announced.assert_next_active("room"); let pending = producer.consume().request("room/alice", None); - let epoch = crate::Epoch::mint(); - let prepared = queued(&claim).await.prepare(epoch.clone()).unwrap(); + let prepared = queued(&claim).await.prepare().unwrap(); let answer = prepared.broadcast().clone(); - let other = Route::default().with_epoch(crate::Epoch::mint()); + let other = Route::default().with_identity(crate::Identity::random(), 0); assert!(matches!(answer.announce(other), Err(Error::Closed))); assert!(answer.route().is_none()); announced.assert_next_wait(); let published = prepared.publish().unwrap(); - assert_eq!(published.route().and_then(|route| route.epoch), Some(epoch.clone())); - assert_eq!(pending.await.unwrap().info().epoch.as_ref(), Some(&epoch)); + let identity = published.route().and_then(|route| route.identity); + assert!(identity.is_some()); + assert_eq!(pending.await.unwrap().info().identity, identity); } /// Announcement control follows the origin role while fill clones retain publication lifetime. @@ -11532,12 +11169,16 @@ mod tests { let publication = producer.create_broadcast("room/alice").unwrap(); let fill = publication.broadcast().clone(); let control = publication.clone(); - publication.announce(Route::default().with_epoch(epoch())).unwrap(); + publication + .announce(Route::default().with_identity(epoch(), 0)) + .unwrap(); events.expect_next_active("room/alice").await; control.unannounce(); events.expect_next_ended("room/alice").await; assert!(publication.route().is_none()); - publication.announce(Route::default().with_epoch(epoch())).unwrap(); + publication + .announce(Route::default().with_identity(epoch(), 0)) + .unwrap(); events.expect_next_active("room/alice").await; drop(control); drop(publication); @@ -11554,17 +11195,20 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let old = producer - .publish("room/alice", Route::default().with_epoch(epoch())) + .publish("room/alice", Route::default().with_identity(epoch(), 0)) .unwrap(); - let replacement_epoch: crate::Epoch = "01900000-0000-7000-8000-000000000002".parse().unwrap(); + let replacement_epoch: crate::Identity = "01900000-0000-7000-8000-000000000002".parse().unwrap(); let replacement = producer - .publish("room/alice", Route::default().with_epoch(replacement_epoch.clone())) + .publish( + "room/alice", + Route::default().with_identity(replacement_epoch.clone(), 0), + ) .unwrap(); old.unannounce(); old.close(); - assert_eq!(replacement.route().unwrap().epoch, Some(replacement_epoch.clone())); + assert_eq!(replacement.route().unwrap().identity, Some(replacement_epoch.clone())); let resolved = consumer.request("room/alice", replacement_epoch.clone()).await.unwrap(); - assert_eq!(resolved.info().epoch.as_ref(), Some(&replacement_epoch)); + assert_eq!(resolved.info().identity.as_ref(), Some(&replacement_epoch)); } /// A refused answer keeps its announcer withheld, so a clone cannot announce it later. @@ -11574,7 +11218,7 @@ mod tests { let producer = origin(1).produce(); let claim = producer.requests("room").unwrap(); let _pending = producer.consume().request("room/alice", None); - let prepared = queued(&claim).await.prepare(crate::Epoch::mint()).unwrap(); + let prepared = queued(&claim).await.prepare().unwrap(); let answer = prepared.broadcast().clone(); drop(claim); @@ -11589,7 +11233,7 @@ mod tests { let consumer = producer.consume(); let claim = producer.requests("room").unwrap(); let pending = consumer.request("room/alice", None); - let prepared = queued(&claim).await.prepare(crate::Epoch::mint()).unwrap(); + let prepared = queued(&claim).await.prepare().unwrap(); drop(prepared); assert!(matches!(pending.await, Err(Error::Unroutable))); } @@ -11603,7 +11247,7 @@ mod tests { let claim = producer.requests("room").unwrap(); announced.assert_next_active("room"); let _pending = producer.consume().request("room/alice", None); - let prepared = queued(&claim).await.prepare(crate::Epoch::mint()).unwrap(); + let prepared = queued(&claim).await.prepare().unwrap(); drop(claim); announced.assert_next_ended("room"); @@ -11611,7 +11255,7 @@ mod tests { announced.assert_next_wait(); } - /// An answer prepared before the claim changed instance is refused, never announced. + /// An answer prepared before the claim changed identity is refused, never announced. #[moq_clock::test] #[cfg(feature = "async")] async fn a_prepared_answer_for_a_released_request_is_never_announced() { @@ -11620,10 +11264,10 @@ mod tests { let claim = producer.requests("room").unwrap(); announced.assert_next_active("room"); let _pending = producer.consume().request("room/alice", None); - let prepared = queued(&claim).await.prepare(crate::Epoch::mint()).unwrap(); + let prepared = queued(&claim).await.prepare().unwrap(); claim - .update_route(Route::default().with_epoch(crate::Epoch::mint())) + .update_route(Route::default().with_identity(crate::Identity::random(), 0)) .unwrap(); announced.assert_next_restarted("room"); assert!(matches!(prepared.publish(), Err(Error::Unroutable))); @@ -11646,7 +11290,7 @@ mod tests { let pending = consumer.request(path, None); let published = queued(&claim) .await - .prepare(crate::Epoch::mint()) + .prepare() .unwrap() .publish() .unwrap(); @@ -11672,25 +11316,25 @@ mod tests { announced.assert_next_wait(); } - /// A request pinned to an epoch resolves only through the answer published under it, - /// never by asking the claim, which has no epoch of its own. + /// A request pinned to an identity resolves only through the answer published under + /// it, never by asking the claim, which serves another identity. #[moq_clock::test] #[cfg(feature = "async")] - async fn a_pinned_request_resolves_only_through_the_published_epoch() { + async fn a_pinned_request_resolves_only_through_the_published_identity() { let producer = origin(1).produce(); let consumer = producer.consume(); let claim = producer.requests("room").unwrap(); - let epoch = crate::Epoch::mint(); - let unknown = consumer.request("room/alice", crate::Epoch::mint()); + let unknown = consumer.request("room/alice", crate::Identity::random()); assert!(matches!(unknown.await, Err(Error::Unroutable))); let pending = consumer.request("room/alice", None); - let _published = queued(&claim).await.prepare(epoch.clone()).unwrap().publish().unwrap(); + let published = queued(&claim).await.prepare().unwrap().publish().unwrap(); + let epoch = published.route().unwrap().identity.unwrap(); pending.await.expect("resolves"); let pinned = consumer.request("room/alice", epoch.clone()).await; - assert_eq!(pinned.expect("resolves").info().epoch.as_ref(), Some(&epoch)); - let stale = consumer.request("room/alice", crate::Epoch::mint()); + assert_eq!(pinned.expect("resolves").info().identity.as_ref(), Some(&epoch)); + let stale = consumer.request("room/alice", crate::Identity::random()); assert!(matches!(stale.await, Err(Error::Unroutable))); } @@ -12131,8 +11775,7 @@ mod tests { let server = producer .requests_route( "room", - Route::default().with_epoch(epoch()).with_hops(hops(first)), - None, + Route::default().with_identity(epoch(), 0).with_hops(hops(first)), None, ) .unwrap(); @@ -12178,8 +11821,7 @@ mod tests { self.producer .requests_route( "room", - Route::default().with_epoch(epoch()).with_hops(hops(first)), - None, + Route::default().with_identity(epoch(), 0).with_hops(hops(first)), None, ) .unwrap() } @@ -12215,6 +11857,70 @@ mod tests { assert_eq!(&frame.payload[..], b"resumed"); } + /// Only an identity's highest epoch is eligible: a costlier route at a higher epoch + /// takes the front over from the cheaper older one still standing, and the reader + /// continues across the epoch change without a seam, since the identity's sequences + /// continue. A new request joins the same broadcast. + #[moq_clock::test] + #[cfg(feature = "async")] + async fn a_higher_epoch_supersedes_a_cheaper_route_and_the_reader_continues() { + let (mut rig, _incumbent, _source) = ResumeRig::new(&[10]).await; + let newer = rig + .producer + .requests_route( + "room", + Route::default() + .with_identity(epoch(), 1) + .with_hops(hops(&[11])) + .with_cost(9), None, + ) + .unwrap(); + assert_resumes(&mut rig, &newer).await; + let joined = rig.producer.consume().request("room/alice", None).await.unwrap(); + assert!( + joined.is_clone(&rig.resolved), + "a newcomer joins the continued broadcast" + ); + } + + /// A pool host that is full withdraws its route: a path it won moves to another host's + /// identity, so its broadcast ends and `follow` yields the other host's. A path the + /// other host won stays where it is. + #[moq_clock::test] + #[cfg(feature = "async")] + async fn a_full_pool_host_withdraws_its_paths() { + let producer = origin(1).produce(); + let consumer = producer.consume(); + let host = |name: &str, hop| { + producer + .requests_route( + "pool", + Route::default() + .with_identity(name.parse().unwrap(), 0) + .with_hops(hops(&[hop])), None, + ) + .unwrap() + }; + // Pinned in `spread_hash_matches_js`: `a` wins `pool/job-0`. + let a = host("a", 10); + let b = host("b", 11); + + let mut follow = consumer.follow("pool/job-0", None).unwrap(); + let (first, ()) = futures::join!(follow.available(), async { + queued(&a).await.accept(broadcast::Info::new().produce()); + }); + let first = first.unwrap(); + assert_eq!(first.info().identity, Some("a".parse().unwrap())); + + drop(a); + let (second, ()) = futures::join!(follow.available(), async { + queued(&b).await.accept(broadcast::Info::new().produce()); + }); + let second = second.unwrap(); + assert_eq!(second.info().identity, Some("b".parse().unwrap())); + assert!(!second.is_clone(&first)); + } + /// Write one single-frame group and read it back through `subscription`. #[cfg(feature = "async")] async fn deliver(track: &track::Producer, subscription: &mut track::Subscriber, payload: &'static [u8]) { @@ -12225,12 +11931,11 @@ mod tests { assert_eq!(&group.read_frame().await.unwrap().unwrap().payload[..], payload); } - /// Without an epoch nothing says another route serves the same bytes: a better - /// route does not move the subscription, and when its own route goes it ends - /// rather than resuming elsewhere. + /// Another identity is another stream: a better route under one does not move the + /// subscription, and when its own route goes it ends rather than resuming elsewhere. #[moq_clock::test] #[cfg(feature = "async")] - async fn a_route_without_an_epoch_never_resumes_elsewhere() { + async fn another_identity_never_resumes_the_subscription() { let producer = origin(1).produce(); let consumer = producer.consume(); let first = producer @@ -12263,17 +11968,19 @@ mod tests { ); } - /// A newer publisher at the path wins new requests even when it costs more, while - /// the subscriptions on the old one stay with it until its route goes. + /// Another identity winning the path takes new requests, while the subscriptions on + /// the old one stay with it until its route goes. #[moq_clock::test] #[cfg(feature = "async")] - async fn a_newer_epoch_leaves_the_broadcast_in_flight() { + async fn another_identity_leaves_the_broadcast_in_flight() { let producer = origin(1).produce(); let consumer = producer.consume(); let old = producer .publish( "room/alice", - Route::default().with_epoch(crate::Epoch::mint()).with_cost(1), + Route::default() + .with_identity(crate::Identity::random(), 0) + .with_cost(9), ) .unwrap(); let old_track = old.create_track("video", crate::track::Info::timed()).unwrap(); @@ -12284,7 +11991,9 @@ mod tests { let new = producer .publish( "room/alice", - Route::default().with_epoch(crate::Epoch::mint()).with_cost(9), + Route::default() + .with_identity(crate::Identity::random(), 0) + .with_cost(1), ) .unwrap(); let new_track = new.create_track("video", crate::track::Info::timed()).unwrap(); @@ -12314,21 +12023,21 @@ mod tests { async fn a_request_racing_a_newer_epoch_skips_the_old_front() { let producer = origin(1).produce(); let consumer = producer.consume(); - let first = crate::Epoch::mint(); + let first = crate::Identity::random(); let old = producer - .publish("room/alice", Route::default().with_epoch(first.clone())) + .publish("room/alice", Route::default().with_identity(first.clone(), 0)) .unwrap(); let _old_track = old.create_track("video", crate::track::Info::timed()).unwrap(); let resolved = consumer.request("room/alice", None).await.expect("resolves"); assert_eq!( - resolved.info().epoch.as_ref(), + resolved.info().identity.as_ref(), Some(&first), "an unpinned request names what it resolved" ); - let epoch = crate::Epoch::mint(); + let epoch = crate::Identity::random(); let new = producer - .publish("room/alice", Route::default().with_epoch(epoch.clone())) + .publish("room/alice", Route::default().with_identity(epoch.clone(), 0)) .unwrap(); let new_track = new.create_track("video", crate::track::Info::timed()).unwrap(); let named = consumer.request("room/alice", epoch.clone()); @@ -12343,7 +12052,7 @@ mod tests { assert!(!bare.is_clone(&resolved), "a bare request joined the replaced front"); assert!(named.is_clone(&bare), "both share the new front"); assert_eq!( - bare.info().epoch.as_ref(), + bare.info().identity.as_ref(), Some(&epoch), "an unpinned request names the new epoch" ); @@ -12359,14 +12068,17 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let old = producer - .requests_route("room", Route::default().with_epoch(crate::Epoch::mint()), None) + .requests_route("room", Route::default().with_identity(crate::Identity::random(), 0), None) .unwrap(); let pending = consumer.request("room/alice", None); let request = queued(&old).await; // The answer and the newer epoch land in the same tick. let new = producer - .publish("room/alice", Route::default().with_epoch(crate::Epoch::mint())) + .publish( + "room/alice", + Route::default().with_identity(crate::Identity::random(), 0), + ) .unwrap(); let new_track = new.create_track("video", crate::track::Info::timed()).unwrap(); let stale = broadcast::Info::new().produce(); @@ -12387,9 +12099,9 @@ mod tests { async fn held_request_across_an_epoch_change(answer_first: bool) { let producer = origin(1).produce(); let consumer = producer.consume(); - let old = crate::Epoch::mint(); + let old = crate::Identity::random(); let dynamic = producer - .requests_route("room", Route::default().with_epoch(old.clone()), None) + .requests_route("room", Route::default().with_identity(old.clone(), 0), None) .unwrap(); let pending = consumer.request("room/alice", None); let pinned = consumer.request("room/alice", old); @@ -12397,12 +12109,16 @@ mod tests { let stale = broadcast::Info::new().produce(); let _stale_track = stale.create_track("video", crate::track::Info::timed()).unwrap(); - let epoch = crate::Epoch::mint(); + let epoch = crate::Identity::random(); if answer_first { request.accept(&stale); - dynamic.update_route(dynamic.route().with_epoch(epoch.clone())).unwrap(); + dynamic + .update_route(dynamic.route().with_identity(epoch.clone(), 0)) + .unwrap(); } else { - dynamic.update_route(dynamic.route().with_epoch(epoch.clone())).unwrap(); + dynamic + .update_route(dynamic.route().with_identity(epoch.clone(), 0)) + .unwrap(); request.accept(&stale); } @@ -12411,7 +12127,7 @@ mod tests { let fresh_track = fresh.create_track("video", crate::track::Info::timed()).unwrap(); request.accept(&fresh); let resolved = pending.await.expect("the request carries over to the new instance"); - assert_eq!(resolved.info().epoch.as_ref(), Some(&epoch)); + assert_eq!(resolved.info().identity.as_ref(), Some(&epoch)); assert!( matches!(pinned.await, Err(Error::Unroutable)), "a request pinned to the old epoch resolved" @@ -12445,18 +12161,20 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let dynamic = producer - .requests_route("room", Route::default().with_epoch(crate::Epoch::mint()), None) + .requests_route("room", Route::default().with_identity(crate::Identity::random(), 0), None) .unwrap(); let pending = consumer.request("room/alice", None); queued(&dynamic).await.reject(Error::NotFound); - let epoch = crate::Epoch::mint(); - dynamic.update_route(dynamic.route().with_epoch(epoch.clone())).unwrap(); + let epoch = crate::Identity::random(); + dynamic + .update_route(dynamic.route().with_identity(epoch.clone(), 0)) + .unwrap(); let fresh = broadcast::Info::new().produce(); let fresh_track = fresh.create_track("video", crate::track::Info::timed()).unwrap(); queued(&dynamic).await.accept(&fresh); let resolved = pending.await.expect("the request carries over"); - assert_eq!(resolved.info().epoch.as_ref(), Some(&epoch)); + assert_eq!(resolved.info().identity.as_ref(), Some(&epoch)); let mut subscription = resolved.track("video").unwrap().subscribe(None).await.unwrap(); deliver(&fresh_track, &mut subscription, b"new").await; } @@ -12468,17 +12186,19 @@ mod tests { async fn a_request_held_across_an_epoch_round_trip_is_asked_again() { let producer = origin(1).produce(); let consumer = producer.consume(); - let old = crate::Epoch::mint(); + let old = crate::Identity::random(); let dynamic = producer - .requests_route("room", Route::default().with_epoch(old.clone()), None) + .requests_route("room", Route::default().with_identity(old.clone(), 0), None) .unwrap(); let pending = consumer.request("room/alice", None); let request = queued(&dynamic).await; dynamic - .update_route(dynamic.route().with_epoch(crate::Epoch::mint())) + .update_route(dynamic.route().with_identity(crate::Identity::random(), 0)) + .unwrap(); + dynamic + .update_route(dynamic.route().with_identity(old.clone(), 0)) .unwrap(); - dynamic.update_route(dynamic.route().with_epoch(old.clone())).unwrap(); let stale = broadcast::Info::new().produce(); let _stale_track = stale.create_track("video", crate::track::Info::timed()).unwrap(); request.accept(&stale); @@ -12487,76 +12207,43 @@ mod tests { let fresh_track = fresh.create_track("video", crate::track::Info::timed()).unwrap(); queued(&dynamic).await.accept(&fresh); let resolved = pending.await.expect("the request carries over"); - assert_eq!(resolved.info().epoch.as_ref(), Some(&old)); + assert_eq!(resolved.info().identity.as_ref(), Some(&old)); let mut subscription = resolved.track("video").unwrap().subscribe(None).await.unwrap(); deliver(&fresh_track, &mut subscription, b"new").await; } - /// A route that gains an epoch and then drops it serves a third instance, not the - /// first: a request after the round trip is asked again rather than joining the - /// front still serving the first instance's subscribers. - #[moq_clock::test] - #[cfg(feature = "async")] - async fn losing_an_epoch_is_another_instance() { - let producer = origin(1).produce(); - let consumer = producer.consume(); - let dynamic = producer.requests_route("room", Route::default(), None).unwrap(); - let pending = consumer.request("room/alice", None); - let old = broadcast::Info::new().produce(); - let _old_track = old.create_track("video", crate::track::Info::timed()).unwrap(); - queued(&dynamic).await.accept(&old); - let resolved = pending.await.expect("resolves"); - - dynamic - .update_route(dynamic.route().with_epoch(crate::Epoch::mint())) - .unwrap(); - let mut route = dynamic.route(); - route.epoch = None; - dynamic.update_route(route).unwrap(); - - let retry = consumer.request("room/alice", None); - let fresh = broadcast::Info::new().produce(); - let _fresh_track = fresh.create_track("video", crate::track::Info::timed()).unwrap(); - queued(&dynamic).await.accept(&fresh); - let replaced = retry.await.expect("the new instance resolves"); - assert!(!replaced.is_clone(&resolved), "rejoined the first instance's front"); - } - - /// Announce cursors follow the newest epoch over a cheaper one, and deliver a - /// change of epoch as a restart: a different broadcast. + /// Announce cursors follow an identity's newest epoch over a cheaper older one, and + /// deliver the change as an update: the same stream, continued. #[moq_clock::test] #[cfg(feature = "async")] - async fn the_newest_epoch_is_announced_as_a_new_broadcast() { + async fn the_newest_epoch_is_announced_in_place() { let producer = origin(1).produce(); let mut announced = producer.consume().announced(); + let identity = crate::Identity::random(); let _old = producer .publish( "room/alice", - Route::default().with_epoch(crate::Epoch::mint()).with_cost(1), + Route::default().with_identity(identity.clone(), 1).with_cost(1), ) .unwrap(); - let old = announced - .expect_next_active("room/alice") - .await - .epoch - .expect("announced"); + assert_eq!(announced.expect_next_active("room/alice").await.cost, Cost::new(1)); let new = producer .publish( "room/alice", - Route::default().with_epoch(crate::Epoch::mint()).with_cost(9), + Route::default().with_identity(identity.clone(), 2).with_cost(9), ) .unwrap(); - let newest = announced - .expect_next_restarted("room/alice") - .await - .epoch - .expect("announced"); - assert!(newest > old); + let newest = announced.expect_next_active("room/alice").await; + assert_eq!( + (newest.identity, newest.epoch, newest.cost), + (Some(identity.clone()), 2, Cost::new(9)) + ); // The older one still stands, so it comes back once the newer one goes. drop(new); - assert_eq!(announced.expect_next_restarted("room/alice").await.epoch, Some(old)); + let back = announced.expect_next_active("room/alice").await; + assert_eq!((back.identity, back.epoch), (Some(identity), 1)); } /// A peer naming one publisher instance is never handed another. @@ -12566,27 +12253,17 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let _broadcast = producer - .publish("room/alice", Route::default().with_epoch(epoch())) + .publish("room/alice", Route::default().with_identity(epoch(), 0)) .unwrap(); - let other: crate::Epoch = "01900000-0000-7000-8000-000000000002".parse().unwrap(); + let other: crate::Identity = "01900000-0000-7000-8000-000000000002".parse().unwrap(); let refused = consumer.request("room/alice", other).await; assert!(matches!(refused, Err(Error::Unroutable))); let named = consumer .request("room/alice", epoch()) .await .expect("the named epoch resolves"); - assert_eq!(named.info().epoch.as_ref(), Some(&epoch())); - } - - /// A route without an epoch resolves a handle without one. - #[moq_clock::test] - #[cfg(feature = "async")] - async fn a_route_without_an_epoch_resolves_without_one() { - let producer = origin(1).produce(); - let _broadcast = producer.publish("room/alice", Route::default()).unwrap(); - let resolved = producer.consume().request("room/alice", None).await.expect("resolves"); - assert_eq!(resolved.info().epoch, None); + assert_eq!(named.info().identity.as_ref(), Some(&epoch())); } /// The driver's completion contract: it resolves once every producer handle @@ -12740,16 +12417,14 @@ mod tests { let incumbent = producer .requests_route( "room", - Route::default().with_epoch(epoch()).with_hops(hops(&[10])), - None, + Route::default().with_identity(epoch(), 0).with_hops(hops(&[10])), None, ) .unwrap(); // The same publisher, reached through the requesting peer. let echo = producer .requests_route( "room", - Route::default().with_epoch(epoch()).with_hops(hops(&[10, 7])), - None, + Route::default().with_identity(epoch(), 0).with_hops(hops(&[10, 7])), None, ) .unwrap(); @@ -12947,7 +12622,7 @@ mod tests { incumbent .update_route( Route::default() - .with_epoch(epoch()) + .with_identity(epoch(), 0) .with_hops(hops(&[10])) .with_cost(Cost::DRAIN), ) @@ -12960,7 +12635,7 @@ mod tests { drop(source); } - /// A second broadcast at the path without an epoch is another instance: new + /// A second broadcast at the path under another identity is another instance: new /// requests resolve it on a fresh front, rather than join the first one's. #[moq_clock::test] #[cfg(feature = "async")] @@ -13402,7 +13077,7 @@ mod tests { let consumer = producer.consume(); let first = producer - .publish("room/alice", Route::default().with_epoch(epoch())) + .publish("room/alice", Route::default().with_identity(epoch(), 0)) .unwrap(); let track = first.create_track("video", crate::track::Info::timed()).unwrap(); let resolved = consumer.request("room/alice", None).await.expect("resolves"); @@ -13420,7 +13095,7 @@ mod tests { // The newest source is dispatched the track, and refused for its metadata. let second = producer - .publish("room/alice", Route::default().with_epoch(epoch())) + .publish("room/alice", Route::default().with_identity(epoch(), 0)) .unwrap(); let _incompatible = second .create_track("video", track::Info::default().with_timescale(crate::Timescale::MICRO)) @@ -13456,8 +13131,7 @@ mod tests { let server = producer .requests_route( "room", - Route::default().with_epoch(epoch()).with_hops(hops(&[10])), - None, + Route::default().with_identity(epoch(), 0).with_hops(hops(&[10])), None, ) .unwrap(); let pending = producer.consume().request("room/alice", None); @@ -13484,7 +13158,7 @@ mod tests { let newcomer = producer.create_broadcast("room/alice").unwrap(); let mut handler = newcomer.requests(); - newcomer.announce(Route::default().with_epoch(epoch())).unwrap(); + newcomer.announce(Route::default().with_identity(epoch(), 0)).unwrap(); let _unanswered = moq_clock::timeout(Duration::from_secs(1), handler.requested_track()) .await .expect("the front asked the newcomer") @@ -13509,7 +13183,7 @@ mod tests { // No handler and no track: refuses "video" with NotFound. let _second = producer - .publish("room/alice", Route::default().with_epoch(epoch())) + .publish("room/alice", Route::default().with_identity(epoch(), 0)) .unwrap(); for _ in 0..10 { moq_clock::sim::yield_now().await; @@ -13528,7 +13202,7 @@ mod tests { .expect("open"); let _third = producer - .publish("room/alice", Route::default().with_epoch(epoch())) + .publish("room/alice", Route::default().with_identity(epoch(), 0)) .unwrap(); for _ in 0..10 { moq_clock::sim::yield_now().await; @@ -13604,7 +13278,7 @@ mod tests { .expect("open"); let newcomer = producer - .publish("room/alice", Route::default().with_epoch(epoch())) + .publish("room/alice", Route::default().with_identity(epoch(), 0)) .unwrap(); let _video = newcomer.create_track("video", crate::track::Info::timed()).unwrap(); for _ in 0..10 { @@ -13627,7 +13301,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let first = producer - .publish("room/alice", Route::default().with_epoch(epoch())) + .publish("room/alice", Route::default().with_identity(epoch(), 0)) .unwrap(); let track = first.create_track("video", crate::track::Info::timed()).unwrap(); let resolved = consumer.request("room/alice", None).await.expect("resolves"); @@ -13639,7 +13313,7 @@ mod tests { assert_eq!(&group.read_frame().await.unwrap().unwrap().payload[..], b"before"); let second = producer - .publish("room/alice", Route::default().with_epoch(epoch())) + .publish("room/alice", Route::default().with_identity(epoch(), 0)) .unwrap(); let incompatible = second .create_track("video", track::Info::default().with_timescale(crate::Timescale::MICRO)) @@ -13667,7 +13341,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let first = producer - .publish("room/alice", Route::default().with_epoch(epoch())) + .publish("room/alice", Route::default().with_identity(epoch(), 0)) .unwrap(); let track = first.create_track("video", crate::track::Info::timed()).unwrap(); let resolved = consumer.request("room/alice", None).await.expect("resolves"); @@ -13680,7 +13354,7 @@ mod tests { drop(group); let second = producer - .publish("room/alice", Route::default().with_epoch(epoch())) + .publish("room/alice", Route::default().with_identity(epoch(), 0)) .unwrap(); let incompatible = second .create_track("video", track::Info::default().with_timescale(crate::Timescale::MICRO)) @@ -13723,7 +13397,7 @@ mod tests { let producer = origin(1).produce(); let consumer = producer.consume(); let first = producer - .publish("room/alice", Route::default().with_epoch(epoch())) + .publish("room/alice", Route::default().with_identity(epoch(), 0)) .unwrap(); let track = first.create_track("video", crate::track::Info::timed()).unwrap(); let resolved = consumer.request("room/alice", None).await.expect("resolves"); @@ -13737,7 +13411,7 @@ mod tests { let second = producer.create_broadcast("room/alice").unwrap(); let mut handler = second.requests(); - second.announce(Route::default().with_epoch(epoch())).unwrap(); + second.announce(Route::default().with_identity(epoch(), 0)).unwrap(); let request = moq_clock::timeout(Duration::from_secs(1), handler.requested_track()) .await .expect("the front asked the newcomer") @@ -14297,18 +13971,20 @@ mod tests { let mut selected = consumer.announced(); selected.assert_next_wait(); - let first_epoch = crate::Epoch::mint(); + let first_epoch = crate::Identity::random(); let exact = producer - .announce("room/alice", Route::default().with_epoch(first_epoch.clone())) + .announce("room/alice", Route::default().with_identity(first_epoch.clone(), 0)) .unwrap(); assert!( matches!(selected.expect_next().await, AnnounceEvent::Start(a) if a.prefix.is_empty() && a.captures.is_some()) ); exact - .update(Route::default().with_epoch(first_epoch).with_cost(2)) + .update(Route::default().with_identity(first_epoch, 0).with_cost(2)) .unwrap(); assert!(matches!(selected.expect_next().await, AnnounceEvent::Update(a) if a.route.cost == Cost::new(2))); - exact.update(Route::default().with_epoch(crate::Epoch::mint())).unwrap(); + exact + .update(Route::default().with_identity(crate::Identity::random(), 0)) + .unwrap(); assert!(matches!(selected.expect_next().await, AnnounceEvent::Restart(_))); drop(exact); selected.expect_next_ended("").await; diff --git a/rs/moq-net/src/model/resume.rs b/rs/moq-net/src/model/resume.rs index 4d212c4858f..4a7f64b503c 100644 --- a/rs/moq-net/src/model/resume.rs +++ b/rs/moq-net/src/model/resume.rs @@ -1,7 +1,7 @@ //! Serve a front's logical track straight from its routes' copies. //! //! A front serves each track through one route at a time and switches when that route -//! dies, withdraws, or is beaten by another with the same epoch. Routes with one epoch +//! dies, withdraws, or is beaten by another with the same identity. Routes with one epoch //! serve one broadcast, so every route's copy of a track holds the same groups and frames. A reader of the logical //! track therefore reads the serving route's copy directly, through its own //! [`Subscriber`]: nothing is copied, and with one route it is a passthrough. diff --git a/rs/moq-net/src/model/resume_model.rs b/rs/moq-net/src/model/resume_model.rs index e4b65e7c409..093239eba0b 100644 --- a/rs/moq-net/src/model/resume_model.rs +++ b/rs/moq-net/src/model/resume_model.rs @@ -4,8 +4,8 @@ //! reader joins and leaves drive an origin's fronts on the simulated clock. Every reader's //! groups, frames, drops, and end are checked against what one plain cursor on the logical //! track would see: every group its range and budget allow, each once, in its order. A -//! newer publisher epoch wins the path while older readers stay on theirs until it has no -//! route left, then end with an error. +//! second identity, priced below the first, wins the path while older readers stay on +//! theirs until it has no route left, then end with an error. //! //! `MOQ_RESUME_SEED` and `MOQ_RESUME_CASES` pick the cases; nightly sweeps a fresh seed. @@ -20,13 +20,13 @@ use proptest::prelude::*; use proptest::test_runner::{Config, RngSeed, TestCaseError, TestRunner}; use super::origin::{Producer, Route}; -use crate::{Epoch, Error, Hop, Hops, SessionError, StreamError, Timestamp, broadcast, group, origin, track}; +use crate::{Error, Hop, Hops, Identity, SessionError, StreamError, Timestamp, broadcast, group, origin, track}; /// Groups in the logical track; group `g` has `1 + g % 3` frames. const GROUPS: u64 = 12; -/// Routes per epoch, cheaper the higher the slot. +/// Routes per identity, cheaper the higher the slot. const SLOTS: usize = 3; -/// The older publisher instance, then a newer one. +/// The first identity, then one whose routes all undercut it. const EPOCHS: usize = 2; const READERS: usize = 4; const PATH: &str = "room/alice"; @@ -39,19 +39,13 @@ fn frames(group: u64) -> usize { 1 + (group % 3) as usize } -/// Frame `frame` of `group` as `epoch` publishes it: every route of one epoch carries it. +/// Frame `frame` of `group` as `epoch` publishes it: every route of one identity carries it. fn payload(epoch: usize, group: u64, frame: usize) -> Vec { format!("e{epoch}:{group}.{frame}").into_bytes() } -fn epoch(index: usize) -> Epoch { - // UUIDv7s minted in order, so the second is the newer instance. - [ - "01900000-0000-7000-8000-000000000001", - "01900000-0001-7000-8000-000000000001", - ][index] - .parse() - .unwrap() +fn epoch(index: usize) -> Identity { + ["first", "second"][index].parse().unwrap() } /// How a route fails: its session closes on the far side. @@ -450,9 +444,10 @@ impl Model { hops.push(Hop::new(10 * (epoch as u64 + 1) + slot as u64).unwrap()) .unwrap(); let route = Route::default() - .with_epoch(self::epoch(epoch)) + .with_identity(self::epoch(epoch), 0) .with_hops(hops) - .with_cost((SLOTS - slot) as u64); + // The second identity's routes all undercut the first's. + .with_cost(((EPOCHS - epoch) * SLOTS - slot) as u64); let requests = self.origin.requests_route(PATH, route, None).unwrap(); let broadcast = broadcast::Info::new().produce(); let track = broadcast.create_track("video", track::Info::timed()).unwrap(); @@ -588,7 +583,7 @@ impl Model { .await .map_err(|err| fail(index, format!("request failed: {err}")))?; let instance = (0..EPOCHS) - .find(|epoch| broadcast.info().epoch.as_ref() == Some(&self::epoch(*epoch))) + .find(|epoch| broadcast.info().identity.as_ref() == Some(&self::epoch(*epoch))) .ok_or_else(|| fail(index, "resolved no known instance"))?; if instance != expected { return Err(fail( diff --git a/rs/moq-net/src/model/track.rs b/rs/moq-net/src/model/track.rs index fb451d50464..a0be6a98fec 100644 --- a/rs/moq-net/src/model/track.rs +++ b/rs/moq-net/src/model/track.rs @@ -7272,7 +7272,7 @@ impl Subscriber { /// edge and writes the rest off; raise it to read history. [`Self::with_range`] and /// [`Subscription::ranges`] are filters, not exemptions: backfill needs a budget that /// covers it. [`Consumer::fetch_group`] is the way to ask for one old group outright. - /// Across same-epoch route copies, the lowest next buffered group is delivered + /// Across same-identity route copies, the lowest next buffered group is delivered /// first; a lower group that arrives later is still delivered in arrival order. /// The budget remains attached to a returned group: if it stalls while newer data /// advances, its pending frame read ends with [`Error::Old`]. diff --git a/rs/moq-net/src/path/mod.rs b/rs/moq-net/src/path/mod.rs index 44d1050126e..5f7ac5b6f86 100644 --- a/rs/moq-net/src/path/mod.rs +++ b/rs/moq-net/src/path/mod.rs @@ -288,7 +288,7 @@ impl Path { /// assert_eq!(path.parts().collect::>(), ["foo", "bar", "baz"]); /// assert_eq!(Path::empty().parts().count(), 0); /// ``` - pub fn parts(&self) -> impl Iterator { + pub fn parts(&self) -> impl Iterator + Clone { // Segments are never empty, so only the empty path ends in an empty substring, // which a terminator split skips; the route table walks this on every lookup. self.as_str().split_terminator('/') @@ -1142,7 +1142,7 @@ mod tests { // A message carrying the longest path still fits the lite message limit. let tracks = crate::lite::Tracks { broadcast: Path::new(&longest).unwrap(), - epoch: None, + identity: None, }; let buf = tracks.encode_bytes(Version::Lite07).unwrap(); let (decoded, _) = crate::lite::Tracks::decode_slice(&buf, Version::Lite07).unwrap(); diff --git a/rs/moq-net/src/test_interop.rs b/rs/moq-net/src/test_interop.rs index ac6c4bd2fdc..660d18ed641 100644 --- a/rs/moq-net/src/test_interop.rs +++ b/rs/moq-net/src/test_interop.rs @@ -536,7 +536,7 @@ fn lite_tracks_interop() { let version = lite::Version::Lite07; let request = lite::Tracks { broadcast: crate::Path::from_static("room"), - epoch: None, + identity: None, } .encode_bytes(version) .unwrap(); diff --git a/rs/moq-net/tests/integration/announce_takeover.rs b/rs/moq-net/tests/integration/announce_takeover.rs index 98f60748698..2ac5b162ae5 100644 --- a/rs/moq-net/tests/integration/announce_takeover.rs +++ b/rs/moq-net/tests/integration/announce_takeover.rs @@ -9,7 +9,7 @@ use crate::support; use std::{cell::RefCell, rc::Rc, time::Duration}; -use moq_net::{Epoch, Hop, Timestamp, Version, announce, origin, track}; +use moq_net::{Hop, Timestamp, Version, announce, origin, track}; use support::harness::{MockConnectOptions, MockPair, connect_mock}; const TEST_TIMEOUT: Duration = Duration::from_secs(600); @@ -50,7 +50,7 @@ impl Worker { let (handler, served) = (claim.clone(), outputs.clone()); drop(moq_clock::sim::spawn(async move { while let Ok(request) = handler.requested_broadcast().await { - let prepared = request.prepare(Epoch::mint()).unwrap(); + let prepared = request.prepare().unwrap(); let track = prepared .broadcast() .create_track( diff --git a/rs/moq-net/tests/integration/catalog_acquisition.rs b/rs/moq-net/tests/integration/catalog_acquisition.rs index 3d2533055d4..e4e5a8d6bb4 100644 --- a/rs/moq-net/tests/integration/catalog_acquisition.rs +++ b/rs/moq-net/tests/integration/catalog_acquisition.rs @@ -21,7 +21,7 @@ async fn link(version: Version, from: &origin::Producer, to: &origin::Producer, connect_mock(options).await } -/// `carries_epoch`: the wire carries the epoch, as lite-07 does. +/// `carries_epoch`: the wire carries the publisher's identity, as only lite-07 does. async fn acquisition(version: &str, carries_epoch: bool) { let version: Version = version.parse().unwrap(); let publisher = origin(1); @@ -29,7 +29,7 @@ async fn acquisition(version: &str, carries_epoch: bool) { let broadcast = publisher.create_broadcast("live").unwrap(); let mut dynamic = broadcast.requests(); broadcast - .announce(origin::Route::default().with_epoch(moq_net::Epoch::mint())) + .announce(origin::Route::default().with_identity(moq_net::Identity::random(), 0)) .unwrap(); let _old = link(version, &publisher, &subscriber, 100).await; let remote = subscriber @@ -52,7 +52,8 @@ async fn acquisition(version: &str, carries_epoch: bool) { // The publisher is still available and the old session remains alive. Holding // TRACK_INFO makes the replacement happen during acquisition without sleeps. let _new = link(version, &publisher, &subscriber, 1).await; - // An epochless replacement restarts the advertisement; a matching epoch only updates it. + // A replacement under a minted identity restarts the advertisement; a carried one only + // updates it. let new_cost = match announced.next().await { Some(moq_net::announce::Event::Update(announce)) if carries_epoch => announce.route.cost, Some(moq_net::announce::Event::Restart(announce)) if !carries_epoch => announce.route.cost, @@ -71,20 +72,20 @@ async fn acquisition(version: &str, carries_epoch: bool) { } else { assert!( matches!(result, Err(Error::Unroutable)), - "epochless replacement: {:?}", + "minted replacement: {:?}", result.err() ); } } #[moq_clock::test] -async fn epochless_replacement_refuses_pending_catalog() { +async fn minted_replacement_refuses_pending_catalog() { acquisition("moq-lite-06", false).await; acquisition("moq-transport-18", false).await; acquisition("moq-transport-22", false).await; } #[moq_clock::test] -async fn same_epoch_replacement_acquires_pending_catalog() { +async fn carried_replacement_acquires_pending_catalog() { acquisition("moq-lite-07-wip", true).await; } diff --git a/rs/moq-net/tests/integration/catalog_churn.rs b/rs/moq-net/tests/integration/catalog_churn.rs index f7223fe1abd..aaf494deba7 100644 --- a/rs/moq-net/tests/integration/catalog_churn.rs +++ b/rs/moq-net/tests/integration/catalog_churn.rs @@ -283,7 +283,7 @@ async fn execute(version: Version, ops: &[Op]) -> Result<(), Failure> { let info = track::Info::default().with_timescale(moq_net::Timescale::MILLI); let track = broadcast.create_track("catalog.json", info).unwrap(); broadcast - .announce(origin::Route::default().with_epoch(moq_net::Epoch::mint())) + .announce(origin::Route::default().with_identity(moq_net::Identity::random(), 0)) .unwrap(); let upstream = vec![ diff --git a/rs/moq-net/tests/integration/catalog_handover.rs b/rs/moq-net/tests/integration/catalog_handover.rs index ad833752fe9..38fab2d3de1 100644 --- a/rs/moq-net/tests/integration/catalog_handover.rs +++ b/rs/moq-net/tests/integration/catalog_handover.rs @@ -3,7 +3,7 @@ //! A catalog's one open group (a snapshot at frame 0, deltas after) survives its //! route changing in the middle of that group, end to end over real sessions. //! -//! A publisher `P` is pulled by two relays `A` and `B` carrying its epoch, and `R` +//! A publisher `P` is pulled by two relays `A` and `B` carrying its identity, and `R` //! reads through `A` until the route changes mid-group. The reader already on `R` //! continues with the next delta, and every reader after the change (a fresh one, a //! returning one, and a burst of short ones) still starts from the snapshot. The @@ -107,7 +107,7 @@ impl Topology { let broadcast = publisher.create_broadcast("live").unwrap(); let track = broadcast.create_track("catalog.json", timed()).unwrap(); broadcast - .announce(origin::Route::default().with_epoch(moq_net::Epoch::mint())) + .announce(origin::Route::default().with_identity(moq_net::Identity::random(), 0)) .unwrap(); let group = track.append_group().unwrap(); @@ -479,7 +479,7 @@ macro_rules! handover_tests { }; } -// Only an epoch lets `R` resume onto another route; older wires end the subscription +// Only an identity lets `R` resume onto another route; older wires end the subscription // with its route instead (see `route_change`). handover_tests! { lite_07: "moq-lite-07-wip", diff --git a/rs/moq-net/tests/integration/drained_claim.rs b/rs/moq-net/tests/integration/drained_claim.rs index 78bd99a3455..b3fdff55642 100644 --- a/rs/moq-net/tests/integration/drained_claim.rs +++ b/rs/moq-net/tests/integration/drained_claim.rs @@ -13,7 +13,7 @@ use crate::support; use std::{cell::RefCell, rc::Rc, time::Duration}; -use moq_net::{Epoch, Hop, Timestamp, Version, goaway::Goaway, origin, track}; +use moq_net::{Hop, Timestamp, Version, goaway::Goaway, origin, track}; use support::harness::{MockConnectOptions, MockPair, connect_mock}; /// A millisecond track: the frames written here carry timestamps. @@ -60,7 +60,7 @@ impl Worker { let (handler, served) = (claim.clone(), outputs.clone()); drop(moq_clock::sim::spawn(async move { while let Ok(request) = handler.requested_broadcast().await { - let prepared = request.prepare(Epoch::mint()).unwrap(); + let prepared = request.prepare().unwrap(); let track = prepared.broadcast().create_track("video", timed()).unwrap(); let mut group = track.append_group().unwrap(); group.write_frame(Timestamp::ZERO, name.as_bytes().to_vec()).unwrap(); diff --git a/rs/moq-net/tests/integration/identity.rs b/rs/moq-net/tests/integration/identity.rs new file mode 100644 index 00000000000..e01abf50d04 --- /dev/null +++ b/rs/moq-net/tests/integration/identity.rs @@ -0,0 +1,99 @@ +#![cfg(feature = "async")] + +//! A route's identity across each wire: lite-07 carries the publisher's, and every other +//! version carries none, so the receiving session mints one per announcement. + +use crate::support; + +use std::time::Duration; + +use moq_net::{Hop, Identity, Version, announce, origin}; +use support::harness::{MockConnectOptions, MockPair, connect_mock}; + +fn produce_origin(hop: u64) -> origin::Producer { + let (producer, driver) = origin::Producer::new(origin::Config::new(Hop::new(hop).unwrap())); + support::harness::spawn(driver); + producer +} + +async fn link(version: Version, from: &origin::Producer, to: &origin::Producer) -> MockPair { + let mut options = MockConnectOptions::new(version); + options.server_publish = Some(from.consume()); + options.client_subscribe = Some(to.clone()); + connect_mock(options).await +} + +/// The identity of the next route `announced` starts, failing on any other event. +async fn started(announced: &mut announce::Consumer) -> Identity { + match moq_clock::timeout(Duration::from_secs(10), announced.next()) + .await + .expect("announce hung") + .expect("announce ended") + { + announce::Event::Start(announce) => announce.route.identity.expect("a delivered route has an identity"), + other => panic!("expected a start, got {other:?}"), + } +} + +async fn ended(announced: &mut announce::Consumer) { + match moq_clock::timeout(Duration::from_secs(10), announced.next()) + .await + .expect("announce hung") + .expect("announce ended") + { + announce::Event::End(_) => {} + other => panic!("expected an end, got {other:?}"), + } +} + +/// A job announced, withdrawn and announced again on one session, under a stable identity. +/// lite-07 delivers that identity both times, so the second run continues the first. Every +/// other wire mints one per announcement, so the second run is a stream of its own and is +/// never spliced onto the first, and neither is the publisher's. +async fn job_reannounced(version: &str) { + let version: Version = version.parse().unwrap(); + let publisher = produce_origin(1); + let relay = produce_origin(2); + let identity: Identity = "job".parse().unwrap(); + let job = publisher + .publish("job", origin::Route::default().with_identity(identity.clone(), 0)) + .unwrap(); + let _session = link(version, &publisher, &relay).await; + let mut announced = relay.consume().announced(); + + let first = started(&mut announced).await; + job.unannounce(); + ended(&mut announced).await; + job.announce(origin::Route::default()).unwrap(); + let second = started(&mut announced).await; + + match version.to_string().as_str() { + "moq-lite-07-wip" => { + assert_eq!(first, identity, "{version}"); + assert_eq!(second, identity, "{version}"); + } + _ => { + assert_ne!(first, identity, "{version}: an identity crossed a wire that has none"); + assert_ne!(second, first, "{version}: a re-announcement continued the old stream"); + } + } +} + +#[moq_clock::test] +async fn lite07_carries_the_identity() { + job_reannounced("moq-lite-07-wip").await; +} + +#[moq_clock::test] +async fn older_lite_mints_per_announcement() { + for version in ["moq-lite-06", "moq-lite-05", "moq-lite-03"] { + job_reannounced(version).await; + } +} + +#[moq_clock::test] +async fn moq_transport_mints_per_announcement() { + for version in ["moq-transport-18", "moq-transport-22"] { + job_reannounced(version).await; + } +} diff --git a/rs/moq-net/tests/integration/main.rs b/rs/moq-net/tests/integration/main.rs index 9f756677753..f446673f006 100644 --- a/rs/moq-net/tests/integration/main.rs +++ b/rs/moq-net/tests/integration/main.rs @@ -32,6 +32,7 @@ mod finite_relay; mod goaway; mod group_order; mod history_groups; +mod identity; mod idle_cancel_refetch; mod idle_session; mod implementation; diff --git a/rs/moq-net/tests/integration/mesh_withdraw.rs b/rs/moq-net/tests/integration/mesh_withdraw.rs index c9022b9bd81..4cd7ae4999d 100644 --- a/rs/moq-net/tests/integration/mesh_withdraw.rs +++ b/rs/moq-net/tests/integration/mesh_withdraw.rs @@ -217,7 +217,7 @@ const PRODUCTION_LATENCY_MS: &[u64] = &[ /// withdrawal here costs tens of thousands of announcements. #[moq_clock::test] async fn partial_mesh_withdraw_retracts_once() { - let version: Version = "moq-lite-06".parse().unwrap(); + let version: Version = "moq-lite-07-wip".parse().unwrap(); let nodes: Vec<_> = (1..=34).map(produce_origin).collect(); let mut pairs = Vec::new(); for (&(a, b), &ms) in PRODUCTION.iter().zip(PRODUCTION_LATENCY_MS) { @@ -272,7 +272,7 @@ const STALE_PATHS: &[Link] = &[ /// offers it as a new route after the relay forgot the withdrawal. #[moq_clock::test] async fn retracted_relay_ignores_a_stale_path() { - let version: Version = "moq-lite-06".parse().unwrap(); + let version: Version = "moq-lite-07-wip".parse().unwrap(); let nodes: Vec<_> = (1..=8).map(produce_origin).collect(); let mut pairs = Vec::new(); for &((a, b), latency) in STALE_PATHS { @@ -306,9 +306,10 @@ async fn retracted_relay_ignores_a_stale_path() { /// too. /// /// The watching client holds nothing, so it reports every toggle its relay -/// sends, as a browser's announce stream does. The broadcast has no epoch, so a -/// relay moving to another path restarts it, which lite-06 sends as a -/// retraction then an announcement. +/// sends, as a browser's announce stream does. The relays speak lite-07, which +/// carries the broadcast's identity, so a relay moving to another path updates it. +/// Older versions carry none, so every relay mints its own and a move between +/// neighbors is a restart the update hold does not absorb. struct Cluster { _pairs: Vec, publisher: origin::Producer, @@ -392,11 +393,6 @@ impl Cluster { /// release before the retraction quarantine (#5376), this client saw it /// retracted, announced, and retracted again. Relay 0 still restarts onto a /// stale path after the hold; see [`residual_slow_ring_reannounces`]. -#[moq_clock::test] -async fn cross_node_close_retracts_once_lite06() { - cross_node_close_retracts_once("moq-lite-06").await; -} - #[moq_clock::test] async fn cross_node_close_retracts_once_lite07() { cross_node_close_retracts_once("moq-lite-07-wip").await; @@ -452,8 +448,8 @@ async fn cross_node_close_without_hold_reannounces() { /// A ring of six relays with links under 150 ms. Relay 3 hears the withdrawal /// from relay 2 at 84 ms, but relay 5's equal-cost copy, the long way round, -/// stays live until 446 ms. That outlasts the hold, so relay 3 restarts onto -/// the stale copy and its client sees the broadcast come back. +/// stays live until 446 ms. That outlasts the hold, so relay 3 moves onto the +/// stale copy and its client sees the route update before it ends. #[moq_clock::test] async fn residual_slow_ring_reannounces() { #[rustfmt::skip] @@ -461,18 +457,17 @@ async fn residual_slow_ring_reannounces() { ((0, 1), ms(141)), ((0, 2), ms(55)), ((2, 3), ms(29)), ((1, 4), ms(135)), ((4, 5), ms(148)), ((3, 5), ms(22)), ]; use Kind::*; - let mut cluster = Cluster::new("moq-lite-06", EDGES, origin::Config::default(), 0, 3).await; + let mut cluster = Cluster::new("moq-lite-07-wip", EDGES, origin::Config::default(), 0, 3).await; let (broadcast, _) = cluster.publish("room/a").await; let (client, relays) = cluster.close("room/a", broadcast).await; - assert_eq!(client, [End, Start, End]); + assert_eq!(client, [Update, End]); let relays: Vec<&[Kind]> = relays.iter().map(Vec::as_slice).collect(); - assert_eq!(relays, [&[End][..], &[End], &[End], &[Restart, End], &[End], &[End]]); + assert_eq!(relays, [&[End][..], &[End], &[End], &[Update, End], &[End], &[End]]); assert_capped_withdrawal(EDGES, 0, 3).await; } /// Nine relays, reduced from a seeded random mesh with [`random_mesh`]'s -/// links. Relays 2 and 8 retract the broadcast and then announce it again: a -/// revival the quarantine misses. +/// links. Relays 0, 5, 6, 7 and 8 move onto stale paths before they retract. #[moq_clock::test] async fn residual_mesh_revives() { #[rustfmt::skip] @@ -481,23 +476,23 @@ async fn residual_mesh_revives() { ((0, 6), ms(121)), ((3, 7), ms(81)), ((0, 2), ms(96)), ((2, 8), ms(93)), ((5, 8), ms(89)), ]; use Kind::*; - let mut cluster = Cluster::new("moq-lite-06", EDGES, origin::Config::default(), 4, 8).await; + let mut cluster = Cluster::new("moq-lite-07-wip", EDGES, origin::Config::default(), 4, 8).await; let (broadcast, _) = cluster.publish("room/a").await; let (client, relays) = cluster.close("room/a", broadcast).await; - assert_eq!(client, [End, Start, End, Start, End]); + assert_eq!(client, [Update, End]); let relays: Vec<&[Kind]> = relays.iter().map(Vec::as_slice).collect(); assert_eq!( relays, [ - &[Restart, End][..], + &[Update, End][..], &[End], - &[End, Start, End], &[End], &[End], - &[Restart, End], &[End], - &[Restart, End], - &[Restart, End, Start, End], + &[Update, End], + &[Update, End], + &[Update, End], + &[Update, End], ] ); assert_capped_withdrawal(EDGES, 4, 8).await; @@ -506,7 +501,7 @@ async fn residual_mesh_revives() { async fn assert_capped_withdrawal(edges: &[Link], publisher: usize, watcher: usize) { let mut config = origin::Config::default(); config.max_hops = origin::HopLimit::new(diameter(edges) + 2).unwrap(); - let mut cluster = Cluster::new("moq-lite-06", edges, config, publisher, watcher).await; + let mut cluster = Cluster::new("moq-lite-07-wip", edges, config, publisher, watcher).await; let (broadcast, _) = cluster.publish("room/a").await; let (client, relays) = cluster.close("room/a", broadcast).await; assert_eq!(client.last(), Some(&Kind::End)); @@ -563,7 +558,7 @@ fn random_mesh(seed: u64) -> (Vec, usize) { /// today's counts; Routes and announces brings all three to zero. #[moq_clock::test] async fn residual_random_meshes() { - assert_eq!(withdrawal_counts(false).await, (179, 3, 192)); + assert_eq!(withdrawal_counts(false).await, (231, 3, 290)); } /// The same topologies and terminal states with room for the diameter and two @@ -571,7 +566,7 @@ async fn residual_random_meshes() { #[moq_clock::test] async fn capped_random_meshes_hunt_less() { let capped = withdrawal_counts(true).await; - assert_eq!(capped, (151, 1, 154)); + assert_eq!(capped, (177, 0, 192)); } fn diameter(edges: &[Link]) -> usize { @@ -610,7 +605,7 @@ async fn withdrawal_counts(capped: bool) -> (usize, usize, usize) { if capped { config.max_hops = origin::HopLimit::new(diameter(&edges) + 2).unwrap(); } - let mut cluster = Cluster::new("moq-lite-06", &edges, config, publisher, 0).await; + let mut cluster = Cluster::new("moq-lite-07-wip", &edges, config, publisher, 0).await; let (broadcast, _) = cluster.publish("room/a").await; let (_, relays) = cluster.close("room/a", broadcast).await; for kinds in relays { diff --git a/rs/moq-net/tests/integration/multi_cdn.rs b/rs/moq-net/tests/integration/multi_cdn.rs index 0435bfeaa1d..c8061f01c33 100644 --- a/rs/moq-net/tests/integration/multi_cdn.rs +++ b/rs/moq-net/tests/integration/multi_cdn.rs @@ -2,12 +2,13 @@ //! An endpoint linked to two CDNs uses its preferred one and fails over to the next. //! -//! A publisher `P` pushes one broadcast, under one epoch, to CDN relays `A` and `B`. A +//! A publisher `P` pushes one broadcast, under one identity, to CDN relays `A` and `B`. A //! subscriber `S` links to both, ranking `A` first with //! [`origin::Producer::with_preference`] while pricing `B` cheaper, so only the //! preference puts `A` ahead. Losing `A` moves `S` to `B`, and `A` returning moves it -//! back. Between lite-07 routes with the same epoch each move resumes the subscription. -//! A moq-transport `B` carries no epoch, so moving onto it restarts the path. +//! back. Between lite-07 routes, which carry the identity, each move resumes the +//! subscription. A moq-transport `B` carries none, so `S` mints one for it, and the move +//! onto it is a restart that ends the subscription rather than stitching onto it. use crate::support; @@ -163,7 +164,7 @@ impl Topology { let broadcast = publisher.create_broadcast("live").unwrap(); let track = broadcast.create_track("video", timed()).unwrap(); broadcast - .announce(origin::Route::default().with_epoch(moq_net::Epoch::mint())) + .announce(origin::Route::default().with_identity(moq_net::Identity::random(), 0)) .unwrap(); let pushes = vec![ @@ -313,7 +314,8 @@ async fn preferred_cdn_fails_over_and_back() { fails_over_and_back("moq-lite-07-wip").await; } -/// Losing the epoch-carrying route restarts onto the epochless moq-transport route. +/// A moq-transport `B` carries no identity, so `S` mints one for its route and losing `A` +/// is a restart onto another stream: the track ends with no frame of `B`'s. async fn restarts_across_protocols(secondary: &str) { moq_clock::timeout(TEST_TIMEOUT, async { let mut topology = Topology::new(secondary.parse().unwrap()).await; @@ -329,7 +331,7 @@ async fn restarts_across_protocols(secondary: &str) { let update = announced.next().await.expect("announced"); assert!( matches!(update, announce::Event::Restart(_)) && !via_a(&update), - "epochless route over B: {update:?}" + "another identity over B: {update:?}" ); topology.group(); settle().await; diff --git a/rs/moq-net/tests/integration/rejoin.rs b/rs/moq-net/tests/integration/rejoin.rs index 6e10b19851f..8067852909e 100644 --- a/rs/moq-net/tests/integration/rejoin.rs +++ b/rs/moq-net/tests/integration/rejoin.rs @@ -705,7 +705,7 @@ async fn quiet_catalog_survives_cache_expiry(version: Version, takeover: bool) { let broadcast = publisher.create_broadcast("bench").unwrap(); let track = broadcast.create_track("catalog", timed()).unwrap(); broadcast - .announce(moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint())) + .announce(moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0)) .unwrap(); let mut group = track.append_group().unwrap(); group.write_frame(Timestamp::ZERO, b"catalog".as_ref()).unwrap(); @@ -785,7 +785,7 @@ async fn quiet_catalog_survives_cache_expiry(version: Version, takeover: bool) { .write_frame(Timestamp::from_millis(1000).unwrap(), b"newer".as_ref()) .unwrap(); newer.finish().unwrap(); - // Epochless wires end the old holder on takeover. The late reader uses the + // Unnamed wires end the old holder on takeover. The late reader uses the // current route on every version, and keeps that copy live for the expiry check. assert_eq!(later.recv_group().await.unwrap().unwrap().sequence, 1, "{version}"); moq_clock::sleep(moq_net::cache::DEFAULT_EXPIRY * 2 + Duration::from_secs(2)).await; diff --git a/rs/moq-net/tests/integration/replacement.rs b/rs/moq-net/tests/integration/replacement.rs index 1eb1b2553c4..7dedf544d6f 100644 --- a/rs/moq-net/tests/integration/replacement.rs +++ b/rs/moq-net/tests/integration/replacement.rs @@ -4,17 +4,17 @@ //! same peer re-announces every route while the first one is still open, as a GOAWAY //! drain does. //! -//! Without an epoch the re-announced routes are new instances, so announce consumers -//! get a `Restart`. A handle resolved before the replacement keeps serving new requests -//! until that `Restart` is deliverable, so its holder can follow the `Restart` instead -//! of failing `Unroutable`. With an epoch the replacement continues the instance: no -//! `Restart`, and the old handle keeps working. +//! lite-07 carries the publisher's identity, so the replacement continues the stream: no +//! `Restart`, and the old handle keeps working. Older wires carry none, so the receiving +//! session mints one per announcement and the re-announced routes are new streams: +//! announce consumers get a `Restart` at once, and the old handle refuses new requests +//! from then on, so its holder follows the `Restart` instead. use crate::support; use std::time::Duration; -use moq_net::{Epoch, Error, Hop, Timestamp, Version, announce, broadcast, origin, track}; +use moq_net::{Error, Hop, Identity, Timestamp, Version, announce, broadcast, origin, track}; use support::harness::{MockConnectOptions, MockPair, connect_mock}; /// A millisecond track: the frames written here carry timestamps. @@ -70,20 +70,19 @@ async fn subscribe(broadcast: &broadcast::Consumer, name: &str) -> Result) { - let carried = (version == "moq-lite-07-wip").then_some(epoch.clone()).flatten(); +async fn replace(version: &str, identity: Option) { let version: Version = version.parse().unwrap(); let publisher = produce_origin(1); let viewer = produce_origin(3); let mut route = origin::Route::default(); - route.epoch = epoch.clone(); + route.identity = identity; let cam = publisher.publish("cam", route).unwrap(); let video = cam.create_track("video", timed()).unwrap(); let audio = cam.create_track("audio", timed()).unwrap(); @@ -92,10 +91,7 @@ async fn replace(version: &str, epoch: Option) { let _first = link(version, &publisher, &viewer).await; let mut announced = viewer.consume().announced(); match next_event(&mut announced).await { - announce::Event::Start(announce) => { - assert_eq!(announce.prefix.as_str(), "cam"); - assert_eq!(announce.route.epoch, carried, "{version}: carried epoch"); - } + announce::Event::Start(announce) => assert_eq!(announce.prefix.as_str(), "cam"), other => panic!("{version}: expected the broadcast, got {other:?}"), } @@ -109,36 +105,34 @@ async fn replace(version: &str, epoch: Option) { let _second = link(version, &publisher, &viewer).await; moq_clock::sleep(WITHIN_HOLD).await; - // A request through the old handle right after the replacement is still served. - let mut late = subscribe(&old, "audio") - .await - .unwrap_or_else(|err| panic!("{version}: the old handle refused a request before any restart: {err}")); - write(&audio, "audio:0"); - assert_eq!(read(&mut late).await, "audio:0", "{version}"); - - if carried.is_some() { - // The epoch continues across the replacement: nothing to restart, and the old + if version.to_string() == "moq-lite-07-wip" { + // The identity continues across the replacement: nothing to restart, and the old // handle keeps serving. if let Ok(event) = moq_clock::timeout(PAST_HOLD, announced.next()).await { - panic!("{version}: an epoch route restarted on replacement: {event:?}"); + panic!("{version}: the stream restarted on replacement: {event:?}"); } let mut later = subscribe(&old, "meta").await.unwrap(); write(&meta, "meta:0"); assert_eq!(read(&mut later).await, "meta:0", "{version}"); + let mut late = subscribe(&old, "audio").await.unwrap(); + write(&audio, "audio:0"); + assert_eq!(read(&mut late).await, "audio:0", "{version}"); return; } - // Without an epoch the replacement is a restart: once the hold releases it, the old - // handle refuses new requests, and the restart is already there to follow. - moq_clock::sleep(PAST_HOLD).await; + // A minted identity makes the replacement a restart, delivered at once, and the old + // handle refuses new requests from then on. + match announced.try_next() { + Some(announce::Event::Restart(announce)) => assert_eq!(announce.prefix.as_str(), "cam"), + other => panic!("{version}: expected the restart, got {other:?}"), + } let Err(err) = subscribe(&old, "meta").await else { panic!("{version}: a retired handle served a request"); }; assert!(matches!(err, Error::Unroutable), "{version}: {err}"); - match announced.try_next() { - Some(announce::Event::Restart(announce)) => assert_eq!(announce.prefix.as_str(), "cam"), - other => panic!("{version}: retired before the restart was deliverable, got {other:?}"), - } + // The subscription already on it keeps flowing. + write(&video, "video:1"); + assert_eq!(read(&mut sticky).await, "video:1", "{version}"); // Following the restart resolves the path afresh, and the new handle serves. let fresh = viewer.consume().request("cam", None).await.unwrap(); @@ -148,59 +142,40 @@ async fn replace(version: &str, epoch: Option) { assert_eq!(read(&mut followed).await, "meta:0", "{version}"); } -/// The epoch a replaced route carries on both sessions. -fn epoch() -> Epoch { - "01900000-0000-7000-8000-000000000001".parse().unwrap() -} - #[moq_clock::test] -async fn epochless_lite06() { +async fn minted_lite06() { moq_clock::timeout(TEST_TIMEOUT, replace("moq-lite-06", None)) .await .expect("timed out"); } +/// lite-07 carries the identity the publication minted, or the stable one it names. #[moq_clock::test] -async fn epochless_lite07() { - moq_clock::timeout(TEST_TIMEOUT, replace("moq-lite-07-wip", None)) - .await - .expect("timed out"); -} - -/// Only lite-07 carries the epoch. -#[moq_clock::test] -async fn epoch_lite07() { - moq_clock::timeout(TEST_TIMEOUT, replace("moq-lite-07-wip", Some(epoch()))) - .await - .expect("timed out"); -} - -/// A local epoch never crosses a moq-transport hop. -#[moq_clock::test] -async fn epoch_is_stripped_ietf() { - for version in ["moq-transport-18", "moq-transport-22"] { - moq_clock::timeout(TEST_TIMEOUT, replace(version, Some(epoch()))) +async fn carried_lite07() { + for identity in [None, Some("cam".parse().unwrap())] { + moq_clock::timeout(TEST_TIMEOUT, replace("moq-lite-07-wip", identity)) .await .expect("timed out"); } } -/// Without one, a moq-transport replacement restarts as it does over lite. +/// moq-transport carries no identity, even a stable one, so a replacement restarts as it +/// does over older lite. #[moq_clock::test] -async fn epochless_ietf() { +async fn minted_ietf() { for version in ["moq-transport-18", "moq-transport-22"] { - moq_clock::timeout(TEST_TIMEOUT, replace(version, None)) + moq_clock::timeout(TEST_TIMEOUT, replace(version, Some("cam".parse().unwrap()))) .await .expect("timed out"); } } -/// A route change alone is never fatal, but a dead route is: once an epochless replacement -/// is announced, a subscription on the old instance keeps going while the old publisher -/// serves it, and its session closing ends that subscription with an error, never cleanly. -/// The viewer reads either straight from the publisher or through a relay, and the old -/// session closes after `wait`: inside the update hold, before the `Restart` is delivered, -/// or past it. +/// A route change alone is never fatal. Once a replacement is announced, a subscription on +/// the old route keeps going while the old publisher serves it. Its session closing then +/// ends the subscription with an error, never cleanly, where the wire minted the routes +/// apart; lite-07 carries one identity on both, so the subscription resumes on the +/// replacement instead. The viewer reads either straight from the publisher or through a +/// relay, and the old session closes after `wait`: inside the update hold, or past it. async fn route_death(version: &str, relayed: bool, wait: Duration) { let version: Version = version.parse().unwrap(); let publisher = produce_origin(1); @@ -234,6 +209,15 @@ async fn route_death(version: &str, relayed: bool, wait: Duration) { ); let _ = old_session.server.close().await; + if version.to_string() == "moq-lite-07-wip" { + write(&video, "video:2"); + assert_eq!( + read(&mut sticky).await, + "video:2", + "{version} (relayed: {relayed}): the identity did not resume on the replacement" + ); + return; + } let end = moq_clock::timeout(Duration::from_secs(10), async { loop { match sticky.recv_group().await { diff --git a/rs/moq-net/tests/integration/route_change.rs b/rs/moq-net/tests/integration/route_change.rs index c37433006b4..393c9f4f934 100644 --- a/rs/moq-net/tests/integration/route_change.rs +++ b/rs/moq-net/tests/integration/route_change.rs @@ -3,16 +3,17 @@ //! A subscription survives its route changing, end to end over real sessions. //! //! A publisher `P` is pulled by two relays `A` and `B`, both of which re-advertise -//! it to the subscribing relay `R`. Both routes carry `P`'s epoch, so `R` may resume +//! it to the subscribing relay `R`. Both routes carry `P`'s identity, so `R` may resume //! a subscription served through one onto the other. The reader on `R` must see //! every frame exactly once, in order, whether the route changes between groups or //! in the middle of one, and however it changes. //! -//! moq-transport and older lite versions carry no epoch, so `R` cannot tell the -//! two routes serve the same bytes: its subscription stays on its route and ends with -//! it, while a better route restarts the path for new requests. +//! Only lite-07 carries the identity. Older lite versions and moq-transport carry none, +//! so `R` mints one per route and cannot tell the two serve the same bytes: its +//! subscription stays on its route and ends with it, while a better route restarts the +//! path for new requests. //! -//! Two publishers sharing one explicit epoch, a redundant pair, are one source the +//! Two publishers sharing one stable identity, a redundant pair, are one source the //! same way. use crate::support; @@ -115,7 +116,7 @@ impl Topology { let broadcast = publisher.create_broadcast("live").unwrap(); let track = broadcast.create_track("video", timed()).unwrap(); broadcast - .announce(origin::Route::default().with_epoch(moq_net::Epoch::mint())) + .announce(origin::Route::default().with_identity(moq_net::Identity::random(), 0)) .unwrap(); let p_to_a = link(version, &publisher, &relay_a).await; @@ -321,10 +322,10 @@ async fn route_flaps(version: &str) { assert!(rx.try_recv().is_err(), "{version}: trailing delivery"); } -/// A wire without epochs: the subscription stays on `A` while `B` stands by, and +/// A wire without identities: the subscription stays on `A` while `B` stands by, and /// ends when `A`'s route goes instead of resuming through `B`. A re-request then /// lands on `B`. -async fn route_dies_without_an_epoch(version: &str, trigger: Trigger) { +async fn route_dies_across_identities(version: &str, trigger: Trigger) { let version: Version = version.parse().unwrap(); let (mut topology, sub) = Topology::new(version).await; let mut rx = read(sub); @@ -367,7 +368,7 @@ async fn route_dies_without_an_epoch(version: &str, trigger: Trigger) { while next(&mut rx).await != (2, payload(2, 0)) {} } -/// A wire without epochs: a better route is another source. `R` restarts the path, the +/// A wire without identities: a better route is another source. `R` restarts the path, the /// subscription already on `A` stays there, and a re-request lands on the better route. async fn better_route_restarts(version: &str) { let version: Version = version.parse().unwrap(); @@ -473,7 +474,7 @@ macro_rules! pinned_tests { use super::*; async fn run(trigger: Trigger) { - moq_clock::timeout(TEST_TIMEOUT, route_dies_without_an_epoch($version, trigger)) + moq_clock::timeout(TEST_TIMEOUT, route_dies_across_identities($version, trigger)) .await .expect("timed out"); } @@ -488,7 +489,7 @@ macro_rules! pinned_tests { run(Trigger::Unannounce).await; } - /// A better route without an epoch wins new requests and announces as a + /// A better route under another identity wins new requests and announces as a /// restart, while the incumbent's subscriptions continue. #[moq_clock::test] async fn better_route_keeps_the_incumbent() { @@ -502,11 +503,11 @@ macro_rules! pinned_tests { } pinned_tests! { - ietf_18: "moq-transport-18", - ietf_22: "moq-transport-22", lite_06: "moq-lite-06", lite_05: "moq-lite-05", lite_04: "moq-lite-04", + ietf_18: "moq-transport-18", + ietf_22: "moq-transport-22", } /// `P` feeds `A` slowly and `B` promptly, and `R` reads through `A` until that route dies @@ -542,7 +543,7 @@ async fn lagging_route_dies(version: Version) { let broadcast = p.create_broadcast("live").unwrap(); let track = broadcast.create_track("video", timed()).unwrap(); broadcast - .announce(origin::Route::default().with_epoch(moq_net::Epoch::mint())) + .announce(origin::Route::default().with_identity(moq_net::Identity::random(), 0)) .unwrap(); let p_a = lagged(version, &p, &a, Duration::from_millis(300)).await; let p_b = lagged(version, &p, &b, Duration::ZERO).await; @@ -631,7 +632,7 @@ async fn undetected_outage(budget: Duration) -> BTreeMap> { let broadcast = p.create_broadcast("live").unwrap(); let track = broadcast.create_track("video", timed()).unwrap(); broadcast - .announce(origin::Route::default().with_epoch(moq_net::Epoch::mint())) + .announce(origin::Route::default().with_identity(moq_net::Identity::random(), 0)) .unwrap(); let p_o = link(version, &p, &o).await; let o_m1 = link(version, &o, &m1).await; @@ -755,22 +756,19 @@ struct Replica { link: Option, } -/// A redundant pair: `P1` and `P2` publish `live` under one explicit epoch and write -/// the same frames, tagged with their name. `R` reads through `P1`, priced below `P2` -/// so it starts there, until `P1` goes mid-group. `R` resumes on `P2` from the first frame its reader -/// lacks: every frame once, in order, and no timestamp ever rewinds. -async fn redundant_pair_fails_over(version: &str, loss: Loss) { +/// A redundant pair: `P1` and `P2` publish `live`, each its own stream under its own +/// identity, priced so `R` starts on `P1`. Each writes frames tagged with its name. When +/// `P1` goes, its broadcast ends rather than stitching `P2`'s groups onto what the reader +/// already has, and `follow` hands out `P2`'s broadcast next. +async fn redundant_pair_follows_the_next_identity(version: &str, loss: Loss) { let version: Version = version.parse().unwrap(); - let epoch = moq_net::Epoch::mint(); let subscriber = produce_origin(4); let mut replicas = Vec::new(); for (name, hop, cost) in [("P1", 1, 1), ("P2", 2, 5)] { let publisher = produce_origin(hop); let broadcast = publisher.create_broadcast("live").unwrap(); let track = broadcast.create_track("video", timed()).unwrap(); - broadcast - .announce(origin::Route::default().with_epoch(epoch.clone()).with_cost(cost)) - .unwrap(); + broadcast.announce(origin::Route::default().with_cost(cost)).unwrap(); let link = link(version, &publisher, &subscriber).await; replicas.push(Replica { name, @@ -783,90 +781,98 @@ async fn redundant_pair_fails_over(version: &str, loss: Loss) { } settle().await; - let consumer = subscriber.consume(); - let remote = consumer.request("live", None).await.unwrap(); + let mut follow = subscriber.consume().follow("live", None).unwrap(); + let first = follow.available().await.unwrap(); let prefs = track::Subscription::default().with_max_delay(Duration::from_secs(60)); - let mut rx = read(remote.track("video").unwrap().subscribe(prefs).await.unwrap()); - - let mut expected = Vec::new(); - let mut seen = Vec::new(); - let mut kept = None; - for sequence in 0..3 { - for replica in &mut replicas { - replica.group = Some(replica.track.append_group().unwrap()); - } - for frame in 0..FRAMES { - if sequence == 1 && frame == FRAMES / 2 { - let Replica { - origin, - broadcast, - link, - .. - } = replicas.remove(0); - let link = link.unwrap(); - match loss { - Loss::Unreachable => { - link.server.abort(Error::Cancel); - link.client.abort(Error::Cancel); - } - // Keep the session and its origin up: only the broadcast, its track, and its open group go. - Loss::Ends => kept = Some((link, origin)), - } - drop(broadcast); - settle().await; - } - let timestamp = Timestamp::from_micros(1_000_000 + sequence * 100_000 + frame * 1_000).unwrap(); - for replica in &mut replicas { - let group = replica.group.as_mut().unwrap(); - group - .write_frame(timestamp, tagged(replica.name, sequence, frame)) - .unwrap(); - } - // The cheapest replica still up serves it. - expected.push((sequence, tagged(replicas[0].name, sequence, frame))); - seen.push(next_timed(&mut rx).await); + let mut rx = read(first.track("video").unwrap().subscribe(prefs.clone()).await.unwrap()); + let write = |replicas: &mut Vec, sequence: u64| { + for replica in replicas.iter_mut() { + let mut group = replica.track.create_group(moq_net::group::Info { sequence }).unwrap(); + group + .write_frame( + Timestamp::from_micros(1_000_000 + sequence * 1_000).unwrap(), + tagged(replica.name, sequence, 0), + ) + .unwrap(); + group.finish().unwrap(); } - for replica in &mut replicas { - replica.group.take().unwrap().finish().unwrap(); + }; + write(&mut replicas, 0); + assert_eq!(next(&mut rx).await, (0, tagged("P1", 0, 0)), "{version} {loss:?}"); + + let Replica { + origin, + broadcast, + link, + .. + } = replicas.remove(0); + let link = link.unwrap(); + let kept = match loss { + Loss::Unreachable => { + link.server.abort(Error::Cancel); + link.client.abort(Error::Cancel); + None } - } + // Keep the session and its origin up: only the broadcast goes. + Loss::Ends => Some((link, origin)), + }; + drop(broadcast); + settle().await; - let frames: Vec<_> = seen.iter().map(|(group, _, frame)| (*group, frame.clone())).collect(); - assert_eq!(frames, expected, "{version} {loss:?}"); - for pair in seen.windows(2) { - assert!( - pair[0].1.expect("timed") < pair[1].1.expect("timed"), - "{version} {loss:?}: timestamp rewound at {pair:?}" - ); - } + let second = follow.available().await.unwrap(); + assert!( + !second.is_clone(&first), + "{version} {loss:?}: handed out the old broadcast" + ); + assert_ne!(second.info().identity, first.info().identity, "{version} {loss:?}"); + // A fresh subscription on `P2`'s own stream, starting from its latest group. + let mut next_rx = read(second.track("video").unwrap().subscribe(prefs).await.unwrap()); + assert_eq!(next(&mut next_rx).await, (0, tagged("P2", 0, 0)), "{version} {loss:?}"); + write(&mut replicas, 1); + assert_eq!(next(&mut next_rx).await, (1, tagged("P2", 1, 0)), "{version} {loss:?}"); + + // Nothing of `P2` was stitched onto the first broadcast. settle().await; - assert!(rx.try_recv().is_err(), "{version} {loss:?}: trailing delivery"); + while let Ok((group, frame)) = rx.try_recv() { + if let Ok((_, frame)) = frame { + panic!( + "{version} {loss:?}: stitched onto the first broadcast at {group}:{}", + String::from_utf8_lossy(&frame) + ); + } + } drop(kept); } #[moq_clock::test] -async fn redundant_pair_fails_over_when_unreachable() { - moq_clock::timeout( - TEST_TIMEOUT, - redundant_pair_fails_over("moq-lite-07-wip", Loss::Unreachable), - ) - .await - .expect("timed out"); +async fn redundant_pair_follows_the_next_identity_when_unreachable() { + for version in ["moq-lite-07-wip", "moq-lite-06", "moq-transport-22"] { + moq_clock::timeout( + TEST_TIMEOUT, + redundant_pair_follows_the_next_identity(version, Loss::Unreachable), + ) + .await + .expect("timed out"); + } } #[moq_clock::test] -async fn redundant_pair_fails_over_when_the_incumbent_ends() { - moq_clock::timeout(TEST_TIMEOUT, redundant_pair_fails_over("moq-lite-07-wip", Loss::Ends)) +async fn redundant_pair_follows_the_next_identity_when_the_incumbent_ends() { + for version in ["moq-lite-07-wip", "moq-lite-06", "moq-transport-22"] { + moq_clock::timeout( + TEST_TIMEOUT, + redundant_pair_follows_the_next_identity(version, Loss::Ends), + ) .await .expect("timed out"); + } } -/// `P1` and `P2` both publish `live` to the relay `R`, `P2` priced to stand by, and -/// `D` reads through `R`. `P1`'s session to `R` dies mid-group. When both name one -/// epoch on a wire that carries it, `R` resumes on `P2` and `D` sees every frame once. -/// Otherwise `D`'s track ends with the error: a re-request through `R` would be -/// answered by `P2`, another instance, stitched onto what `D` already read. -async fn replaced_behind_a_relay(version: &str, epoch: Option, resumes: bool) { +/// `P1` and `P2` both publish `live` to the relay `R`, each under its own identity, `P2` +/// priced to stand by, and `D` reads through `R`. `P1`'s session to `R` dies mid-group. +/// `D`'s track ends with the error: a re-request through `R` would be answered by `P2`, +/// another stream, stitched onto what `D` already read. +async fn replaced_behind_a_relay(version: &str) { let version: Version = version.parse().unwrap(); let relay = produce_origin(3); let downstream = produce_origin(4); @@ -875,11 +881,7 @@ async fn replaced_behind_a_relay(version: &str, epoch: Option, r let publisher = produce_origin(hop); let broadcast = publisher.create_broadcast("live").unwrap(); let track = broadcast.create_track("video", timed()).unwrap(); - let route = match &epoch { - Some(epoch) => origin::Route::default().with_epoch(epoch.clone()), - None => origin::Route::default(), - }; - broadcast.announce(route).unwrap(); + broadcast.announce(origin::Route::default()).unwrap(); let link = priced_link(version, &publisher, &relay, cost).await; replicas.push(Replica { name, @@ -918,7 +920,7 @@ async fn replaced_behind_a_relay(version: &str, epoch: Option, r .write_frame(timestamp, tagged(replica.name, sequence, frame)) .unwrap(); } - if resumes || dead.is_none() { + if dead.is_none() { let expected = (sequence, tagged(replicas[0].name, sequence, frame)); assert_eq!(next(&mut rx).await, expected, "{version}"); } @@ -929,52 +931,30 @@ async fn replaced_behind_a_relay(version: &str, epoch: Option, r } settle().await; - if resumes { - assert!(rx.try_recv().is_err(), "{version}: trailing delivery"); - } else { - let mut failed = false; - while let Ok((group, frame)) = rx.try_recv() { - match frame { - Ok((_, frame)) => panic!( - "{version}: stitched onto the replacement at {group}:{}", - String::from_utf8_lossy(&frame) - ), - Err(_) => failed = true, - } + let mut failed = false; + while let Ok((group, frame)) = rx.try_recv() { + match frame { + Ok((_, frame)) => panic!( + "{version}: stitched onto the replacement at {group}:{}", + String::from_utf8_lossy(&frame) + ), + Err(_) => failed = true, } - assert!(failed, "{version}: the track did not end with the upstream error"); } + assert!(failed, "{version}: the track did not end with the upstream error"); drop((dead, replicas)); } #[moq_clock::test] -async fn replaced_without_an_epoch_ends_lite_06() { - moq_clock::timeout(TEST_TIMEOUT, replaced_behind_a_relay("moq-lite-06", None, false)) - .await - .expect("timed out"); -} - -#[moq_clock::test] -async fn replaced_without_an_epoch_ends_lite_07() { - moq_clock::timeout(TEST_TIMEOUT, replaced_behind_a_relay("moq-lite-07-wip", None, false)) - .await - .expect("timed out"); -} - -/// The relay resumes a pair sharing an epoch, so nothing reaches `D`. -#[moq_clock::test] -async fn pair_behind_a_relay_fails_over_lite_07() { - let epoch = Some(moq_net::Epoch::mint()); - moq_clock::timeout(TEST_TIMEOUT, replaced_behind_a_relay("moq-lite-07-wip", epoch, true)) +async fn replaced_under_another_identity_ends_lite_06() { + moq_clock::timeout(TEST_TIMEOUT, replaced_behind_a_relay("moq-lite-06")) .await .expect("timed out"); } -/// lite-06 carries no epoch, so the relay cannot tell the pair is one broadcast. #[moq_clock::test] -async fn pair_behind_a_relay_ends_lite_06() { - let epoch = Some(moq_net::Epoch::mint()); - moq_clock::timeout(TEST_TIMEOUT, replaced_behind_a_relay("moq-lite-06", epoch, false)) +async fn replaced_under_another_identity_ends_lite_07() { + moq_clock::timeout(TEST_TIMEOUT, replaced_behind_a_relay("moq-lite-07-wip")) .await .expect("timed out"); } @@ -986,7 +966,7 @@ async fn pair_behind_a_relay_ends_lite_06() { /// reads `P2`'s groups under `P1`'s framing, and the track ends with the refusal. async fn compression_changes_behind_a_relay(compress: bool) { let version: Version = "moq-lite-07-wip".parse().unwrap(); - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let relay = produce_origin(3); let downstream = produce_origin(4); let mut replicas = Vec::new(); @@ -997,7 +977,7 @@ async fn compression_changes_behind_a_relay(compress: bool) { .create_track("video", timed().with_compress(compress)) .unwrap(); broadcast - .announce(origin::Route::default().with_epoch(epoch.clone())) + .announce(origin::Route::default().with_identity(epoch.clone(), 0)) .unwrap(); let link = priced_link(version, &publisher, &relay, cost).await; replicas.push(Replica { @@ -1091,7 +1071,7 @@ async fn producer_replaced(version: &str) { let broadcast = publisher.create_broadcast("live").unwrap(); let mut dynamic = broadcast.requests(); broadcast - .announce(origin::Route::default().with_epoch(moq_net::Epoch::mint())) + .announce(origin::Route::default().with_identity(moq_net::Identity::random(), 0)) .unwrap(); let (tx, mut producers) = mpsc::unbounded(); moq_clock::sim::spawn(async move { diff --git a/rs/moq-net/tests/integration/routing_contracts/mod.rs b/rs/moq-net/tests/integration/routing_contracts/mod.rs index 43e87226ff3..ffb3e549218 100644 --- a/rs/moq-net/tests/integration/routing_contracts/mod.rs +++ b/rs/moq-net/tests/integration/routing_contracts/mod.rs @@ -398,7 +398,7 @@ fn overflow_is_unreachable() { // Forwarding progress: path-only requests reselect the origin at every hop. -/// Three relays in a ring, each next to its own importer of one epoch, and each +/// Three relays in a ring, each next to its own importer of one identity, and each /// giving its clockwise link `priority`. fn priority_ring(priority: i8) -> Net { let mut net = Net::new(Config::default(), 6); @@ -459,8 +459,8 @@ fn aggregation_cycles_starve_without_a_forwarding_constraint() { } } -/// Without an epoch, the serving identity is the instance. A relay that picks -/// another epochless origin within the requester's bound splices a different +/// Under another identity, the serving identity is the instance. A relay that picks +/// another unnamed origin within the requester's bound splices a different /// publisher under the requester's announced one, with no Restart. Only a /// request naming the identity keeps the instance the requester announced. #[test] diff --git a/rs/moq-net/tests/integration/track_abort_over_session.rs b/rs/moq-net/tests/integration/track_abort_over_session.rs index b94dbd47d7f..c612127bcc2 100644 --- a/rs/moq-net/tests/integration/track_abort_over_session.rs +++ b/rs/moq-net/tests/integration/track_abort_over_session.rs @@ -78,7 +78,7 @@ async fn recreate(finish: bool) { let origin = produce_origin(1); let broadcast = origin.create_broadcast("bench").unwrap(); broadcast - .announce(moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint())) + .announce(moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0)) .unwrap(); let track = broadcast.create_track("video", timed()).unwrap(); let remote = origin.consume().request("bench", None).await.unwrap(); diff --git a/rs/moq-net/tests/integration/track_info_cancel.rs b/rs/moq-net/tests/integration/track_info_cancel.rs index 089b5df834d..299a600b707 100644 --- a/rs/moq-net/tests/integration/track_info_cancel.rs +++ b/rs/moq-net/tests/integration/track_info_cancel.rs @@ -74,7 +74,7 @@ async fn withdrawn_broadcast_releases_track_info(version: &str, answered: bool, let mut dynamic = broadcast.requests(); let mut route = origin::Route::default(); if explicit { - route.epoch = Some("019a1234-5678-7000-8000-000000000001".parse().unwrap()); + route.identity = Some("019a1234-5678-7000-8000-000000000001".parse().unwrap()); } broadcast.announce(route).unwrap(); let mut options = MockConnectOptions::new(version); diff --git a/test/drill/mutations/subscriber-leaks-broadcasts.patch b/test/drill/mutations/subscriber-leaks-broadcasts.patch index f31c3f66fc2..683b2dba68f 100644 --- a/test/drill/mutations/subscriber-leaks-broadcasts.patch +++ b/test/drill/mutations/subscriber-leaks-broadcasts.patch @@ -9,10 +9,10 @@ # session is gone, so a cancelled reader's broadcast parks forever instead of # closing. Leaking only one is not a leak: the other still closes the front. diff --git a/rs/moq-net/src/lite/subscriber.rs b/rs/moq-net/src/lite/subscriber.rs -index 94175c3ec6..8f07d4f860 100644 +index a3f03296c7..3046b3af8f 100644 --- a/rs/moq-net/src/lite/subscriber.rs +++ b/rs/moq-net/src/lite/subscriber.rs -@@ -1512,7 +1512,8 @@ struct PrefixRun { +@@ -1536,7 +1536,8 @@ struct PrefixRun { /// it comes from the connect config or the peer's SETUP, neither of which /// changes for the life of the session. link_cost: u64, @@ -22,29 +22,32 @@ index 94175c3ec6..8f07d4f860 100644 // Lite06+: announce ids. Each received `active` implicitly assigns the next // per-stream ordinal; `ended`/`restart` reference it instead of repeating the // path, and lite-07 bases name it too. Tracked even for announces we drop -@@ -1600,7 +1601,7 @@ impl AnnouncePrefix { +@@ -1624,7 +1625,10 @@ impl AnnouncePrefix { let mut run: PrefixRun = PrefixRun { responder_origin, link_cost, -- announced: Announced::new(self.subscriber.announces.clone()), -+ announced: std::mem::ManuallyDrop::new(Announced::new(self.subscriber.announces.clone())), +- announced: Announced::new(self.subscriber.announces.clone(), self.subscriber.minted.clone()), ++ announced: std::mem::ManuallyDrop::new(Announced::new( ++ self.subscriber.announces.clone(), ++ self.subscriber.minted.clone(), ++ )), decoder: lite::AnnounceDecoder::default(), auth_epoch: 0, initial, -@@ -1728,7 +1729,8 @@ struct SourceServe { +@@ -1756,7 +1760,8 @@ struct SourceServe { subscriber: Subscriber, path: Path, - epoch: Option, + identity: Option, - source: crate::model::broadcast::SourceGuard, + // MUTATION: never dropped, so a session that ends keeps every source it minted alive. + source: std::mem::ManuallyDrop, dynamic: crate::broadcast::Requests, route: kio::Consumer<()>, // A dedicated close-watch handle, since each pending operation needs its own. -@@ -1871,7 +1873,7 @@ impl SourceServe { +@@ -1899,7 +1904,7 @@ impl SourceServe { subscriber, path: minted.path, - epoch: minted.epoch, + identity: minted.identity, - source: minted.source, + source: std::mem::ManuallyDrop::new(minted.source), dynamic: minted.dynamic, diff --git a/test/interop/clients/rust/audio.rs b/test/interop/clients/rust/audio.rs index 8c5d5b0ba02..39ac58f542e 100644 --- a/test/interop/clients/rust/audio.rs +++ b/test/interop/clients/rust/audio.rs @@ -86,7 +86,9 @@ async fn decode( let mut log = create("txt")?; let mut pcm = create("pcm")?; - let rendition = source.resolve(audio.broadcast.as_ref(), audio.epoch.as_ref()).await?; + let rendition = source + .resolve(audio.broadcast.as_ref(), audio.identity.as_ref()) + .await?; let mut options = moq_audio::decode::Options::new(); options.output.format = moq_audio::Format::F32; // How far the read may fall behind live before it skips. A gap fails the run, which a reader diff --git a/test/interop/clients/rust/goaway.rs b/test/interop/clients/rust/goaway.rs index 9161b1898d3..041c245c21e 100644 --- a/test/interop/clients/rust/goaway.rs +++ b/test/interop/clients/rust/goaway.rs @@ -34,7 +34,7 @@ async fn main() -> anyhow::Result<()> { let origin = moq_tokio::origin::spawn(); let broadcast = origin.create_broadcast("handover")?; let mut track = broadcast.create_track("video", moq_net::track::Info::default())?; - broadcast.announce(moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()))?; + broadcast.announce(moq_net::origin::Route::default())?; let (mut first, first_url) = endpoint(&origin, version).await?; let (mut second, second_url) = endpoint(&origin, version).await?; let script = concat!( From ddeb7967b8811c33cd6b8e27404ef18d1e2f94d1 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 31 Oct 2026 23:30:29 -0700 Subject: [PATCH 11/41] fix(net): a takeover continues requests pinned to its predecessor's identity A request through a claim pins the claim's identity on lite-07, so the job's exact route that takes the claim over must answer it: the lookup, the front's staleness check and the handed-out broadcast all accept a winner continuing the pinned identity. Co-Authored-By: Claude Opus 5.5 --- rs/moq-net/src/model/origin.rs | 41 ++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 864f62b51a2..b7b495d8716 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -3541,7 +3541,10 @@ async fn serve_front(task: FrontTask) -> Vec { } match resolved { Some(resolved) => resolved != asked, - None => pick(&table, &path, horizon, front, pin.as_ref()).is_some_and(|best| best.identity != *asked), + // A winner that took over the identity asked continues it. + None => pick(&table, &path, horizon, front, pin.as_ref()).is_some_and(|best| { + best.identity != *asked && best.takeover.as_ref().is_none_or(|takeover| takeover.identity != *asked) + }), } }; @@ -3715,6 +3718,7 @@ async fn serve_front(task: FrontTask) -> Vec { if let Ok(mut pending) = request.write() && pending.resolved.is_none() { + pending.first = Some(identity.clone()); pending.resolved = Some(Ok(identity)); pending.via = Some((route, asked)); } @@ -5519,6 +5523,9 @@ struct PendingFront { /// asked: only a [`Takeover`] after that continues it. Each route that takes the /// front over carries this forward; see [`Self::continue_with`]. via: Option<(u64, u64)>, + /// The identity the front resolved first, which each [`Takeover`] since carried on + /// as `resolved`: a request naming it reads what continues it. + first: Option, /// The identity the front resolves on unless the table moves first: the winner it /// was minted for, then each one it selects. Written under the table's lock, so a /// request never joins a front that picked an identity the request saw beaten. @@ -6021,14 +6028,24 @@ impl Lookup { RequestState::Failed(error) => Poll::Ready(Err(error.clone())), RequestState::Pending { front, broadcast } => Poll::Ready( match ready!(front.poll(waiter, |state| match &state.resolved { - Some(result) => Poll::Ready(result.clone()), + Some(result) => Poll::Ready( + result + .clone() + .map(|identity| (identity, state.first.clone())), + ), None => Poll::Pending, })) { - // Another identity than the one named: never hand it out. - Ok(Ok(identity)) if self.identity.as_ref().is_some_and(|named| *named != identity) => { + // Another identity than the one named, and not one continuing it: never hand + // it out. + Ok(Ok((identity, first))) + if self + .identity + .as_ref() + .is_some_and(|named| *named != identity && Some(named) != first.as_ref()) => + { Err(Error::Unroutable) } - Ok(Ok(identity)) => Ok(self.hand_out(broadcast.clone(), identity)), + Ok(Ok((identity, _))) => Ok(self.hand_out(broadcast.clone(), identity)), Ok(Err(err)) => Err(err), // The front's driver went away without a verdict: nobody could route it. Err(_closed) => Err(Error::Unroutable), @@ -6781,8 +6798,16 @@ impl Consumer { return kio::Pending::new(Lookup::failed(Error::Unroutable)); }; // A request naming another identity than the winner follows that identity's own - // routes: every hop resolves the identity the first one chose. - let pin = identity.filter(|identity| **identity != winner).cloned(); + // routes: every hop resolves the identity the first one chose. A winner that took + // over the named identity continues it, so the request is the winner's. + let pin = identity + .filter(|identity| { + **identity != winner + && takeover + .as_ref() + .is_none_or(|takeover| takeover.identity != **identity) + }) + .cloned(); // Only the winner's takeover can continue a front this request would join. let takeover = takeover.filter(|_| pin.is_none()); let best = match &pin { @@ -6847,6 +6872,7 @@ impl Consumer { let request = kio::Producer::new(PendingFront { resolved: None, via: None, + first: None, selected: best, refused: false, }); @@ -9806,6 +9832,7 @@ mod tests { request: kio::Producer::new(PendingFront { resolved: None, via: None, + first: None, selected: epoch(), refused: false, }), From 31364b0e1a73879054db68a833fef09e20c2ec88 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Fri, 30 Oct 2026 20:07:11 -0700 Subject: [PATCH 12/41] feat!: catalogs, archives and tools name streams by identity - hang: catalog references carry an optional `identity` in place of the UUIDv7 `epoch`, refused when empty; health observers name the identity. - moq-mux: a reference pin is checked against the resolved identity, best effort against one a session minted. - moq-cli: archive keys are `/@`; `play` resolves renditions by identity. moq-hls serves each stream's URLs under `@`. - moq-e2ee keeps its own UUIDv7 epoch and announces it as the route identity. - moq-stats keys nodes by route identity and mints its own group-frame epoch. - Ingests (RTMP, SRT, WHIP, RTSP, GStreamer) publish under the identity each broadcast mints. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + drafts/draft-lcurley-moq-sign.md | 32 +- drafts/moq-sign-00.json | 52 +-- drafts/moq-sign-00.ts | 14 +- rs/hang/examples/subscribe.rs | 2 +- rs/hang/fixtures/health.json | 58 +-- rs/hang/src/catalog/audio/mod.rs | 15 +- rs/hang/src/catalog/binary.rs | 15 +- rs/hang/src/catalog/json.rs | 15 +- rs/hang/src/catalog/reference.rs | 12 +- rs/hang/src/catalog/root.rs | 39 +- rs/hang/src/catalog/text/mod.rs | 15 +- rs/hang/src/catalog/video/mod.rs | 15 +- rs/hang/src/health.rs | 20 +- rs/moq-boy/src/main.rs | 4 +- rs/moq-cli/src/archive.rs | 91 ++-- rs/moq-cli/src/args.rs | 2 +- rs/moq-cli/src/hls.rs | 12 +- rs/moq-cli/src/main.rs | 4 +- rs/moq-cli/src/play/media.rs | 49 ++- rs/moq-cli/src/publish.rs | 17 +- rs/moq-cli/src/report.rs | 2 +- rs/moq-cli/src/rtc.rs | 4 +- rs/moq-cli/src/rtsp.rs | 4 +- rs/moq-cli/src/transcode.rs | 8 +- rs/moq-cli/tests/integration/epochs.rs | 24 +- rs/moq-cli/tests/integration/export.rs | 14 +- rs/moq-e2ee/Cargo.toml | 1 + rs/moq-e2ee/README.md | 6 +- rs/moq-e2ee/src/broadcast.rs | 5 +- rs/moq-e2ee/src/credential.rs | 2 +- rs/moq-e2ee/src/epoch.json | 33 ++ rs/moq-e2ee/src/epoch.rs | 106 +++++ rs/moq-e2ee/src/generation.rs | 2 +- rs/moq-e2ee/src/lib.rs | 9 +- rs/moq-e2ee/src/tests.rs | 2 +- rs/moq-e2ee/tests/transport.rs | 14 +- rs/moq-gst/src/sink/session.rs | 23 +- rs/moq-gst/src/source/imp.rs | 74 ++-- rs/moq-hls/src/export/mod.rs | 12 +- rs/moq-hls/src/export/rendition.rs | 2 +- rs/moq-hls/src/server/mod.rs | 24 +- rs/moq-hls/src/server/routes.rs | 6 +- rs/moq-hls/tests/integration/restart.rs | 14 +- rs/moq-mux/src/catalog/hang/consumer.rs | 2 +- rs/moq-mux/src/catalog/producer.rs | 6 +- rs/moq-mux/src/container/consumer.rs | 4 +- rs/moq-mux/src/container/source.rs | 12 +- rs/moq-mux/src/container/ts/export.rs | 24 +- rs/moq-mux/src/container/ts/export_test.rs | 174 ++++---- .../src/container/ts/export_timing_test.rs | 2 +- rs/moq-mux/src/container/ts/follower.rs | 16 +- rs/moq-mux/src/container/ts/programs.rs | 12 +- rs/moq-mux/src/error.rs | 14 +- rs/moq-mux/src/follow.rs | 54 +-- rs/moq-mux/src/json.rs | 2 +- rs/moq-mux/src/source.rs | 152 ++++--- rs/moq-relay/src/cluster.rs | 2 +- .../tests/integration/goaway_cluster.rs | 4 +- .../tests/integration/upstream_links.rs | 6 + rs/moq-rtc/src/egress.rs | 8 +- rs/moq-rtc/src/lib.rs | 10 +- rs/moq-rtc/src/server/whip.rs | 4 +- rs/moq-rtmp/src/dial.rs | 14 +- rs/moq-rtmp/src/rml/sessions/server/mod.rs | 2 +- rs/moq-rtmp/src/server.rs | 16 +- rs/moq-rtsp/src/client.rs | 6 +- rs/moq-scene/src/source.rs | 12 +- rs/moq-sign/README.md | 12 +- rs/moq-sign/benches/sign.rs | 2 +- rs/moq-sign/src/broadcast.rs | 45 +- rs/moq-sign/src/error.rs | 6 +- rs/moq-sign/src/lib.rs | 8 +- rs/moq-sign/src/scope.rs | 6 +- rs/moq-sign/src/tests.rs | 36 +- rs/moq-sign/src/track.rs | 2 +- rs/moq-srt/src/server.rs | 2 +- rs/moq-srt/src/ts.rs | 8 +- rs/moq-stats/Cargo.toml | 1 + rs/moq-stats/benches/aggregate.rs | 32 +- rs/moq-stats/benches/decode.rs | 2 +- rs/moq-stats/src/aggregate.rs | 392 +++--------------- rs/moq-stats/src/lib.rs | 25 +- rs/moq-stats/src/produce.rs | 24 +- rs/moq-studio/src/engine.rs | 2 +- rs/moq-tokio/tests/integration/broadcast.rs | 8 +- 86 files changed, 960 insertions(+), 1087 deletions(-) create mode 100644 rs/moq-e2ee/src/epoch.json create mode 100644 rs/moq-e2ee/src/epoch.rs diff --git a/Cargo.lock b/Cargo.lock index d2591188365..cf61e7e97ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5607,6 +5607,7 @@ dependencies = [ "serde_json", "thiserror 2.0.21", "tokio", + "uuid", "zeroize", ] @@ -6152,6 +6153,7 @@ dependencies = [ "thiserror 2.0.21", "tokio", "tracing", + "uuid", ] [[package]] diff --git a/drafts/draft-lcurley-moq-sign.md b/drafts/draft-lcurley-moq-sign.md index 59cb3c65a3d..e3171d4d126 100644 --- a/drafts/draft-lcurley-moq-sign.md +++ b/drafts/draft-lcurley-moq-sign.md @@ -39,7 +39,7 @@ informative: --- abstract This document specifies moq-sign-00, a versioned profile that authenticates MoQ application payloads with a per-frame Ed25519 signature. -A publisher signs each grouped frame with its endpoint key, binding the broadcast path, the publisher epoch, the track, the group, the frame index, and the frame's timestamp. +A publisher signs each grouped frame with its endpoint key, binding the broadcast path, the publisher's identity, the track, the group, the frame index, and the frame's timestamp. A subscriber that expects that key delivers a frame only after its signature verifies, so a relay can forward and cache content without forging it, retiming it, or moving it to another position. --- note_Note_to_Readers @@ -91,7 +91,7 @@ Integers are unsigned big-endian of the stated width, and `bytes(x)` is `u64(len ~~~ message = "moq-sign-00" || bytes(path) - || bytes(epoch) + || bytes(identity) || bytes(track) || u64(group) || u64(frame) @@ -104,10 +104,10 @@ message = "moq-sign-00" The UTF-8 broadcast path both ends name the broadcast by. A relay may mount a broadcast under another prefix, so this is the application's path, not the one a transport hop reports. -**epoch**: -The UUID text of the publisher instance's epoch, carried on the broadcast's route ({{moql}}). -A publisher instance MUST sign under an epoch it minted and no other instance has used. -The epoch names one sequence space: a publisher that restarts its group sequences signs under a new one. +**identity**: +The UTF-8 identity carried on the broadcast's route ({{moql}}), naming one continuous stream. +A publisher MUST sign under an identity no other publisher uses. +The identity names one sequence space: a publisher that restarts its group sequences signs under a new one. **track**: The UTF-8 track name. @@ -152,9 +152,9 @@ A subscriber MUST NOT deliver any byte of a frame before its signature verifies. A `signature` failure ends that group for the subscriber: it MUST deliver no later frame of the group. Other groups of the track continue, since each verifies on its own. -A subscriber that cannot learn the broadcast's epoch from its route MUST refuse to read it (`epoch`). +A subscriber that cannot learn the publisher's identity from the broadcast's route MUST refuse to read it (`identity`), including when a relay minted the identity for a hop that carries none. -Every hop between the publisher and the subscriber must carry the route's epoch and each frame's timestamp and timescale unchanged; in {{moql}} that is moq-lite-07. +Every hop between the publisher and the subscriber must carry the route's identity and each frame's timestamp and timescale unchanged; in {{moql}} that is moq-lite-07. A hop that cannot, such as a moq-lite-05 or -06 hop that stamps an untimed frame with its send time, causes `signature` failures rather than a frame delivered with the wrong time. Datagrams are not covered by this profile; a subscriber MUST NOT deliver datagrams from a track it reads under it. @@ -170,22 +170,22 @@ The file covers key derivation from a seed, including {{RFC8032}} Section 7.1 TE Relays, caches, and recorders are untrusted for content. The publisher's secret key is trusted; anyone holding it can sign as that endpoint. -The signature binds every field a relay could otherwise change to move or retime an authentic frame: the path, the epoch, the track, the group, the frame index, and the timestamp. -Binding the epoch keeps a frame from an earlier instance of the same broadcast, which restarted its sequences, from verifying in a later one. +The signature binds every field a relay could otherwise change to move or retime an authentic frame: the path, the identity, the track, the group, the frame index, and the timestamp. +Binding the identity keeps a frame from an earlier instance of the same broadcast, which restarted its sequences, from verifying in a later one. Within an instance, a signed position names one frame only because the publisher never signs a group sequence twice; a relay holding two groups signed at one sequence could splice their frames into a group that was never published. Binding the timestamp keeps a relay from changing when a frame presents. Any subscriber can verify but none can sign, so subscribers that share a broadcast cannot forge frames to each other, as they could with a shared symmetric key. A relay can still delay, drop, or reorder groups. -A relay that presents a wrong epoch only causes `signature` failures. +A relay that presents a wrong identity only causes `signature` failures. ## Freshness {#freshness} The profile authenticates content, not liveness. -The subscriber learns the epoch from the route, which a relay presents, so a relay that recorded an instance can later announce that epoch again and replay the instance's frames, whole or in part, after the publisher has gone or restarted. +The subscriber learns the identity from the route, which a relay presents, so a relay that recorded an instance can later announce that identity again and replay the instance's frames, whole or in part, after the publisher has gone or restarted. Every replayed frame verifies, because the publisher did sign it at that position. -Binding the epoch only keeps frames of different instances from being spliced into one. -An application that needs freshness learns the expected epoch from a channel it trusts, or checks the signed timestamps against a clock it shares with the publisher. +Binding the identity only keeps frames of different streams from being spliced into one. +An application that needs freshness learns the expected identity from a channel it trusts, or checks the signed timestamps against a clock it shares with the publisher. ## Group End {#group-end} Nothing authenticates where a group ends: a relay can end a group after any frame, and the subscriber sees an ordinary end of group. @@ -193,7 +193,7 @@ MoQ already lets a relay drop the tail of a group, for example when it falls beh An application MUST NOT treat a group's end as proof that it received every frame the publisher wrote. One that needs a complete group, such as a byte stream reading the end as a clean close, signs its own end marker as the group's last frame. -A relay can observe everything a signature does not hide: paths, epochs, track names, payloads, sizes, and timing. +A relay can observe everything a signature does not hide: paths, identities, track names, payloads, sizes, and timing. Ed25519 signing is deterministic, so no per-signature randomness can leak the key. @@ -210,4 +210,4 @@ This document requests no registrations. ## draft-lcurley-moq-sign-00 {:numbered="false"} -- Initial `moq-sign-00` profile: Ed25519 endpoint keys with a z-base-32 ID, a message binding path, epoch, track, group, frame index, and timestamp, a payload-then-signature frame body, group-ending failures, an unauthenticated group end, no freshness guarantee, and shared vectors. +- Initial `moq-sign-00` profile: Ed25519 endpoint keys with a z-base-32 ID, a message binding path, identity, track, group, frame index, and timestamp, a payload-then-signature frame body, group-ending failures, an unauthenticated group end, no freshness guarantee, and shared vectors. diff --git a/drafts/moq-sign-00.json b/drafts/moq-sign-00.json index 6f854a0d3ac..32bdc40ebe5 100644 --- a/drafts/moq-sign-00.json +++ b/drafts/moq-sign-00.json @@ -36,7 +36,7 @@ "seed": "6d6f712d7369676e2d3030207465737420736565642121212121212121212121", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -51,7 +51,7 @@ "seed": "6d6f712d7369676e2d3030207465737420736565642121212121212121212121", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 0, @@ -66,7 +66,7 @@ "seed": "6d6f712d7369676e2d3030207465737420736565642121212121212121212121", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 1099511627781, "frame": 0, @@ -81,7 +81,7 @@ "seed": "6d6f712d7369676e2d3030207465737420736565642121212121212121212121", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 4294967296, @@ -96,7 +96,7 @@ "seed": "6d6f712d7369676e2d3030207465737420736565642121212121212121212121", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 9007199254740991, "frame": 1, @@ -111,7 +111,7 @@ "seed": "6d6f712d7369676e2d3030207465737420736565642121212121212121212121", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "café", "group": 3, "frame": 1, @@ -126,7 +126,7 @@ "seed": "6d6f712d7369676e2d3030207465737420736565642121212121212121212121", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "video", "group": 3, "frame": 1, @@ -141,7 +141,7 @@ "seed": "6d6f712d7369676e2d3030207465737420736565642121212121212121212121", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "video", "group": 3, "frame": 1, @@ -156,7 +156,7 @@ "seed": "6d6f712d7369676e2d3030206f74686572207365656421212121212121212121", "endpoint": "wfc7hxboduwytxyrr35ct7oj77o6jmsidyq9teynw9obene7g7cy", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -172,7 +172,7 @@ "id": "relocated-path", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/http", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -182,10 +182,10 @@ "error": "signature" }, { - "id": "relocated-epoch", + "id": "relocated-identity", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7c", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7c", "track": "stream", "group": 3, "frame": 1, @@ -198,7 +198,7 @@ "id": "relocated-track", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream2", "group": 3, "frame": 1, @@ -211,7 +211,7 @@ "id": "relocated-group", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 4, "frame": 1, @@ -224,7 +224,7 @@ "id": "relocated-frame", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 0, @@ -237,7 +237,7 @@ "id": "shifted-boundary", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/sshs", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "tream", "group": 3, "frame": 1, @@ -250,7 +250,7 @@ "id": "relocated-timestamp", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "video", "group": 3, "frame": 1, @@ -263,7 +263,7 @@ "id": "rescaled-timestamp", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "video", "group": 3, "frame": 1, @@ -276,7 +276,7 @@ "id": "timestamp-dropped", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "video", "group": 3, "frame": 1, @@ -289,7 +289,7 @@ "id": "timestamp-added", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -302,7 +302,7 @@ "id": "wrong-key", "endpoint": "wfc7hxboduwytxyrr35ct7oj77o6jmsidyq9teynw9obene7g7cy", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -315,7 +315,7 @@ "id": "tampered-payload", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -328,7 +328,7 @@ "id": "tampered-signature", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -341,7 +341,7 @@ "id": "appended-byte", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -354,7 +354,7 @@ "id": "truncated", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, @@ -367,7 +367,7 @@ "id": "empty", "endpoint": "eyamftwcf3sck8rs5m1meugf8odwqg78473t7p3st9wxob9csz8y", "path": "mesh/node/ssh", - "epoch": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", + "identity": "019a2b3c-4d5e-7f60-8a1b-2c3d4e5f6a7b", "track": "stream", "group": 3, "frame": 1, diff --git a/drafts/moq-sign-00.ts b/drafts/moq-sign-00.ts index 9a23a4c219f..87cf3700bd0 100644 --- a/drafts/moq-sign-00.ts +++ b/drafts/moq-sign-00.ts @@ -33,7 +33,7 @@ export class ProfileError extends Error { /** One signed frame's identity. An untimed frame has timescale and timestamp 0. */ export type Identity = { path: string; - epoch: string; + identity: string; track: string; group: bigint; frame: bigint; @@ -99,7 +99,7 @@ export function message(identity: Identity, payload: Uint8Array): Uint8Array anyhow::Result<() // Resolve an announced broadcast and read media frames. async fn play(consumer: moq_net::origin::Consumer, announce: moq_net::announce::Announce) -> anyhow::Result<()> { - let broadcast = consumer.request(&announce.prefix, announce.route.epoch).await?; + let broadcast = consumer.request(&announce.prefix, announce.route.identity).await?; // Read the catalog to discover available tracks. let catalog_track = broadcast diff --git a/rs/hang/fixtures/health.json b/rs/hang/fixtures/health.json index 96c03a434d3..4fd1394a57c 100644 --- a/rs/hang/fixtures/health.json +++ b/rs/hang/fixtures/health.json @@ -5,7 +5,7 @@ "previous": { "observer": { "path": "room/live", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -43,7 +43,7 @@ "current": { "observer": { "path": "room/live", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -84,7 +84,7 @@ "health": { "interval": 4998, "observer": { - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", "path": "room/live" }, "renditions": { @@ -160,7 +160,7 @@ "previous": { "observer": { "path": "room/live", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -189,7 +189,7 @@ "current": { "observer": { "path": "room/live", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -220,7 +220,7 @@ "health": { "interval": 4521, "observer": { - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", "path": "room/live" }, "renditions": { @@ -296,7 +296,7 @@ "previous": { "observer": { "path": "room/viewers/alice.echo", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -335,7 +335,7 @@ "current": { "observer": { "path": "room/viewers/alice.echo", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -376,7 +376,7 @@ "health": { "interval": 5004, "observer": { - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", "path": "room/viewers/alice.echo" }, "renditions": { @@ -472,7 +472,7 @@ "previous": { "observer": { "path": "room/viewers/alice.echo", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -514,7 +514,7 @@ "current": { "observer": { "path": "room/viewers/alice.echo", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -558,7 +558,7 @@ "health": { "interval": 5000, "observer": { - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", "path": "room/viewers/alice.echo" }, "renditions": { @@ -654,7 +654,7 @@ "previous": { "observer": { "path": "room/viewers/alice.echo", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -696,7 +696,7 @@ "current": { "observer": { "path": "room/viewers/alice.echo", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -740,7 +740,7 @@ "health": { "interval": 5000, "observer": { - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", "path": "room/viewers/alice.echo" }, "renditions": { @@ -836,7 +836,7 @@ "previous": { "observer": { "path": "room/live", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -865,7 +865,7 @@ "current": { "observer": { "path": "room/live", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -896,7 +896,7 @@ "health": { "interval": 4998, "observer": { - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", "path": "room/live" }, "renditions": { @@ -1085,12 +1085,12 @@ } }, { - "name": "reset by an epoch change", + "name": "reset by an identity change", "role": "publisher", "previous": { "observer": { "path": "room/live", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -1119,7 +1119,7 @@ "current": { "observer": { "path": "room/live", - "epoch": "019a1774-02b9-7c18-a4e7-1d9f6b2c8e05" + "identity": "019a1774-02b9-7c18-a4e7-1d9f6b2c8e05" }, "snapshot": { "transport": { @@ -1149,7 +1149,7 @@ "age": 250, "health": { "observer": { - "epoch": "019a1774-02b9-7c18-a4e7-1d9f6b2c8e05", + "identity": "019a1774-02b9-7c18-a4e7-1d9f6b2c8e05", "path": "room/live" }, "renditions": { @@ -1210,7 +1210,7 @@ "current": { "observer": { "path": "room/cam", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -1230,7 +1230,7 @@ "age": 250, "health": { "observer": { - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", "path": "room/cam" }, "renditions": { @@ -1272,7 +1272,7 @@ "current": { "observer": { "path": "room/cam", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -1292,7 +1292,7 @@ "age": 3000, "health": { "observer": { - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", "path": "room/cam" }, "renditions": { @@ -1335,7 +1335,7 @@ "previous": { "observer": { "path": "room/live", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -1365,7 +1365,7 @@ "current": { "observer": { "path": "room/live", - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18" }, "snapshot": { "transport": { @@ -1396,7 +1396,7 @@ "health": { "interval": 4521, "observer": { - "epoch": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", + "identity": "019a1772-8c4e-7b3d-9f21-5a6e0c3d4b18", "path": "room/live" }, "renditions": { diff --git a/rs/hang/src/catalog/audio/mod.rs b/rs/hang/src/catalog/audio/mod.rs index fe1e336918b..98b3e2e6532 100644 --- a/rs/hang/src/catalog/audio/mod.rs +++ b/rs/hang/src/catalog/audio/mod.rs @@ -113,15 +113,16 @@ pub struct AudioConfig { #[serde(default)] pub track: Option, - /// The publisher instance the reference names, when the catalog knows it. Absent names - /// whichever instance the reader resolves first, as in every catalog before this field. + /// The identity of the stream the reference names, when the catalog knows it. Absent names + /// whichever stream the reader resolves first, as in every catalog before this field. /// - /// A reader on an epoch-aware route refuses another instance rather than fall back to it; - /// on an epochless route it is best effort, since the route cannot prove the instance. - /// Without [`broadcast`](Self::broadcast), it names the catalog's own instance. + /// A reader refuses a broadcast of another identity rather than fall back to it. A route + /// over a wire that carries no identity has one its session minted, which proves nothing, + /// so there it is best effort. Without [`broadcast`](Self::broadcast), it names the + /// catalog's own stream. #[serde(default)] #[serde_as(as = "Option")] - pub epoch: Option, + pub identity: Option, /// Human-readable rendition name for track pickers. #[serde(default)] @@ -220,7 +221,7 @@ impl AudioConfig { Self { broadcast: None, track: None, - epoch: None, + identity: None, label: None, codec: codec.into(), sample_rate, diff --git a/rs/hang/src/catalog/binary.rs b/rs/hang/src/catalog/binary.rs index 5a59e0c1328..3a9b1a69529 100644 --- a/rs/hang/src/catalog/binary.rs +++ b/rs/hang/src/catalog/binary.rs @@ -83,15 +83,16 @@ pub struct BinaryConfig { #[serde(default)] pub track: Option, - /// The publisher instance the reference names, when the catalog knows it. Absent names - /// whichever instance the reader resolves first, as in every catalog before this field. + /// The identity of the stream the reference names, when the catalog knows it. Absent names + /// whichever stream the reader resolves first, as in every catalog before this field. /// - /// A reader on an epoch-aware route refuses another instance rather than fall back to it; - /// on an epochless route it is best effort, since the route cannot prove the instance. - /// Without [`broadcast`](Self::broadcast), it names the catalog's own instance. + /// A reader refuses a broadcast of another identity rather than fall back to it. A route + /// over a wire that carries no identity has one its session minted, which proves nothing, + /// so there it is best effort. Without [`broadcast`](Self::broadcast), it names the + /// catalog's own stream. #[serde(default)] #[serde_as(as = "Option")] - pub epoch: Option, + pub identity: Option, /// Whether the track is a latest-value blob or an append log. Always stated: see [`Mode`]. #[serde_as(as = "serde_with::DisplayFromStr")] @@ -136,7 +137,7 @@ impl BinaryConfig { Self { broadcast: None, track: None, - epoch: None, + identity: None, mode, mime: None, bitrate: None, diff --git a/rs/hang/src/catalog/json.rs b/rs/hang/src/catalog/json.rs index 7ac1d098391..50a1d102338 100644 --- a/rs/hang/src/catalog/json.rs +++ b/rs/hang/src/catalog/json.rs @@ -81,15 +81,16 @@ pub struct JsonConfig { #[serde(default)] pub track: Option, - /// The publisher instance the reference names, when the catalog knows it. Absent names - /// whichever instance the reader resolves first, as in every catalog before this field. + /// The identity of the stream the reference names, when the catalog knows it. Absent names + /// whichever stream the reader resolves first, as in every catalog before this field. /// - /// A reader on an epoch-aware route refuses another instance rather than fall back to it; - /// on an epochless route it is best effort, since the route cannot prove the instance. - /// Without [`broadcast`](Self::broadcast), it names the catalog's own instance. + /// A reader refuses a broadcast of another identity rather than fall back to it. A route + /// over a wire that carries no identity has one its session minted, which proves nothing, + /// so there it is best effort. Without [`broadcast`](Self::broadcast), it names the + /// catalog's own stream. #[serde(default)] #[serde_as(as = "Option")] - pub epoch: Option, + pub identity: Option, /// Whether the track is a latest-value document or an append log. Always stated: see [`Mode`]. #[serde_as(as = "serde_with::DisplayFromStr")] @@ -134,7 +135,7 @@ impl JsonConfig { Self { broadcast: None, track: None, - epoch: None, + identity: None, mode, schema: None, bitrate: None, diff --git a/rs/hang/src/catalog/reference.rs b/rs/hang/src/catalog/reference.rs index 2647c1ee3d5..9eecc579495 100644 --- a/rs/hang/src/catalog/reference.rs +++ b/rs/hang/src/catalog/reference.rs @@ -4,14 +4,14 @@ use super::{AudioConfig, BinaryConfig, JsonConfig, TextConfig, VideoConfig}; /// /// An entry's key in the catalog is its ID. The track it names is `track` when set and the ID /// otherwise, in `broadcast` when set and the catalog's own broadcast otherwise. Every reader -/// subscribes through [`track`](Self::track) so the two never diverge. [`epoch`](Self::epoch) -/// optionally names the publisher instance the broadcast must be. +/// subscribes through [`track`](Self::track) so the two never diverge. +/// [`identity`](Self::identity) optionally names the stream the broadcast must be. pub trait Reference { /// The broadcast publishing the track, relative to the catalog's; `None` is the catalog's own. fn broadcast(&self) -> Option<&moq_net::path::Relative>; - /// The publisher instance the broadcast must be; `None` accepts the first one resolved. - fn epoch(&self) -> Option<&moq_net::Epoch>; + /// The identity the broadcast must have; `None` accepts the first one resolved. + fn identity(&self) -> Option<&moq_net::Identity>; /// The track's name in its broadcast, given this entry's ID. fn track<'a>(&'a self, id: &'a str) -> &'a str; @@ -24,8 +24,8 @@ macro_rules! reference { self.broadcast.as_ref() } - fn epoch(&self) -> Option<&moq_net::Epoch> { - self.epoch.as_ref() + fn identity(&self) -> Option<&moq_net::Identity> { + self.identity.as_ref() } fn track<'a>(&'a self, id: &'a str) -> &'a str { diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 8fd98c304b3..e9395d1515a 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -456,23 +456,23 @@ mod test { assert_eq!(Catalog::<()>::from_str(&catalog.to_json().unwrap()).unwrap(), catalog); } - /// Every reference-bearing section carries an optional epoch beside its `broadcast`. + /// Every reference-bearing section carries an optional identity beside its `broadcast`. #[test] - fn reference_epochs_roundtrip_in_every_section() { + fn reference_identities_roundtrip_in_every_section() { use crate::catalog::Reference; const EPOCH: &str = "01890a5d-ac96-774b-bcce-b302099a8057"; let encoded = format!( r#"{{ - "video": {{"renditions": {{"v": {{"broadcast": "./a", "epoch": "{EPOCH}", "codec": "avc1.64001f", "container": {{"kind": "legacy"}}}}}}}}, - "audio": {{"renditions": {{"a": {{"broadcast": "./a", "epoch": "{EPOCH}", "codec": "opus", "sampleRate": 48000, "numberOfChannels": 2, "container": {{"kind": "legacy"}}}}}}}}, - "text": {{"renditions": {{"t": {{"broadcast": "./a", "epoch": "{EPOCH}", "format": "vtt"}}}}}}, - "json": {{"tracks": {{"j": {{"broadcast": "./a", "epoch": "{EPOCH}", "mode": "snapshot"}}}}}}, - "binary": {{"tracks": {{"b": {{"broadcast": "./a", "epoch": "{EPOCH}", "mode": "snapshot"}}}}}} + "video": {{"renditions": {{"v": {{"broadcast": "./a", "identity": "{EPOCH}", "codec": "avc1.64001f", "container": {{"kind": "legacy"}}}}}}}}, + "audio": {{"renditions": {{"a": {{"broadcast": "./a", "identity": "{EPOCH}", "codec": "opus", "sampleRate": 48000, "numberOfChannels": 2, "container": {{"kind": "legacy"}}}}}}}}, + "text": {{"renditions": {{"t": {{"broadcast": "./a", "identity": "{EPOCH}", "format": "vtt"}}}}}}, + "json": {{"tracks": {{"j": {{"broadcast": "./a", "identity": "{EPOCH}", "mode": "snapshot"}}}}}}, + "binary": {{"tracks": {{"b": {{"broadcast": "./a", "identity": "{EPOCH}", "mode": "snapshot"}}}}}} }}"# ); let catalog = Catalog::<()>::from_str(&encoded).unwrap(); - let epoch: moq_net::Epoch = EPOCH.parse().unwrap(); + let epoch: moq_net::Identity = EPOCH.parse().unwrap(); let references: [&dyn Reference; 5] = [ &catalog.video.renditions["v"], &catalog.audio.renditions["a"], @@ -481,20 +481,17 @@ mod test { &catalog.binary.tracks["b"], ]; for reference in references { - assert_eq!(reference.epoch(), Some(&epoch)); + assert_eq!(reference.identity(), Some(&epoch)); } assert_eq!(Catalog::<()>::from_str(&catalog.to_json().unwrap()).unwrap(), catalog); - // Omitted stays omitted, and a malformed or noncanonical epoch refuses the catalog. - assert!(!Catalog::<()>::default().to_json().unwrap().contains("poch")); - for bad in [ - "not-a-uuid", - "01890A5D-AC96-774B-BCCE-B302099A8057", - "01890a5d-ac96-474b-bcce-b302099a8057", - ] { - let encoded = encoded.replace(EPOCH, bad); - assert!(Catalog::<()>::from_str(&encoded).is_err(), "{bad} must be refused"); - } + // Omitted stays omitted, and an empty identity refuses the catalog. + assert!(!Catalog::<()>::default().to_json().unwrap().contains("identity")); + let encoded = encoded.replace(EPOCH, ""); + assert!( + Catalog::<()>::from_str(&encoded).is_err(), + "an empty identity must be refused" + ); } #[test] @@ -639,7 +636,7 @@ mod test { VideoConfig { broadcast: None, track: None, - epoch: None, + identity: None, label: None, codec: H264 { profile: 0x64, @@ -677,7 +674,7 @@ mod test { AudioConfig { broadcast: None, track: None, - epoch: None, + identity: None, label: None, codec: Opus, sample_rate: 48_000, diff --git a/rs/hang/src/catalog/text/mod.rs b/rs/hang/src/catalog/text/mod.rs index a0b7b49b34d..66d521cecf2 100644 --- a/rs/hang/src/catalog/text/mod.rs +++ b/rs/hang/src/catalog/text/mod.rs @@ -89,15 +89,16 @@ pub struct TextConfig { #[serde(default)] pub track: Option, - /// The publisher instance the reference names, when the catalog knows it. Absent names - /// whichever instance the reader resolves first, as in every catalog before this field. + /// The identity of the stream the reference names, when the catalog knows it. Absent names + /// whichever stream the reader resolves first, as in every catalog before this field. /// - /// A reader on an epoch-aware route refuses another instance rather than fall back to it; - /// on an epochless route it is best effort, since the route cannot prove the instance. - /// Without [`broadcast`](Self::broadcast), it names the catalog's own instance. + /// A reader refuses a broadcast of another identity rather than fall back to it. A route + /// over a wire that carries no identity has one its session minted, which proves nothing, + /// so there it is best effort. Without [`broadcast`](Self::broadcast), it names the + /// catalog's own stream. #[serde(default)] #[serde_as(as = "Option")] - pub epoch: Option, + pub identity: Option, /// The serialization format of each cue payload. #[serde_as(as = "DisplayFromStr")] @@ -151,7 +152,7 @@ impl TextConfig { Self { broadcast: None, track: None, - epoch: None, + identity: None, format, role: TextRole::default(), lang: None, diff --git a/rs/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index a52a474a005..f21c59215fc 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -200,15 +200,16 @@ pub struct VideoConfig { #[serde(default)] pub track: Option, - /// The publisher instance the reference names, when the catalog knows it. Absent names - /// whichever instance the reader resolves first, as in every catalog before this field. + /// The identity of the stream the reference names, when the catalog knows it. Absent names + /// whichever stream the reader resolves first, as in every catalog before this field. /// - /// A reader on an epoch-aware route refuses another instance rather than fall back to it; - /// on an epochless route it is best effort, since the route cannot prove the instance. - /// Without [`broadcast`](Self::broadcast), it names the catalog's own instance. + /// A reader refuses a broadcast of another identity rather than fall back to it. A route + /// over a wire that carries no identity has one its session minted, which proves nothing, + /// so there it is best effort. Without [`broadcast`](Self::broadcast), it names the + /// catalog's own stream. #[serde(default)] #[serde_as(as = "Option")] - pub epoch: Option, + pub identity: Option, /// Human-readable rendition name for track pickers. #[serde(default)] @@ -370,7 +371,7 @@ impl VideoConfig { Self { broadcast: None, track: None, - epoch: None, + identity: None, label: None, codec: codec.into(), description: None, diff --git a/rs/hang/src/health.rs b/rs/hang/src/health.rs index 57a9ad14ffd..c2d2e7ad06f 100644 --- a/rs/hang/src/health.rs +++ b/rs/hang/src/health.rs @@ -41,16 +41,16 @@ pub enum Role { pub struct Observer { /// The publisher's media broadcast, or the viewer's echo broadcast. pub path: moq_net::Path, - /// The reporting broadcast's publisher epoch, when its route carries one. + /// The reporting broadcast's identity, when it was resolved through an origin. #[serde_as(as = "Option")] #[serde(skip_serializing_if = "Option::is_none")] - pub epoch: Option, + pub identity: Option, } impl Observer { /// Name the broadcast a report was read from. - pub fn new(path: moq_net::Path, epoch: Option) -> Self { - Self { path, epoch } + pub fn new(path: moq_net::Path, identity: Option) -> Self { + Self { path, identity } } } @@ -207,7 +207,7 @@ impl Default for Thresholds { pub enum Reset { /// A cumulative counter went down. Decrease, - /// The reporting broadcast's path or epoch changed. + /// The reporting broadcast's path or identity changed. Replaced, } @@ -633,7 +633,7 @@ mod tests { #[derive(Deserialize)] struct Named { path: String, - epoch: Option, + identity: Option, } fn cases() -> Vec { @@ -641,8 +641,12 @@ mod tests { } fn report(fixture: &Fixture) -> Report { - let epoch = fixture.observer.epoch.as_deref().map(|epoch| epoch.parse().unwrap()); - let observer = Observer::new(moq_net::Path::new(&fixture.observer.path).unwrap(), epoch); + let identity = fixture + .observer + .identity + .as_deref() + .map(|identity| identity.parse().unwrap()); + let observer = Observer::new(moq_net::Path::new(&fixture.observer.path).unwrap(), identity); Report::new(observer, serde_json::from_value(fixture.snapshot.clone()).unwrap()) } diff --git a/rs/moq-boy/src/main.rs b/rs/moq-boy/src/main.rs index deff337f231..8c2c4ffdac3 100644 --- a/rs/moq-boy/src/main.rs +++ b/rs/moq-boy/src/main.rs @@ -260,9 +260,9 @@ async fn run(config: &Config) -> Result<()> { let broadcast = publish_origin .create_broadcast(&broadcast_path) .context("failed to create broadcast")?; - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); broadcast - .announce(moq_net::origin::Route::default().with_epoch(epoch.clone())) + .announce(moq_net::origin::Route::default().with_identity(epoch.clone(), 0)) .context("failed to announce broadcast")?; tracing::info!(broadcast = %broadcast_path, %epoch, "announced"); diff --git a/rs/moq-cli/src/archive.rs b/rs/moq-cli/src/archive.rs index 4adcd2be007..a22cec6e255 100644 --- a/rs/moq-cli/src/archive.rs +++ b/rs/moq-cli/src/archive.rs @@ -46,14 +46,14 @@ pub struct ImportArgs { pub refresh: Option, } -/// One publisher instance being recorded: its path, and its epoch when its route has one. -type Instance = (moq_net::Path, Option); +/// One stream being recorded: its path, and its route's identity. +type Instance = (moq_net::Path, moq_net::Identity); -/// Record every broadcast announced at or under `prefix` into `args.store`, each publisher -/// instance under `/@`, until the announcements end. +/// Record every broadcast announced at or under `prefix` into `args.store`, each stream under +/// `/@`, until the announcements end. /// -/// A route without an epoch names no instance, so each one it announces records under an epoch -/// minted here: a restarted export never resumes it, and never stitches two instances together. +/// A wire that carries no identity has its relay mint one per announcement, so each one records +/// under a key of its own: a restarted export never stitches two streams together. pub async fn export(origin: moq_net::origin::Consumer, prefix: String, args: ExportArgs) -> anyhow::Result<()> { let dvr = match (args.retention, args.retention_grace) { (Some(window), grace) => Some(moq_archive::producer::Dvr::new( @@ -81,14 +81,17 @@ pub async fn export(origin: moq_net::origin::Consumer, prefix: String, args: Exp let instance = tokio::select! { event = announced.next() => { let announce = match event.context("announcements ended")? { - // Another publisher instance, or the same one announced again. + // Another identity, or the same one announced again. moq_net::announce::Event::Start(announce) | moq_net::announce::Event::Restart(announce) | moq_net::announce::Event::Takeover { announce, .. } => announce, _ => continue, }; if !announce.prefix.has_prefix(&prefix) { continue; } - let instance: Instance = (announce.prefix, announce.route.epoch); + let Some(identity) = announce.route.identity else { + continue; + }; + let instance: Instance = (announce.prefix, identity); if !running.start(&instance) { continue; } @@ -107,7 +110,7 @@ pub async fn export(origin: moq_net::origin::Consumer, prefix: String, args: Exp let store = moq_archive::Store::new(store.clone(), key); // Requested now, so an announcement resolves the instance serving it when read, not // whichever one serves by the time its task runs. - let request = origin.request(&instance.0, instance.1.clone()); + let request = origin.request(&instance.0, Some(instance.1.clone())); recordings.spawn(record(request, instance, store, config.clone(), claims.clone())); } } @@ -153,18 +156,14 @@ impl Drop for Claim { } } -/// The epoch-pinned instances recording, so each records under its key once at a time, and again -/// when it was announced anew meanwhile. +/// The streams recording, so each records under its key once at a time, and again when it was +/// announced anew meanwhile. #[derive(Default)] struct Running(HashMap); impl Running { - /// Whether to record `instance` now that it was announced. An epochless one is a new instance - /// each time, under a fresh key. + /// Whether to record `instance` now that it was announced. fn start(&mut self, instance: &Instance) -> bool { - if instance.1.is_none() { - return true; - } match self.0.get_mut(instance) { Some(again) => { *again = true; @@ -218,12 +217,11 @@ async fn record( (instance, result) } -/// The key prefix one instance records under: its path's segments, then `@`. +/// The key prefix one stream records under: its path's segments, then `@`. /// /// A path segment's key never starts with `@`, since a leading one is percent-encoded like `%` -/// always is, so no instance's prefix lies inside another's, whatever paths a publisher picks. -fn key(root: &Path, (path, epoch): &Instance) -> anyhow::Result { - let epoch = epoch.clone().unwrap_or_else(moq_net::Epoch::mint); +/// always is, so no stream's prefix lies inside another's, whatever paths a publisher picks. +fn key(root: &Path, (path, identity): &Instance) -> anyhow::Result { let mut key = root.clone(); for segment in path.as_str().split('/').filter(|segment| !segment.is_empty()) { let part = PathPart::from(segment); @@ -232,8 +230,8 @@ fn key(root: &Path, (path, epoch): &Instance) -> anyhow::Result { None => key.join(part), }; } - let epoch = format!("@{epoch}"); - Ok(key.join(PathPart::parse(&epoch).context("an epoch is one key segment")?)) + // Percent-encoded like any segment, so an identity holding a delimiter stays one segment. + Ok(key.join(PathPart::from(format!("@{identity}").as_str()))) } /// Republish the recording at `args.store` as the broadcast `name`, announced under `epoch`. @@ -245,7 +243,7 @@ pub async fn import( origin: moq_net::origin::Producer, name: String, args: ImportArgs, - epoch: moq_net::Epoch, + epoch: moq_net::Identity, ) -> anyhow::Result<()> { let store = open(&args.store)?; let mut config = moq_archive::consumer::Config::default(); @@ -257,7 +255,7 @@ pub async fn import( .await .with_context(|| format!("no readable recording at {}", args.store))?; broadcast - .announce(moq_net::origin::Route::default().with_epoch(epoch)) + .announce(moq_net::origin::Route::default().with_identity(epoch, 0)) .context("failed to announce broadcast")?; tracing::info!(%name, store = %args.store, "replaying"); @@ -335,9 +333,9 @@ mod tests { let mut group = track.create_group(group::Info { sequence: 0 }).unwrap(); group.write_frame(Timestamp::from_millis(0).unwrap(), path).unwrap(); group.finish().unwrap(); - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); broadcast - .announce(moq_net::origin::Route::default().with_epoch(epoch.clone())) + .announce(moq_net::origin::Route::default().with_identity(epoch.clone(), 0)) .unwrap(); epochs.push(epoch); broadcasts.push((broadcast, track)); @@ -415,14 +413,14 @@ mod tests { store: url, refresh: None, }; - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let _import = tokio::spawn(import(origin.clone(), "replay".into(), args, epoch.clone())); let mut announced = origin.consume().announced(); moq_clock::timeout(Duration::from_secs(10), async { loop { if let Some(moq_net::announce::Event::Start(announce)) = announced.next().await { assert_eq!(announce.prefix.as_str(), "replay"); - assert_eq!(announce.route.epoch, Some(epoch.clone())); + assert_eq!(announce.route.identity, Some(epoch.clone())); break; } } @@ -436,17 +434,12 @@ mod tests { assert_eq!(&frame.payload[..], b"frame 0"); } - /// An epochless route announced again while its last recording drains is a new recording, and - /// an epoch-pinned one records again once its recording ends. + /// A stream announced again while its last recording drains records again once that + /// recording ends. #[test] fn a_re_announced_instance_is_recorded() { let mut running = Running::default(); - let epochless = (moq_net::Path::from_static("live/a"), None); - assert!(running.start(&epochless)); - assert!(running.start(&epochless)); - assert!(!running.done(&epochless)); - - let pinned = (moq_net::Path::from_static("live/b"), Some(moq_net::Epoch::mint())); + let pinned = (moq_net::Path::from_static("live/b"), moq_net::Identity::random()); assert!(running.start(&pinned)); assert!(!running.start(&pinned)); assert!(running.done(&pinned)); @@ -474,38 +467,34 @@ mod tests { assert!(claims.0.lock().unwrap().is_empty()); } - /// A route without an epoch records under one minted per instance, so two instances never - /// share a key prefix. + /// A stream records under its identity, which stays one key segment even when it holds a + /// delimiter. #[test] - fn an_epochless_instance_gets_a_fresh_key() { - let instance = (moq_net::Path::from_static("live/a"), None); - let first = key(&Path::from("rec"), &instance).unwrap(); - let second = key(&Path::from("rec"), &instance).unwrap(); - assert_ne!(first, second); - assert!(first.as_ref().starts_with("rec/live/a/"), "{first}"); - - let epoch = moq_net::Epoch::mint(); - let pinned = (moq_net::Path::from_static("live/a"), Some(epoch.clone())); + fn an_identity_keys_one_segment() { + let epoch = moq_net::Identity::random(); + let pinned = (moq_net::Path::from_static("live/a"), epoch.clone()); assert_eq!( key(&Path::from("rec"), &pinned).unwrap().as_ref(), format!("rec/live/a/@{epoch}") ); + let slashed = (moq_net::Path::from_static("live/a"), "a/b".parse().unwrap()); + assert_eq!(key(&Path::from("rec"), &slashed).unwrap().parts().count(), 4); } /// A path segment's key never starts with `@`, so a path that spells out another instance's /// key prefix records beside it, never inside it. #[test] fn keys_are_prefix_free() { - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let victim = key( &Path::from("rec"), - &(moq_net::Path::from_static("live/a"), Some(epoch.clone())), + &(moq_net::Path::from_static("live/a"), epoch.clone()), ) .unwrap(); let hostile = format!("live/a/@{epoch}"); let nested = key( &Path::from("rec"), - &(moq_net::Path::new(&hostile).unwrap(), Some(moq_net::Epoch::mint())), + &(moq_net::Path::new(&hostile).unwrap(), moq_net::Identity::random()), ) .unwrap(); assert!(nested.prefix_match(&victim).is_none(), "{nested} is under {victim}"); @@ -517,7 +506,7 @@ mod tests { // `%` is escaped too, so an escaped-looking segment keys apart from the `@` one. let literal = key( &Path::from("rec"), - &(moq_net::Path::new(&format!("live/a/%40{epoch}")).unwrap(), Some(epoch)), + &(moq_net::Path::new(&format!("live/a/%40{epoch}")).unwrap(), epoch), ) .unwrap(); assert!(literal.as_ref().starts_with("rec/live/a/%2540"), "{literal}"); diff --git a/rs/moq-cli/src/args.rs b/rs/moq-cli/src/args.rs index 4dfbdc15b57..81c5c367aa2 100644 --- a/rs/moq-cli/src/args.rs +++ b/rs/moq-cli/src/args.rs @@ -2054,7 +2054,7 @@ mod tests { #[test] fn independent_importers_cannot_select_a_shared_epoch() { - let epoch = hang::moq_net::Epoch::mint().to_string(); + let epoch = hang::moq_net::Identity::random().to_string(); let refused = Invocation::try_parse_from([ "moq", "--connect", diff --git a/rs/moq-cli/src/hls.rs b/rs/moq-cli/src/hls.rs index a10b1890769..7b6a1bf269b 100644 --- a/rs/moq-cli/src/hls.rs +++ b/rs/moq-cli/src/hls.rs @@ -162,7 +162,7 @@ impl Tls { /// Pull a remote HLS/LL-HLS playlist (URL or file path) into the Origin under `target.name`, /// announced under `epoch`. Once the playlist ends, serve the finished broadcast for the linger. -pub async fn import(target: ImportTarget, args: ImportArgs, epoch: moq_net::Epoch) -> anyhow::Result<()> { +pub async fn import(target: ImportTarget, args: ImportArgs, epoch: moq_net::Identity) -> anyhow::Result<()> { let ImportTarget { origin, name, @@ -217,7 +217,7 @@ async fn run_announced( importer: &mut moq_hls::import::Import, listed: &mut moq_mux::catalog::hang::Consumer, publication: &moq_net::origin::Publication, - epoch: moq_net::Epoch, + epoch: moq_net::Identity, ) -> anyhow::Result<()> { let run = importer.run(); tokio::pin!(run); @@ -234,7 +234,7 @@ async fn run_announced( }; anyhow::ensure!(catalog.is_some(), "the playlist ended before it declared a track"); publication - .announce(moq_net::origin::Route::default().with_epoch(epoch)) + .announce(moq_net::origin::Route::default().with_identity(epoch, 0)) .context("failed to announce broadcast")?; if !ended { run.await?; @@ -415,7 +415,7 @@ mod test { async fn an_ended_playlist_announces_its_catalog() { for _ in 0..32 { let (_dir, mut importer, mut listed, producer) = joined_then_ended(1).await.unwrap(); - run_announced(&mut importer, &mut listed, &producer, moq_net::Epoch::mint()) + run_announced(&mut importer, &mut listed, &producer, moq_net::Identity::random()) .await .unwrap(); assert!(producer.route().is_some(), "the finished playlist was not announced"); @@ -436,7 +436,7 @@ mod test { #[tokio::test] async fn an_empty_playlist_fails_unannounced() { let (_dir, mut importer, mut listed, producer) = joined_then_ended(0).await.unwrap(); - let result = run_announced(&mut importer, &mut listed, &producer, moq_net::Epoch::mint()).await; + let result = run_announced(&mut importer, &mut listed, &producer, moq_net::Identity::random()).await; assert!(producer.route().is_none(), "an empty playlist was announced"); let err = result.expect_err("an empty playlist imports nothing"); assert!(format!("{err:#}").contains("before it declared a track"), "{err:#}"); @@ -521,7 +521,7 @@ mod test { playlist: playlist.to_str().unwrap().to_string(), passthrough: false, }; - let importing = tokio::spawn(import(target, args, moq_net::Epoch::mint())); + let importing = tokio::spawn(import(target, args, moq_net::Identity::random())); announced.next().await.expect("announced"); let broadcast = consumer.request("hls", None).await.unwrap(); diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index a3c74d6d564..6c31b812e85 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -575,7 +575,7 @@ async fn run_stages(moq: MoqSide, stages: Vec, net: Net) -> anyhow::Res let mut stdout = None; // Each run publishes its own content and therefore mints its own instance. - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); tracing::info!(%epoch, "publisher instance"); for stage in stages { @@ -714,7 +714,7 @@ fn spawn_import( origin: &moq_net::origin::Producer, import: Import, name: String, - epoch: moq_net::Epoch, + epoch: moq_net::Identity, bandwidth: moq_net::bandwidth::Allocator, tasks: &mut JoinSet>, ) -> anyhow::Result> { diff --git a/rs/moq-cli/src/play/media.rs b/rs/moq-cli/src/play/media.rs index 193e4106c0b..d97e7bf727f 100644 --- a/rs/moq-cli/src/play/media.rs +++ b/rs/moq-cli/src/play/media.rs @@ -96,7 +96,7 @@ impl Media { }; tracing::info!( broadcast = %self.broadcast, - epoch = broadcast.info().epoch.as_ref().map(tracing::field::display), + epoch = broadcast.info().identity.as_ref().map(tracing::field::display), "online" ); @@ -255,7 +255,10 @@ impl Media { // A rendition pointing at a broadcast we can't reach is that // rendition's problem, not the catalog's: fall through to the // next one like an unsupported codec does. - let rendition = match source.resolve(config.broadcast.as_ref(), config.epoch.as_ref()).await { + let rendition = match source + .resolve(config.broadcast.as_ref(), config.identity.as_ref()) + .await + { Ok(rendition) => rendition, Err(err) => { tracing::warn!(track = name, %err, "cannot resolve video rendition"); @@ -369,7 +372,10 @@ impl Media { if playback.finished(&reference) { continue; } - let rendition = match source.resolve(config.broadcast.as_ref(), config.epoch.as_ref()).await { + let rendition = match source + .resolve(config.broadcast.as_ref(), config.identity.as_ref()) + .await + { Ok(rendition) => rendition, Err(err) => { tracing::warn!(track = name, %err, "cannot resolve audio rendition"); @@ -1712,10 +1718,9 @@ mod tests { (broadcast, catalog, audio) } - /// Answer the next request through `claim` with one `audio` rendition, under `route`'s epoch. + /// Answer the next request through `claim` with one `audio` rendition, under a fresh identity. async fn answer( claim: &moq_net::origin::Requests, - route: &moq_net::origin::Route, ) -> ( moq_net::origin::Publication, catalog::Producer, @@ -1725,7 +1730,7 @@ mod tests { .await .expect("the claim is asked") .unwrap(); - let prepared = request.prepare(route.epoch.clone().expect("an epoch")).unwrap(); + let prepared = request.prepare().unwrap(); let mut broadcast = prepared.broadcast().clone(); let catalog = catalog::Producer::new(&mut broadcast, Default::default()).unwrap(); let audio = rendition(&broadcast, &catalog, "audio"); @@ -1774,7 +1779,7 @@ mod tests { const OLD: f32 = 0.25; const NEW: f32 = 0.5; let origin = moq_tokio::origin::spawn(); - let epoch = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let epoch = || moq_net::origin::Route::default(); let (_old_broadcast, _old_catalog, mut old) = publish(&origin, epoch()); let recorder = Recorder::default(); @@ -1814,10 +1819,10 @@ mod tests { assert!(recorder.events().iter().any(|event| matches!(event, Event::Failed(_)))); } - /// Without epochs (moq-lite 06), a restart that overlaps the old route still - /// replaces it: the newest announcement wins the path. + /// A restart that overlaps the old route still replaces it: it is a new identity, and + /// the newest announcement at the exact path wins it. #[tokio::test] - async fn an_epochless_republish_plays_the_new_broadcast() { + async fn an_overlapping_republish_plays_the_new_broadcast() { tokio::time::pause(); const OLD: f32 = 0.25; @@ -1830,9 +1835,8 @@ mod tests { write(&mut old, 10, OLD).await; let (_new_broadcast, _new_catalog, mut new) = publish(&origin, Default::default()); - // A restart without an epoch waits out the origin's update hold, since a - // withdrawal wave's stale route looks just like one, and the player joins the - // new broadcast at its live edge. + // Give the player the update hold to see the restart, so it joins the new + // broadcast at its live edge. moq_clock::sleep(moq_net::origin::Config::default().update_hold).await; write(&mut new, 10, NEW).await; @@ -1944,8 +1948,8 @@ mod tests { assert_eq!(Track::of(&source, &config, "audio"), absent); } - /// A covering route of the same epoch that arrives as the exact route goes, both - /// before the player looks, is the path served again after a gap. A player with + /// A covering route that arrives as the exact route goes, both before the player + /// looks, is the path served again after a gap. A player with /// nothing playing plays it. #[tokio::test] async fn a_covering_route_after_a_gap_plays_while_idle() { @@ -1954,7 +1958,7 @@ mod tests { const OLD: f32 = 0.25; const NEW: f32 = 0.5; let origin = moq_tokio::origin::spawn(); - let route = moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = moq_net::origin::Route::default(); let (exact, mut exact_catalog, mut old) = publish(&origin, route.clone()); let recorder = Recorder::default(); @@ -1968,7 +1972,7 @@ mod tests { let pool = origin.requests("").unwrap(); drop(exact); - let (served, served_catalog, mut new) = answer(&pool, &route).await; + let (served, served_catalog, mut new) = answer(&pool).await; write(&mut new, 10, NEW).await; settle(player, &recorder).await; @@ -1981,9 +1985,8 @@ mod tests { } /// The exact route goes while the old run still holds its delay, and a covering - /// route of the same epoch takes over. Playback reaches the covering route, whether - /// the announcements show the gap (a restart) or the origin rides it out (the same - /// run carrying on). + /// route takes over under another identity. Playback reaches the covering route as + /// a restart. #[tokio::test] async fn a_handoff_to_a_covering_route_plays_on_while_draining() { tokio::time::pause(); @@ -1991,7 +1994,7 @@ mod tests { const OLD: f32 = 0.25; const NEW: f32 = 0.5; let origin = moq_tokio::origin::spawn(); - let route = moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = moq_net::origin::Route::default(); // One instance, served by the exact route and then answered again behind a covering one. let (exact, exact_catalog, mut old) = publish(&origin, route.clone()); @@ -2004,7 +2007,7 @@ mod tests { exact.unannounce(); let pool = origin.requests("").unwrap(); - let (mirror, mirror_catalog, mut copy) = answer(&pool, &route).await; + let (mirror, mirror_catalog, mut copy) = answer(&pool).await; for index in 50..60 { copy.write(packet(index, NEW)).unwrap(); moq_clock::sleep(PACKET_DURATION).await; @@ -2026,7 +2029,7 @@ mod tests { const SAMPLE: f32 = 0.25; let origin = moq_tokio::origin::spawn(); - let route = moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = moq_net::origin::Route::default(); let (broadcast, _catalog, mut audio) = publish(&origin, route.clone()); let recorder = Recorder::default(); diff --git a/rs/moq-cli/src/publish.rs b/rs/moq-cli/src/publish.rs index 8d2982a69c8..b1771760ec0 100644 --- a/rs/moq-cli/src/publish.rs +++ b/rs/moq-cli/src/publish.rs @@ -617,8 +617,8 @@ impl Publish { /// /// A capture announces now. A stdin import announces from [`run`](Self::run), once the input /// has declared its tracks: see [`Pending`]. - pub fn announce(&mut self, epoch: moq_net::Epoch) -> anyhow::Result<()> { - let route = moq_net::origin::Route::default().with_epoch(epoch); + pub fn announce(&mut self, epoch: moq_net::Identity) -> anyhow::Result<()> { + let route = moq_net::origin::Route::default().with_identity(epoch, 0); match (&self.source, &self.publication) { (_, None) => {} (Source::Stream { .. }, Some(_)) => self.route = Some(route), @@ -739,9 +739,12 @@ impl Republish { if let Some(echo) = &self.echo { catalog.mutate(|catalog| catalog.echo = Some(echo.clone()))?; } - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); tracing::info!(%epoch, "publisher instance"); - let pending = Pending::new(control.clone(), moq_net::origin::Route::default().with_epoch(epoch))?; + let pending = Pending::new( + control.clone(), + moq_net::origin::Route::default().with_identity(epoch, 0), + )?; Ok(Instance { decoder: PublishDecoder::Ts(Box::new(import)), catalog: PublishCatalog::Ts(catalog), @@ -2095,7 +2098,7 @@ pub(crate) mod tests { let Source::Stream { decoder, catalog } = publish.source else { panic!("expected a stream source"); }; - let route = moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = moq_net::origin::Route::default(); let pending = Pending::new(broadcast, route).unwrap(); let (mut stdin, input_end) = tokio::io::duplex(input.len()); let publication = Publication { @@ -2207,7 +2210,7 @@ pub(crate) mod tests { moq_net::path::Relative::new("viewer").unwrap(), )) .unwrap(); - let first = moq_net::Epoch::mint(); + let first = moq_net::Identity::random(); publish.announce(first.clone()).unwrap(); #[cfg_attr( not(feature = "capture"), @@ -2239,7 +2242,7 @@ pub(crate) mod tests { loop { use moq_net::announce::Event; if let Event::Start(announce) | Event::Restart(announce) = announced.next().await.unwrap() { - break announce.route.epoch.unwrap(); + break announce.route.identity.unwrap(); } } } => epoch, diff --git a/rs/moq-cli/src/report.rs b/rs/moq-cli/src/report.rs index ad2b10f86be..16de474f836 100644 --- a/rs/moq-cli/src/report.rs +++ b/rs/moq-cli/src/report.rs @@ -351,7 +351,7 @@ impl Echo { let broadcast = self.target.origin.create_broadcast(path)?; let tracks = ReportTrack::create(&mut broadcast.broadcast().clone(), hang::echo::TRACK)?; // A fresh instance, so a reader sees a replaced feedback broadcast as a restart. - broadcast.announce(moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()))?; + broadcast.announce(moq_net::origin::Route::default())?; Ok(Feedback { path: path.clone(), _broadcast: broadcast, diff --git a/rs/moq-cli/src/rtc.rs b/rs/moq-cli/src/rtc.rs index 00812a9ae48..6d8a8f0cc3d 100644 --- a/rs/moq-cli/src/rtc.rs +++ b/rs/moq-cli/src/rtc.rs @@ -90,7 +90,7 @@ fn scope_producer(origin: &moq_net::origin::Producer, name: &str) -> anyhow::Res /// WHEP client: pull a remote broadcast into the Origin under `target.name` (import), /// announced under `epoch`. -pub async fn connect_import(target: ImportTarget, url: Url, epoch: moq_net::Epoch) -> anyhow::Result<()> { +pub async fn connect_import(target: ImportTarget, url: Url, epoch: moq_net::Identity) -> anyhow::Result<()> { let name = &target.name; let producer = target .origin @@ -99,7 +99,7 @@ pub async fn connect_import(target: ImportTarget, url: Url, epoch: moq_net::Epoc // The WHEP pull fills the tracks as they arrive; announce up front so viewers // can discover the broadcast while it connects. producer - .announce(moq_net::origin::Route::default().with_epoch(epoch)) + .announce(moq_net::origin::Route::default().with_identity(epoch, 0)) .context("failed to announce broadcast")?; tracing::info!(url = %RedactedUrl::new(&url), %name, "WHEP client pulling"); diff --git a/rs/moq-cli/src/rtsp.rs b/rs/moq-cli/src/rtsp.rs index 2565277ca2f..bcddb09f76b 100644 --- a/rs/moq-cli/src/rtsp.rs +++ b/rs/moq-cli/src/rtsp.rs @@ -86,9 +86,9 @@ async fn run(target: &ImportTarget, client: &moq_rtsp::Client, published: &mut b if updates.next().await?.is_none() { return std::future::pending().await; } - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); broadcast - .announce(moq_net::origin::Route::default().with_epoch(epoch.clone())) + .announce(moq_net::origin::Route::default().with_identity(epoch.clone(), 0)) .context("failed to announce broadcast")?; tracing::info!(name = %target.name, %epoch, "publishing RTSP broadcast"); *published = true; diff --git a/rs/moq-cli/src/transcode.rs b/rs/moq-cli/src/transcode.rs index 44209bcff7a..ff5d8569071 100644 --- a/rs/moq-cli/src/transcode.rs +++ b/rs/moq-cli/src/transcode.rs @@ -180,7 +180,7 @@ impl Following { drop(running.take()); let output = self.publish.create_broadcast(&self.output_path)?; let transcoder = moq_transcode::Transcoder::new(source, output.broadcast().clone(), self.config.clone())?; - output.announce(moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()))?; + output.announce(moq_net::origin::Route::default())?; tracing::info!(source = %self.source_path, output = %self.output_path, "transcoding"); running = Some(Box::pin(transcoder.run())); } @@ -224,7 +224,7 @@ mod tests { let route = || { let route = moq_net::origin::Route::default(); if with_epoch { - route.with_epoch(moq_net::Epoch::mint()) + route.with_identity(moq_net::Identity::random(), 0) } else { route } @@ -251,7 +251,7 @@ mod tests { let _new_dynamic = new.requests(); let second = next(&mut derivatives).await; assert!(!first.is_clone(&second)); - assert_ne!(first.info().epoch, second.info().epoch); + assert_ne!(first.info().identity, second.info().identity); assert!(first.is_closed(), "the superseded ladder is retired"); loop { match old_catalog.recv_group().await { @@ -275,7 +275,7 @@ mod tests { async fn a_source_ending_before_its_video_waits_for_the_next_instance() { let origin = moq_tokio::origin::spawn(); let publish = moq_tokio::origin::spawn(); - let route = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = || moq_net::origin::Route::default(); let old = origin.publish("source", route()).unwrap(); let mut catalog = moq_mux::catalog::Producer::new(&mut old.broadcast().clone(), Default::default()).unwrap(); let mut derivatives = moq_mux::follow::Instances::new(&publish.consume(), "derived", None).unwrap(); diff --git a/rs/moq-cli/tests/integration/epochs.rs b/rs/moq-cli/tests/integration/epochs.rs index 3ed16a99c96..4e2689a0c40 100644 --- a/rs/moq-cli/tests/integration/epochs.rs +++ b/rs/moq-cli/tests/integration/epochs.rs @@ -4,7 +4,7 @@ use std::time::Duration; use moq_clock::Instant; -use hang::moq_net::{self, Epoch, Timestamp, announce, origin, track}; +use hang::moq_net::{self, Identity, Timestamp, announce, origin, track}; use moq_mux::catalog::Stream; const LITE_07: &str = "moq-lite-07-wip"; @@ -82,8 +82,8 @@ struct Published { media: track::Producer, } -fn publish(origin: &origin::Producer, epoch: &Epoch, tag: &str) -> Published { - let route = origin::Route::default().with_epoch(epoch.clone()); +fn publish(origin: &origin::Producer, epoch: &Identity, tag: &str) -> Published { + let route = origin::Route::default().with_identity(epoch.clone(), 0); let broadcast = origin.publish("live/catalog.hang", route.clone()).unwrap(); let sibling = origin.publish("live/media", route).unwrap(); let media = sibling @@ -98,7 +98,7 @@ fn publish(origin: &origin::Producer, epoch: &Epoch, tag: &str) -> Published { .mutate(|catalog| { let mut audio = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2); audio.broadcast = Some(moq_net::path::Relative::new("./media").unwrap()); - audio.epoch = Some(epoch.clone()); + audio.identity = Some(epoch.clone()); catalog.audio.renditions.insert("audio".into(), audio); }) .unwrap(); @@ -135,8 +135,7 @@ async fn changed(follow: &mut announce::Consumer) -> bool { async fn replacement(version: &str) { let relay = relay().await; - let (a, b) = (Epoch::mint(), Epoch::mint()); - assert!(b > a); + let (a, b) = (Identity::random(), Identity::random()); let first_origin = moq_tokio::origin::spawn(); let mut first = publish(&first_origin, &a, "A"); let first_client = client(LITE_07).with_publisher(&first_origin); @@ -170,7 +169,7 @@ async fn replacement(version: &str) { let snapshot = catalog.next().await.unwrap().unwrap(); let reference = &snapshot.audio.renditions["audio"]; let pinned = old - .resolve(reference.broadcast.as_ref(), reference.epoch.as_ref()) + .resolve(reference.broadcast.as_ref(), reference.identity.as_ref()) .await .unwrap(); let cold = pinned.track("audio").unwrap(); @@ -207,8 +206,11 @@ async fn replacement(version: &str) { (changed(&mut catalog_follow).await, changed(&mut media_follow).await) }) .await; - // Only lite-07 carries the epoch, so only it restarts in place. - assert_eq!(catalog_restart, version == LITE_07); + assert_eq!( + catalog_restart, + version == LITE_07, + "only lite-07 carries the identity, which restarts in place" + ); assert_eq!(media_restart, catalog_restart); println!( "{version}: relay replacement observed in {:?} of {:?} (publisher already connected)", @@ -263,9 +265,9 @@ async fn replacement(version: &str) { .unwrap(); let snapshot = fresh_catalog.next().await.unwrap().unwrap(); let reference = &snapshot.audio.renditions["audio"]; - assert_eq!(reference.epoch.as_ref(), Some(&b)); + assert_eq!(reference.identity.as_ref(), Some(&b)); let new = fresh - .resolve(reference.broadcast.as_ref(), reference.epoch.as_ref()) + .resolve(reference.broadcast.as_ref(), reference.identity.as_ref()) .await .unwrap(); assert!(!new.is_clone(&pinned)); diff --git a/rs/moq-cli/tests/integration/export.rs b/rs/moq-cli/tests/integration/export.rs index 82c1eee8f94..5f22d5dfd46 100644 --- a/rs/moq-cli/tests/integration/export.rs +++ b/rs/moq-cli/tests/integration/export.rs @@ -512,13 +512,13 @@ async fn a_restarted_publisher_is_a_program_switch_with_stitch() { assert_eq!(unflagged(after), Vec::::new(), "every PID flags the break"); } -/// One publisher reconnects within the follow, continuing its capture under the same epoch +/// One publisher reconnects within the follow, continuing its capture under the same identity /// and program before delivering a clean final finish. #[tokio::test] #[ignore = "real-time process test: just rs cli-export"] async fn the_same_instance_within_the_follow_continues() { let relay = relay().await; - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let lite07 = ["--connect-version", LITE_07]; let origin = moq_tokio::origin::spawn(); let broadcast = origin.create_broadcast("demo").unwrap(); @@ -528,7 +528,7 @@ async fn the_same_instance_within_the_follow_continues() { let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), config).unwrap(); let mut import = ts::Import::new(broadcast.broadcast().clone(), catalog.reserve()); broadcast - .announce(moq_net::origin::Route::default().with_epoch(epoch.clone())) + .announce(moq_net::origin::Route::default().with_identity(epoch.clone(), 0)) .unwrap(); let mut config = moq_tokio::connect::Config::default(); config.tls.insecure = Some(true); @@ -563,7 +563,7 @@ async fn the_same_instance_within_the_follow_continues() { export.output_past(0).await; assert!(split < LONG.len(), "the capture has a tail after the export joined"); let old = relay.origin.request("demo", None).await.unwrap(); - assert_eq!(old.info().epoch.as_ref(), Some(&epoch)); + assert_eq!(old.info().identity.as_ref(), Some(&epoch)); let clock = catalog.snapshot().clock; connection.abort(moq_net::Error::Cancel); connection.closed().await.unwrap(); @@ -576,7 +576,7 @@ async fn the_same_instance_within_the_follow_continues() { .await .expect("the relay observes the returning publication") .unwrap(); - assert_eq!(back.info().epoch.as_ref(), Some(&epoch)); + assert_eq!(back.info().identity.as_ref(), Some(&epoch)); assert!(!back.is_clone(&old), "the old relay copy was replaced"); for chunk in chunks { pace.tick().await; @@ -1055,14 +1055,14 @@ async fn redundant_failover(bufferbloat: bool) { } /// The epoch of the next route announced for the path. -async fn next_epoch(announced: &mut moq_net::announce::Consumer) -> moq_net::Epoch { +async fn next_epoch(announced: &mut moq_net::announce::Consumer) -> moq_net::Identity { moq_clock::timeout(TIMEOUT, async { loop { match announced.next().await.expect("the relay stays up") { moq_net::announce::Event::Start(announce) | moq_net::announce::Event::Restart(announce) | moq_net::announce::Event::Takeover { announce, .. } => { - return announce.route.epoch.expect("a lite-07 importer announces its epoch"); + return announce.route.identity.expect("a lite-07 importer announces its epoch"); } _ => continue, } diff --git a/rs/moq-e2ee/Cargo.toml b/rs/moq-e2ee/Cargo.toml index 7815c77692d..48b1e0dd7e1 100644 --- a/rs/moq-e2ee/Cargo.toml +++ b/rs/moq-e2ee/Cargo.toml @@ -20,6 +20,7 @@ kio = { workspace = true } moq-net = { workspace = true } rind = { workspace = true } thiserror = { workspace = true } +uuid = { workspace = true } zeroize = { workspace = true } [dev-dependencies] diff --git a/rs/moq-e2ee/README.md b/rs/moq-e2ee/README.md index 2830c9e7dd1..9cef21b5536 100644 --- a/rs/moq-e2ee/README.md +++ b/rs/moq-e2ee/README.md @@ -29,12 +29,12 @@ let generation = credential.produce(); let broadcast = origin.create_broadcast(&credential.path("meeting.hang")?)?; let protected = broadcast::Producer::new(broadcast.clone(), generation.clone()); let video = protected.create_track("video", None)?; -broadcast.announce(Route::default().with_epoch(generation.epoch().clone()))?; +broadcast.announce(Route::default().with_identity(generation.epoch().identity(), 0))?; ``` A subscriber follows `credential.path(semantic)` with `origin.follow(path, None)`. It takes -each instance's epoch from the broadcast it resolved, `broadcast.info().epoch` (or from the -application's channel on a wire older than lite-07), so the key always matches the instance +each instance's epoch from the broadcast it resolved, `Epoch::of(&broadcast)` (or from the +application's channel on a wire that carries no identity, older than lite-07), so the key always matches the instance it reads. It binds the epoch with `credential.consume(epoch)` and wraps the broadcast in a `broadcast::Consumer`. Its `subscribe(semantic, subscription)` opens the track a decrypted catalog names. A consuming generation cannot produce. Nothing survives a publisher instance: a restart mints a new epoch. diff --git a/rs/moq-e2ee/src/broadcast.rs b/rs/moq-e2ee/src/broadcast.rs index 6acc1e262ea..c8267e54ea6 100644 --- a/rs/moq-e2ee/src/broadcast.rs +++ b/rs/moq-e2ee/src/broadcast.rs @@ -10,8 +10,9 @@ use crate::{generation, track}; /// The publishing side of a protected broadcast. /// /// Wraps the `moq-net` broadcast at [`Credential::path`](crate::Credential::path). Announce that -/// broadcast with [`generation().epoch()`](generation::Producer::epoch) on its route once the -/// tracks a subscriber needs first exist. +/// broadcast with [`generation().epoch()`](generation::Producer::epoch)'s +/// [`identity`](crate::Epoch::identity) on its route once the tracks a subscriber needs first +/// exist. #[derive(Clone)] pub struct Producer { inner: moq_net::broadcast::Producer, diff --git a/rs/moq-e2ee/src/credential.rs b/rs/moq-e2ee/src/credential.rs index 87e3a8c0f11..7a3003d294f 100644 --- a/rs/moq-e2ee/src/credential.rs +++ b/rs/moq-e2ee/src/credential.rs @@ -9,10 +9,10 @@ use bytes::Bytes; use rind::hkdf; use zeroize::{Zeroize, ZeroizeOnDrop}; +use crate::Epoch; use crate::error::{Error, Result}; use crate::generation; use crate::limits::{PATH_LABEL, PATH_LEN, PROFILE, SECRET_LEN, check_bytes, check_u53}; -use moq_net::Epoch; /// What the application distributes over its own authenticated channel. pub struct Config { diff --git a/rs/moq-e2ee/src/epoch.json b/rs/moq-e2ee/src/epoch.json new file mode 100644 index 00000000000..505e6b8be4d --- /dev/null +++ b/rs/moq-e2ee/src/epoch.json @@ -0,0 +1,33 @@ +{ + "valid": [ + "00000000-0000-7000-8000-000000000000", + "0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7f", + "0199b7f4-3c2a-7d1e-af0b-2b6c1a9d8e7f", + "0199b7f4-9e10-7f42-8a3c-5d7e2b1c0f9a", + "ffffffff-ffff-7fff-bfff-ffffffffffff" + ], + "invalid": [ + "", + "alice", + "2026/09", + "0199B7F4-3C2A-7D1E-9F0B-2B6C1A9D8E7F", + "0199b7f43c2a7d1e9f0b2b6c1a9d8e7f", + "@0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7f", + "{0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7f}", + "urn:uuid:0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7f", + "0199b7f4-3c2a-4d1e-9f0b-2b6c1a9d8e7f", + "0199b7f4-3c2a-7d1e-cf0b-2b6c1a9d8e7f", + "0199b7f4-3c2a-7d1e-7f0b-2b6c1a9d8e7f", + "0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7f\n", + " 0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7f", + "0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7f ", + "0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7g" + ], + "ordered": [ + "00000000-0000-7000-8000-000000000000", + "0199b7f4-3c2a-7d1e-9f0b-2b6c1a9d8e7f", + "0199b7f4-3c2a-7d1e-af0b-2b6c1a9d8e7f", + "0199b7f4-9e10-7f42-8a3c-5d7e2b1c0f9a", + "ffffffff-ffff-7fff-bfff-ffffffffffff" + ] +} diff --git a/rs/moq-e2ee/src/epoch.rs b/rs/moq-e2ee/src/epoch.rs new file mode 100644 index 00000000000..1372ab96dd1 --- /dev/null +++ b/rs/moq-e2ee/src/epoch.rs @@ -0,0 +1,106 @@ +use std::fmt; +use std::str::FromStr; +use std::sync::Arc; + +/// One publisher instance's generation epoch, the lowercase hyphenated text of a UUIDv7. +/// +/// The instance announces it as its route's [`moq_net::Identity`] ([`Self::identity`]), so a +/// subscriber reads it back from the broadcast it resolved ([`Self::of`]). A wire that carries +/// no identity hands the subscriber one its relay minted, which is no epoch: the application +/// supplies the epoch over its own authenticated channel then. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Epoch(Arc); + +/// The epoch is not a lowercase hyphenated UUIDv7 with the RFC variant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("invalid epoch: expected a lowercase hyphenated UUIDv7")] +#[non_exhaustive] +pub struct InvalidEpoch; + +impl Epoch { + /// Mint a fresh identity using the wall clock and secure randomness, ordered newest last. + pub fn mint() -> Self { + Self(uuid::Uuid::now_v7().hyphenated().to_string().into()) + } + + /// The UUID text. + pub fn as_str(&self) -> &str { + &self.0 + } + + /// The route identity that announces this epoch. + pub fn identity(&self) -> moq_net::Identity { + self.as_str().parse().expect("an epoch is never empty") + } + + /// The epoch a resolved broadcast's route announced, if its identity is one. + pub fn of(broadcast: &moq_net::broadcast::Consumer) -> Option { + broadcast.info().identity.as_ref()?.as_str().parse().ok() + } +} + +impl FromStr for Epoch { + type Err = InvalidEpoch; + + fn from_str(text: &str) -> Result { + let uuid = uuid::Uuid::parse_str(text).map_err(|_| InvalidEpoch)?; + if uuid.get_version_num() != 7 + || uuid.get_variant() != uuid::Variant::RFC4122 + || uuid.hyphenated().to_string() != text + { + return Err(InvalidEpoch); + } + Ok(Self(text.into())) + } +} + +impl fmt::Debug for Epoch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("Epoch").field(&self.as_str()).finish() + } +} + +impl fmt::Display for Epoch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl AsRef for Epoch { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mint_is_canonical_and_ordered() { + let epoch = Epoch::mint(); + assert_eq!(epoch.as_str().parse::().unwrap(), epoch); + assert!(Epoch::mint() > epoch); + } + + /// Shared with `js/e2ee/src/epoch.test.ts`, so both parse and order alike. + #[test] + fn epoch_vectors() { + let vectors: serde_json::Value = serde_json::from_str(include_str!("epoch.json")).unwrap(); + for row in vectors["valid"].as_array().unwrap() { + let text = row.as_str().unwrap(); + let epoch: Epoch = text.parse().unwrap(); + assert_eq!(epoch.as_str(), text); + } + for row in vectors["invalid"].as_array().unwrap() { + assert!(row.as_str().unwrap().parse::().is_err(), "{row}"); + } + let ordered: Vec = vectors["ordered"] + .as_array() + .unwrap() + .iter() + .map(|row| row.as_str().unwrap().parse().unwrap()) + .collect(); + assert!(ordered.windows(2).all(|pair| pair[0] < pair[1])); + } +} diff --git a/rs/moq-e2ee/src/generation.rs b/rs/moq-e2ee/src/generation.rs index d07274224a0..ee1db3f1416 100644 --- a/rs/moq-e2ee/src/generation.rs +++ b/rs/moq-e2ee/src/generation.rs @@ -5,13 +5,13 @@ use std::collections::hash_map::Entry; use std::fmt; use std::sync::{Arc, Mutex}; +use crate::Epoch; use crate::credential::{Credential, append_bytes}; use crate::error::{Error, Result}; use crate::key::TrackKey; use crate::limits::{KEY_LABEL, NAME_KEY_LABEL, NAME_MAC_LABEL}; use crate::name::{self, Name}; use crate::track; -use moq_net::Epoch; use rind::{aead, hmac}; /// Grouped-frame versus datagram key domain. diff --git a/rs/moq-e2ee/src/lib.rs b/rs/moq-e2ee/src/lib.rs index 0194986d3bd..eb6ccc73623 100644 --- a/rs/moq-e2ee/src/lib.rs +++ b/rs/moq-e2ee/src/lib.rs @@ -7,15 +7,16 @@ //! //! A [`Credential`] is what the application distributes out of band. Each publisher //! instance calls [`Credential::produce`] to mint a [`generation::Producer`] and wraps its -//! `moq-net` broadcast in a [`broadcast::Producer`]. Subscribers follow the path, take each -//! instance's epoch from the broadcast it resolved, bind a [`generation::Consumer`] with +//! `moq-net` broadcast in a [`broadcast::Producer`], announcing the generation's +//! [`Epoch::identity`] on its route. Subscribers follow the path, read each instance's +//! epoch with [`Epoch::of`] from the broadcast it resolved, bind a [`generation::Consumer`] with //! [`Credential::consume`], and wrap the broadcast in a [`broadcast::Consumer`]. Both sides name tracks by their semantic names; only //! the derived physical names reach `moq-net`. //! //! A caller cannot reopen an explicit epoch for production: //! //! ```compile_fail,E0624 -//! fn reopen(credential: moq_e2ee::Credential, epoch: moq_net::Epoch) { +//! fn reopen(credential: moq_e2ee::Credential, epoch: moq_e2ee::Epoch) { //! let _ = moq_e2ee::generation::Producer::new(credential, epoch); //! } //! ``` @@ -28,6 +29,7 @@ pub mod broadcast; pub mod credential; pub mod datagram; +mod epoch; pub mod group; pub mod track; @@ -41,6 +43,7 @@ mod terminal; mod window; pub use credential::Credential; +pub use epoch::{Epoch, InvalidEpoch}; pub use error::{Error, Result}; pub use limits::{MAX_DATAGRAM_PLAINTEXT, MAX_GROUPED_PLAINTEXT, SECRET_LEN}; pub use name::Name; diff --git a/rs/moq-e2ee/src/tests.rs b/rs/moq-e2ee/src/tests.rs index a2cba6baa45..8456e0c3d8b 100644 --- a/rs/moq-e2ee/src/tests.rs +++ b/rs/moq-e2ee/src/tests.rs @@ -9,6 +9,7 @@ use bytes::Bytes; use rind::{aead, hmac}; use serde_json::Value; +use crate::Epoch; use crate::credential::{Config, Credential}; use crate::datagram::Event; use crate::error::Error; @@ -21,7 +22,6 @@ use crate::limits::{ use crate::name::{Name, pad}; use crate::protect::{nonce, protect}; use crate::{group, track}; -use moq_net::Epoch; /// A millisecond track: the frames written here carry timestamps. fn timed() -> moq_net::track::Info { diff --git a/rs/moq-e2ee/tests/transport.rs b/rs/moq-e2ee/tests/transport.rs index d71b9d160d2..dcc291891ad 100644 --- a/rs/moq-e2ee/tests/transport.rs +++ b/rs/moq-e2ee/tests/transport.rs @@ -46,7 +46,7 @@ async fn connect_protected(version: Version, track: &str) -> Fixture { let protected = moq_e2ee::broadcast::Producer::new(broadcast.broadcast().clone(), generation.clone()); let producer = protected.create_track(track, timed()).unwrap(); broadcast - .announce(moq_net::origin::Route::default().with_epoch(generation.epoch().clone())) + .announce(moq_net::origin::Route::default().with_identity(generation.epoch().identity(), 0)) .unwrap(); let mut options = MockConnectOptions::new(version); @@ -54,22 +54,18 @@ async fn connect_protected(version: Version, track: &str) -> Fixture { options.client_subscribe = Some(consumer_origin.clone()); let pair = connect_mock(options).await; - // The subscriber knows the opaque path and takes the epoch from the announced route. - // A wire without epochs leaves it to the application's own channel. + // The subscriber knows the opaque path and takes the epoch from the announced route's + // identity. A wire without identities leaves it to the application's own channel. let consumer = consumer_origin.consume(); let remote = consumer.follow(&path, None).unwrap().available().await.unwrap(); if version.to_string().starts_with("moq-lite-07") { assert_eq!( - remote.info().epoch.as_ref(), + moq_e2ee::Epoch::of(&remote).as_ref(), Some(generation.epoch()), "lite-07 announces the epoch" ); } - let epoch = remote - .info() - .epoch - .clone() - .unwrap_or_else(|| generation.epoch().clone()); + let epoch = moq_e2ee::Epoch::of(&remote).unwrap_or_else(|| generation.epoch().clone()); let consumer = moq_e2ee::broadcast::Consumer::new(remote, cred.consume(epoch)) .subscribe( track, diff --git a/rs/moq-gst/src/sink/session.rs b/rs/moq-gst/src/sink/session.rs index 9d35bae4598..52afb1f814d 100644 --- a/rs/moq-gst/src/sink/session.rs +++ b/rs/moq-gst/src/sink/session.rs @@ -277,11 +277,12 @@ impl Session { let _rt = RUNTIME.enter(); let origin = moq_tokio::origin::spawn(); - // Each run is a new publisher instance: a restarted pipeline takes over from one whose route still - // lingers, rather than resuming viewers into the old run's group sequence. Reconnects keep it. - let epoch = moq_net::Epoch::mint(); - gst::info!(CAT, "publishing {} under epoch {epoch}", settings.broadcast); - let broadcast = origin.publish(&settings.broadcast, moq_net::origin::Route::default().with_epoch(epoch))?; + // Each run is a new stream under a fresh identity, which the publication mints: a restarted + // pipeline never resumes viewers into the old run's group sequence. Reconnects keep it. + let broadcast = origin.publish(&settings.broadcast, moq_net::origin::Route::default())?; + if let Some(identity) = broadcast.route().and_then(|route| route.identity) { + gst::info!(CAT, "publishing {} under identity {identity}", settings.broadcast); + } let catalog = moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), moq_mux::catalog::Config::default())?; @@ -568,19 +569,19 @@ mod tests { (session, connection) } - fn announced_epoch(broadcast: &moq_net::origin::Publication) -> moq_net::Epoch { - broadcast.route().expect("announced").epoch.expect("an epoch") + fn announced_identity(broadcast: &moq_net::origin::Publication) -> moq_net::Identity { + broadcast.route().expect("announced").identity.expect("an identity") } - // Without an epoch, a restart whose old route lingers resumes viewers into the old run's groups. + // Under one identity, a restart whose old route lingers would resume viewers into the old run's groups. #[test] - fn each_run_announces_a_newer_epoch() { + fn each_run_announces_a_new_identity() { let (first, first_broadcast) = started_with_broadcast(); - let first_epoch = announced_epoch(&first_broadcast); + let first_identity = announced_identity(&first_broadcast); first.stop(); let (second, second_broadcast) = started_with_broadcast(); - assert!(announced_epoch(&second_broadcast) > first_epoch); + assert_ne!(announced_identity(&second_broadcast), first_identity); second.stop(); } diff --git a/rs/moq-gst/src/source/imp.rs b/rs/moq-gst/src/source/imp.rs index f6c8f07037a..f917fb58256 100644 --- a/rs/moq-gst/src/source/imp.rs +++ b/rs/moq-gst/src/source/imp.rs @@ -391,7 +391,7 @@ struct Shape { container: hang::catalog::Container, track: String, broadcast: Option, - epoch: Option, + epoch: Option, } /// A rendition by kind and moq track name: what a pad is kept by. @@ -599,7 +599,7 @@ async fn follow_path( err = &mut lost => break Err(err), event = follow.next() => match event { Ok(Event::Start(broadcast)) => { - tracing::info!(%path, epoch = broadcast.info().epoch.as_ref().map(tracing::field::display), "online"); + tracing::info!(%path, epoch = broadcast.info().identity.as_ref().map(tracing::field::display), "online"); Some(broadcast) } Ok(Event::End) => { @@ -839,7 +839,7 @@ fn reconcile( container: container.clone(), track: reference.track(name).to_string(), broadcast: reference.broadcast().cloned(), - epoch: reference.epoch().cloned(), + epoch: reference.identity().cloned(), }; desired.insert((kind, name.clone()), shape); } @@ -1022,8 +1022,8 @@ struct Follower { /// The instance last started. current: Option, /// The number and timeline of each instance a return would resume: the current one and each - /// a rendition pins, by epoch. An epochless instance cannot prove it is the one returning. - resumable: HashMap)>, + /// a rendition pins, by identity. + resumable: HashMap)>, /// Shared with the readers. finished: Finished, /// How many instances started, numbering each. @@ -1040,7 +1040,7 @@ struct Current { /// Released at its end, so an offline sibling holds none of its state. instance: Option, /// Its route's, which [`Follower::resumable`] keeps it by. - epoch: Option, + epoch: Option, /// Shared by every rendition reading the instance, so their first frames fix one mapping for /// all of them. timeline: Arc, @@ -1223,7 +1223,7 @@ impl Follower { } // An instance served again keeps its timeline and the tracks it finished. - let epoch = instance.info().epoch.clone(); + let epoch = instance.info().identity.clone(); let (serial, timeline) = match epoch.as_ref().and_then(|epoch| self.resumable.get(epoch)) { Some((serial, timeline)) => (*serial, timeline.clone()), None => { @@ -1254,7 +1254,7 @@ impl Follower { /// Drop the state no return can resume: an instance neither current nor pinned by a rendition, /// and the records of a retired pad. A disabled rendition keeps its pad, and its records. fn forget(&mut self) { - let pinned: HashSet<&moq_net::Epoch> = self + let pinned: HashSet<&moq_net::Identity> = self .renditions .values() .filter_map(|entry| entry.shape.epoch.as_ref()) @@ -1350,11 +1350,12 @@ impl Follower { } } -/// Whether `instance` may serve a rendition naming `epoch`. An epochless route cannot prove its -/// instance, so only a known mismatch refuses it. -fn serves(instance: &moq_net::broadcast::Consumer, epoch: Option<&moq_net::Epoch>) -> bool { - match (epoch, &instance.info().epoch) { - (Some(expected), Some(actual)) => expected == actual, +/// Whether `instance` may serve a rendition naming `epoch`, an identity. A broadcast not +/// resolved through an origin has none to compare, and a minted one proves nothing, so only a +/// known mismatch refuses it. +fn serves(instance: &moq_net::broadcast::Consumer, epoch: Option<&moq_net::Identity>) -> bool { + match (epoch, &instance.info().identity) { + (Some(expected), Some(actual)) if !actual.is_minted() => expected == actual, _ => true, } } @@ -3567,7 +3568,7 @@ mod session_tests { } fn epoch() -> moq_net::origin::Route { - moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()) + moq_net::origin::Route::default() } /// A publisher restarted while the old one stays up switches at once, on the same pad, and @@ -3607,12 +3608,6 @@ mod session_tests { restart(epoch); } - /// Without epochs (moq-lite 06), the newest announcement still replaces the old one. - #[test] - fn an_epochless_restart_switches_on_the_same_pad() { - restart(moq_net::origin::Route::default); - } - /// The route serving the path can be a prefix covering it, here the root, and a restart of /// its answer switches too. #[test] @@ -3626,7 +3621,7 @@ mod session_tests { let (answers, answered) = std::sync::mpsc::channel(); let handler = super::RUNTIME.spawn(async move { while let Ok(request) = claim.requested_broadcast().await { - let prepared = request.prepare(moq_net::Epoch::mint()).unwrap(); + let prepared = request.prepare().unwrap(); let answer = Publisher::build(prepared.broadcast().clone(), video_rendition()); prepared.publish().unwrap(); answers.send(answer).unwrap(); @@ -3672,10 +3667,10 @@ mod session_tests { } /// A `room` whose video rendition references the `cam` sibling, naming `epoch` when given. - fn referencing(origin: &moq_net::origin::Producer, epoch: Option) -> Publisher { + fn referencing(origin: &moq_net::origin::Producer, epoch: Option) -> Publisher { let mut config = video_rendition(); config.broadcast = Some(moq_net::path::Relative::new("./cam").unwrap()); - config.epoch = epoch; + config.identity = epoch; Publisher::new(origin, Default::default(), config) } @@ -4069,16 +4064,16 @@ mod session_tests { let element = element(); let recorder = Recorder::attach(&element); let origin = origin(); - let (old, new) = (moq_net::Epoch::mint(), moq_net::Epoch::mint()); + let (old, new) = (moq_net::Identity::random(), moq_net::Identity::random()); let mut room = referencing(&origin, Some(old.clone())); let session = follow(&element, &origin); - let mut first = Publisher::sibling(&origin, moq_net::origin::Route::default().with_epoch(old)); + let mut first = Publisher::sibling(&origin, moq_net::origin::Route::default().with_identity(old, 0)); let pad = recorder.written(&mut first, 1); first.video.finish().unwrap(); await_finished(&first); - let mut second = Publisher::sibling(&origin, moq_net::origin::Route::default().with_epoch(new.clone())); + let mut second = Publisher::sibling(&origin, moq_net::origin::Route::default().with_identity(new.clone(), 0)); room.catalog .modify() .unwrap() @@ -4086,7 +4081,7 @@ mod session_tests { .renditions .get_mut("video") .unwrap() - .epoch = Some(new); + .identity = Some(new); assert_eq!( recorder.written(&mut second, 2), pad, @@ -4106,20 +4101,24 @@ mod session_tests { let element = element(); let recorder = Recorder::attach(&element); let origin = origin(); - let (pinned, other, next) = (moq_net::Epoch::mint(), moq_net::Epoch::mint(), moq_net::Epoch::mint()); + let (pinned, other, next) = ( + moq_net::Identity::random(), + moq_net::Identity::random(), + moq_net::Identity::random(), + ); let mut room = referencing(&origin, Some(pinned.clone())); { let mut guard = room.catalog.modify().unwrap(); let mut again = guard.video.renditions["video"].clone(); again.track = Some("video".into()); - again.epoch = None; + again.identity = None; guard.video.renditions.insert("again".into(), again); } let session = follow(&element, &origin); // Each rendition plays the first instance on a pad of its own. Written once, so a second // copy on either pad can only be a replay. - let mut first = Publisher::sibling(&origin, moq_net::origin::Route::default().with_epoch(pinned)); + let mut first = Publisher::sibling(&origin, moq_net::origin::Route::default().with_identity(pinned, 0)); first.write(1); let played = recorder.wait("1 never reached both renditions", |seen| { let pads = reached(seen, 1); @@ -4130,7 +4129,7 @@ mod session_tests { // Another instance wins the path, which only the unpinned rendition plays, then leaves it // to the pinned one. - let mut second = Publisher::sibling(&origin, moq_net::origin::Route::default().with_epoch(other)); + let mut second = Publisher::sibling(&origin, moq_net::origin::Route::default().with_identity(other, 0)); let unpinned = recorder.written(&mut second, 2); let pad = played.into_iter().find(|pad| *pad != unpinned).unwrap(); let mut follow = watch(&origin, "cam"); @@ -4145,7 +4144,10 @@ mod session_tests { ) }); - let mut third = Publisher::sibling(&origin, moq_net::origin::Route::default().with_epoch(next.clone())); + let mut third = Publisher::sibling( + &origin, + moq_net::origin::Route::default().with_identity(next.clone(), 0), + ); room.catalog .modify() .unwrap() @@ -4153,7 +4155,7 @@ mod session_tests { .renditions .get_mut("video") .unwrap() - .epoch = Some(next); + .identity = Some(next); third.write(3); recorder.wait("the next instance never reached the pinned pad", |seen| { reached(seen, 3).contains(&pad).then_some(()) @@ -4287,7 +4289,7 @@ mod session_tests { } #[test] - fn an_epochless_sibling_restart_switches_on_the_same_pad() { + fn a_minted_sibling_restart_switches_on_the_same_pad() { sibling_restart(moq_net::origin::Route::default); } @@ -4299,9 +4301,9 @@ mod session_tests { let element = element(); let recorder = Recorder::attach(&element); let origin = origin(); - let named = moq_net::Epoch::mint(); + let named = moq_net::Identity::random(); let _room = referencing(&origin, Some(named.clone())); - let mut old = Publisher::sibling(&origin, moq_net::origin::Route::default().with_epoch(named)); + let mut old = Publisher::sibling(&origin, moq_net::origin::Route::default().with_identity(named, 0)); let session = follow(&element, &origin); await_demand(&old, true); let pad = recorder.written(&mut old, 1); diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index b0f38f57d3f..b2e3bb9c43d 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -96,8 +96,8 @@ pub struct Broadcaster { ) )] broadcast: moq_net::broadcast::Consumer, - /// The publisher instance served, which every URL the master playlist and manifest list - /// starts with: its epoch, or an id minted for an epochless instance. + /// The stream served, which every URL the master playlist and manifest list starts with: + /// its identity, or an id minted for a broadcast that came through no route. instance: Arc, /// The current rendition set, reconciled from the catalog by the watcher task. renditions: renditions::Producer, @@ -111,8 +111,12 @@ impl Broadcaster { /// Start tracking the renditions of `source`'s catalog broadcast. pub fn new(source: moq_mux::Source, config: Config) -> Arc { let broadcast = source.broadcast(); - // An epoch names the instance on every edge, so each serves it under the same URLs. - let instance = broadcast.info().epoch.clone().unwrap_or_else(moq_net::Epoch::mint); + // An identity names the stream on every edge, so each serves it under the same URLs. + let instance = broadcast + .info() + .identity + .clone() + .unwrap_or_else(moq_net::Identity::random); let renditions = renditions::Producer::new(config.window); let broadcaster = Arc::new(Self { broadcast, diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index f873c8d9cc7..cd89b27e6e0 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -142,7 +142,7 @@ impl Media { fn bind(source: &moq_mux::Source, reference: &impl hang::catalog::Reference, track: &str) -> moq_mux::Result { let rel = reference.broadcast(); let mut handle = Handle { - binding: Arc::new(source.bind(rel, reference.epoch())?), + binding: Arc::new(source.bind(rel, reference.identity())?), track: None, ended: false, }; diff --git a/rs/moq-hls/src/server/mod.rs b/rs/moq-hls/src/server/mod.rs index bc5e2430816..5bcccaec4c0 100644 --- a/rs/moq-hls/src/server/mod.rs +++ b/rs/moq-hls/src/server/mod.rs @@ -11,9 +11,9 @@ //! GET /{broadcast}/@{instance}/{kind}/{rendition}/seg/{tag}.t{pts}.m4s //! ``` //! -//! The master playlist and manifest list the publisher instance serving the broadcast now, -//! and every URL they list is under that `@{instance}`: its epoch, or an id the server mints -//! for an epochless instance. A publisher that restarts gets its own URLs, so a playlist URL +//! The master playlist and manifest list the stream serving the broadcast now, and every +//! URL they list is under that `@{instance}`: its identity, which a session mints for a route +//! whose wire carries none. A publisher that restarts gets its own URLs, so a playlist URL //! never resets its media sequence. A replaced instance keeps serving its URLs while its //! broadcast stays open, so a player already on it keeps playing until its publisher goes, //! then reloads the master to reach the replacement. @@ -125,8 +125,8 @@ impl Followed { self.instances.lock().unwrap().by_id.get(id).cloned() } - /// List `broadcaster` from now on. An instance returning after a gap keeps its epoch, and so - /// its id, which then names the new broadcaster. + /// List `broadcaster` from now on. An instance returning after a gap keeps its identity, and + /// so its id, which then names the new broadcaster. fn start(&self, broadcaster: Arc) { let mut instances = self.instances.lock().unwrap(); instances @@ -232,8 +232,8 @@ impl Server { if !existing.current().is_closed() { return Some(existing.clone()); } - // A player may still be on a replaced instance, and an epochless one would not get - // its id back. + // A player may still be on a replaced instance of another identity, which the + // fresh resolution does not list. followed.adopt(existing); } broadcasts.insert(name.to_string(), followed.clone()); @@ -507,8 +507,8 @@ mod tests { async fn a_replaced_instance_serves_until_it_closes() { let origin = produce_origin(); let server = Server::new(origin.consume(), Config::default()); - let (old_epoch, new_epoch) = (moq_net::Epoch::mint(), moq_net::Epoch::mint()); - let route = |epoch: &moq_net::Epoch| moq_net::origin::Route::default().with_epoch(epoch.clone()); + let (old_epoch, new_epoch) = (moq_net::Identity::random(), moq_net::Identity::random()); + let route = |epoch: &moq_net::Identity| moq_net::origin::Route::default().with_identity(epoch.clone(), 0); let old = origin.publish("live", route(&old_epoch)).expect("publish allowed"); settle().await; @@ -554,8 +554,8 @@ mod tests { } /// A request that finds the listed instance closed before the follower does resolves the path - /// afresh, yet a replaced instance still open keeps its URLs. An epochless one gets a new id - /// when resolved again, so its old URLs would never serve otherwise. + /// afresh, yet a replaced instance still open keeps its URLs: its identity names them, so the + /// fresh resolution of that same stream serves them on. #[tokio::test] async fn a_closed_current_instance_keeps_the_open_ones() { let origin = produce_origin(); @@ -580,7 +580,7 @@ mod tests { assert!(!current.is_closed()); let kept = server.instance("live", open.instance()).await; assert!( - kept.is_some_and(|kept| Arc::ptr_eq(&kept, &open)), + kept.is_some_and(|kept| kept.instance() == open.instance() && !kept.is_closed()), "the open instance lost its URLs" ); } diff --git a/rs/moq-hls/src/server/routes.rs b/rs/moq-hls/src/server/routes.rs index e4f1e070329..fa394f3cf57 100644 --- a/rs/moq-hls/src/server/routes.rs +++ b/rs/moq-hls/src/server/routes.rs @@ -229,7 +229,7 @@ mod tests { const RUN: &str = "01920000-0000-7000-8000-000000000000"; fn run() -> moq_net::origin::Route { - moq_net::origin::Route::default().with_epoch(RUN.parse().unwrap()) + moq_net::origin::Route::default().with_identity(RUN.parse().unwrap(), 0) } fn vp8_frame(micros: u64, keyframe: bool) -> moq_mux::container::Frame { @@ -439,9 +439,9 @@ mod tests { wait_listed(&app, "/live/master.m3u8", &format!("@{RUN}/video/video0/media.m3u8")).await; wait_listed(&app, &old_playlist, "seg/53e0aafd.0.m4s").await; - let next = moq_net::Epoch::mint(); + let next = moq_net::Identity::random(); let new = origin.create_broadcast("live").expect("publish"); - new.announce(moq_net::origin::Route::default().with_epoch(next.clone())) + new.announce(moq_net::origin::Route::default().with_identity(next.clone(), 0)) .expect("announce"); let (_new_catalog, _, _new_track, mut new_media) = publish_video(&mut new.broadcast().clone(), video_config(), timed()); diff --git a/rs/moq-hls/tests/integration/restart.rs b/rs/moq-hls/tests/integration/restart.rs index 2e7112dfe04..2200960047c 100644 --- a/rs/moq-hls/tests/integration/restart.rs +++ b/rs/moq-hls/tests/integration/restart.rs @@ -14,9 +14,10 @@ const NEW: u8 = 2; /// What the replaced run's recording holds when the new instance takes over. #[derive(Clone, Copy, Debug)] enum Case { - /// A cached segment returned while the cursor's live SUBSCRIBE was still unanswered. - /// Without an epoch nothing says which instance answers it, so it is refused and the - /// recording loses the old run's last uncached GOP. + /// A cached segment returned while the cursor's live SUBSCRIBE was still unanswered. On + /// lite-07 it names the old run's identity, so the old run answers it and the recording + /// completes. An older wire names none, so the takeover refuses it and the recording loses + /// the old run's last uncached GOP. Unanswered, /// As [`Self::Unanswered`], while the replacement's own group of the same number is /// still open, which the old cursor once waited on forever. @@ -66,7 +67,7 @@ async fn takeover(version: &str, case: Case) { } // The new instance numbers its groups and segments from zero again. - let route = moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = moq_net::origin::Route::default(); let mut new = Publisher::new(&pair.publisher, "live", route); let deadline = moq_clock::now() + TIMEOUT; let current = loop { @@ -113,14 +114,15 @@ async fn takeover(version: &str, case: Case) { // The old recording reads only its own segments, and either completes or fails at the GOP // it lost. let (read, end) = drain_old(&mut cursor).await; + let pinned = version == "moq-lite-07-wip"; match case { - Case::Answered => { + _ if pinned || matches!(case, Case::Answered) => { end.unwrap_or_else(|err| panic!("{case:?}: {err}")); assert_eq!(read, [1, 2, 3], "{case:?}"); let playlist = rendition.media_playlist(None).expect("finished").expect("listed"); assert!(playlist.ends_with("#EXT-X-ENDLIST\n"), "{case:?}: {playlist}"); } - Case::Unanswered | Case::UnansweredUnfinished | Case::FinishedFirst => { + Case::Answered | Case::Unanswered | Case::UnansweredUnfinished | Case::FinishedFirst => { assert!( matches!(end, Err(moq_hls::Error::SegmentUnavailable { segment: 3 })), "{case:?}: {end:?}" diff --git a/rs/moq-mux/src/catalog/hang/consumer.rs b/rs/moq-mux/src/catalog/hang/consumer.rs index 83a03846273..ceaf6e69f0b 100644 --- a/rs/moq-mux/src/catalog/hang/consumer.rs +++ b/rs/moq-mux/src/catalog/hang/consumer.rs @@ -155,7 +155,7 @@ mod test { .requested_broadcast() .await .expect("handler should be asked for the path") - .prepare(moq_net::Epoch::mint()) + .prepare() .expect("the answer should prepare"); let track = prepared .broadcast() diff --git a/rs/moq-mux/src/catalog/producer.rs b/rs/moq-mux/src/catalog/producer.rs index 96439aa5fe1..447ea984e28 100644 --- a/rs/moq-mux/src/catalog/producer.rs +++ b/rs/moq-mux/src/catalog/producer.rs @@ -1397,9 +1397,9 @@ fn to_msf_media(catalog: &hang::Catalog, base: &moq_net::Path) -> let mut tracks = Vec::new(); let reference = |id: &str, config: &dyn Reference| { let name = config.track(id); - if let Some(epoch) = config.epoch() { - // MSF has nowhere to name an instance, so its readers take whichever resolves first. - tracing::warn!(rendition = id, %epoch, "MSF cannot carry a reference epoch; advertising without it"); + if let Some(identity) = config.identity() { + // MSF has nowhere to name an identity, so its readers take whichever resolves first. + tracing::warn!(rendition = id, %identity, "MSF cannot carry a reference identity; advertising without it"); } let namespace = match config.broadcast().filter(|rel| !rel.is_empty()) { None => None, diff --git a/rs/moq-mux/src/container/consumer.rs b/rs/moq-mux/src/container/consumer.rs index ce818570038..04fea306c3f 100644 --- a/rs/moq-mux/src/container/consumer.rs +++ b/rs/moq-mux/src/container/consumer.rs @@ -2628,12 +2628,12 @@ mod tests { #[tokio::test(start_paused = true)] async fn truncated_resumed_group_skips_to_the_next_clean_group() { // One publisher instance behind every route, so each may resume the others. - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let origin = crate::source::produce_origin(); let hops = moq_net::Hops::try_from(vec![moq_net::Hop::new(10).unwrap()]).unwrap(); let route = |cost: u64| { moq_net::origin::Route::default() - .with_epoch(epoch.clone()) + .with_identity(epoch.clone(), 0) .with_hops(hops.clone()) .with_cost(cost) }; diff --git a/rs/moq-mux/src/container/source.rs b/rs/moq-mux/src/container/source.rs index de2cdf1dc9e..a4e5c004f07 100644 --- a/rs/moq-mux/src/container/source.rs +++ b/rs/moq-mux/src/container/source.rs @@ -69,12 +69,12 @@ enum SourceState { Ended, } -/// The track a source reads: its broadcast's path, the publisher instance that broadcast must -/// be, and the track's name there. +/// The track a source reads: its broadcast's path, the identity that broadcast must have, and +/// the track's name there. #[derive(Clone, Debug, PartialEq, Eq)] struct Target { path: moq_net::Path, - epoch: Option, + identity: Option, track: String, } @@ -83,13 +83,13 @@ impl Target { fn of(source: &crate::Source, name: &str, config: &impl Reference) -> Option { Some(Self { path: source.resolve_reference(config.broadcast())?, - epoch: config.epoch().cloned(), + identity: config.identity().cloned(), track: config.track(name).to_string(), }) } fn request(&self, source: &crate::Source) -> SourceState { - let binding = source.request_path(&self.path, self.epoch.as_ref()); + let binding = source.request_path(&self.path, self.identity.as_ref()); SourceState::Requesting(binding, self.track.clone()) } } @@ -268,7 +268,7 @@ impl ExportSource { ) -> Result { let target = Target { path: source.path().clone(), - epoch: None, + identity: None, track: name.to_string(), }; let media = HangContainer::Legacy(crate::container::Kind::Data); diff --git a/rs/moq-mux/src/container/ts/export.rs b/rs/moq-mux/src/container/ts/export.rs index d5ae4b3d752..fe02449f56e 100644 --- a/rs/moq-mux/src/container/ts/export.rs +++ b/rs/moq-mux/src/container/ts/export.rs @@ -144,8 +144,9 @@ where catalog_ended: bool, /// A snapshot arrived on `catalog` since it was subscribed. cataloged: bool, - /// The publisher epoch of the broadcast being exported, `None` for an epochless route. - instance: Option, + /// The identity of the broadcast being exported, `None` for one not resolved through an + /// origin. + instance: Option, /// Tracks [`Export::follow`] left on the ended broadcast, resubscribed as the returned /// catalog lists them. stale: HashSet, @@ -1402,7 +1403,7 @@ where { fn new(mut source: crate::Source, catalog: S) -> Self { source.isolate(); - let instance = source.broadcast().info().epoch.clone(); + let instance = source.broadcast().info().identity.clone(); Self { id: 0, retired: false, @@ -2646,19 +2647,20 @@ where &self.selected().source } - /// The publisher epoch of the broadcast being exported, `None` for an epochless route. - pub(super) fn instance(&self) -> Option<&moq_net::Epoch> { + /// The identity of the broadcast being exported, `None` for one not resolved through an + /// origin. + pub(super) fn instance(&self) -> Option<&moq_net::Identity> { self.selected().instance.as_ref() } /// Carry on into `broadcast`, using the caller's replacement `catalog` stream. /// - /// The same publisher instance (an equal epoch) continues the stream: the program and + /// The same stream (an equal identity) continues: the program and /// its PSI carry on, and its catalog resubscribes each track as it lists it, so a gap is a /// skip like any other. A track it adds or drops revises the PMT, as any catalog update /// does. /// - /// Another instance (another epoch, or an epochless route) opens a fresh jitter generation. + /// Another identity, or a broadcast without one, opens a fresh jitter generation. /// [`Follower`](super::Follower) keeps feeding the old program until the new clock and /// first media are usable, bounded by its follow window. Then what the old program /// already released goes out before the new PMT, and what it still buffers is dropped, @@ -2682,15 +2684,17 @@ where catalog: S, follow: Duration, ) -> Result<(), crate::Error> { - tracing::debug!(target: "moq_mux::container::ts::handoff", phase = "follow", now = ?moq_clock::now(), program = self.program.id, pending = ?self.pending.as_ref().map(|pending| pending.id), old_epoch = ?self.program.instance, new_epoch = ?broadcast.info().epoch, ?follow); - if self.pending.is_some() && self.program.instance.is_some() && broadcast.info().epoch == self.program.instance + tracing::debug!(target: "moq_mux::container::ts::handoff", phase = "follow", now = ?moq_clock::now(), program = self.program.id, pending = ?self.pending.as_ref().map(|pending| pending.id), old_identity = ?self.program.instance, new_identity = ?broadcast.info().identity, ?follow); + if self.pending.is_some() + && self.program.instance.is_some() + && broadcast.info().identity == self.program.instance { self.cancel_pending(); self.program.source.priority(0)?; self.follow_until = None; self.follow_timer = None; } - let same = self.selected().instance.is_some() && broadcast.info().epoch == self.selected().instance; + let same = self.selected().instance.is_some() && broadcast.info().identity == self.selected().instance; if !same { self.cancel_pending(); let next = self.replacement(self.program.source.switched(broadcast), catalog); diff --git a/rs/moq-mux/src/container/ts/export_test.rs b/rs/moq-mux/src/container/ts/export_test.rs index 72d91533751..70f970ac85d 100644 --- a/rs/moq-mux/src/container/ts/export_test.rs +++ b/rs/moq-mux/src/container/ts/export_test.rs @@ -161,7 +161,7 @@ async fn custom_selected_catalog_drives_export_and_follow() { for payload in [b"first".as_slice(), b"next".as_slice()] { let broadcast = origin.create_broadcast("live").unwrap(); broadcast - .announce(moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint())) + .announce(moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0)) .unwrap(); let mut snapshot = Catalog::default(); let mut producers = Vec::new(); @@ -224,7 +224,7 @@ async fn a_follower_keeps_msf_after_catalog_eof() { use std::task::Poll; let origin = crate::source::produce_origin(); - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let publish = || { let broadcast = origin.create_broadcast("live").unwrap(); let track = broadcast @@ -235,7 +235,7 @@ async fn a_follower_keeps_msf_after_catalog_eof() { group.write_frame(moq_net::Timestamp::now(), msf).unwrap(); group.finish().unwrap(); broadcast - .announce(moq_net::origin::Route::default().with_epoch(epoch.clone())) + .announce(moq_net::origin::Route::default().with_identity(epoch.clone(), 0)) .unwrap(); (broadcast, track) }; @@ -8740,8 +8740,8 @@ fn unflagged(frames: &[Frame]) -> Vec { /// gap the clock spans. Both ways a broadcast ends, a clean finish and a drop, continue alike. async fn same_instance_returns(finish: bool) { let origin = crate::source::produce_origin(); - let epoch = moq_net::Epoch::mint(); - let route = || moq_net::origin::Route::default().with_epoch(epoch.clone()); + let epoch = moq_net::Identity::random(); + let route = || moq_net::origin::Route::default().with_identity(epoch.clone(), 0); let (broadcast, mut catalog) = publish_live::<()>(&origin, route()); let mut track = aac_rendition(&mut broadcast.broadcast().clone(), &mut catalog, "a.aac"); @@ -8764,7 +8764,7 @@ async fn same_instance_returns(finish: bool) { frames.extend(rest); assert_eq!(end.is_ok(), finish, "a finish ends cleanly and a drop fails: {end:?}"); - // Back under the same epoch a second later, its media as far on as the wall clock, its + // Back under the same identity a second later, its media as far on as the wall clock, its // catalog listing the track only in its second snapshot. moq_clock::sleep(Duration::from_secs(1)).await; let (broadcast, mut catalog) = publish_live::<()>(&origin, route()); @@ -8821,8 +8821,8 @@ async fn a_return_keeps_catalog_and_group_history() { crate::clock::fake_host_wall(wall); let start = moq_clock::now(); let origin = crate::source::produce_origin(); - let epoch = moq_net::Epoch::mint(); - let route = moq_net::origin::Route::default().with_epoch(epoch.clone()); + let epoch = moq_net::Identity::random(); + let route = moq_net::origin::Route::default().with_identity(epoch.clone(), 0); let (broadcast, mut catalog) = publish_live::<()>(&origin, route.clone()); let mut track = aac_rendition(&mut broadcast.broadcast().clone(), &mut catalog, "a.aac"); let mut groups = track.consume(); @@ -8854,7 +8854,7 @@ async fn a_return_keeps_catalog_and_group_history() { .available() .await .unwrap(); - assert_eq!(back.info().epoch.as_ref(), Some(&epoch)); + assert_eq!(back.info().identity.as_ref(), Some(&epoch)); assert!(!back.is_clone(&old), "the withdrawn front is replaced"); let stream = crate::catalog::Consumer::new(&back, Default::default()).await.unwrap(); let mut export = export.follow(back, stream).unwrap(); @@ -8884,7 +8884,7 @@ async fn a_return_keeps_catalog_and_group_history() { #[tokio::test(start_paused = true)] async fn a_switch_carries_through_an_instance_that_never_built_its_tables() { let origin = crate::source::produce_origin(); - let route = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = || moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0); let (old, mut old_catalog) = publish_live::<()>(&origin, route()); let mut old_audio = aac_rendition(&mut old.broadcast().clone(), &mut old_catalog, "a.aac"); @@ -8942,7 +8942,7 @@ async fn a_switch_carries_through_an_instance_that_never_built_its_tables() { #[tokio::test(start_paused = true)] async fn another_instance_is_a_full_program_switch() { let origin = crate::source::produce_origin(); - let route = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = || moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0); let (old, mut old_catalog) = publish_live::<()>(&origin, route()); let mut old_audio = aac_rendition(&mut old.broadcast().clone(), &mut old_catalog, "a.aac"); @@ -9046,7 +9046,7 @@ async fn another_instance_is_a_full_program_switch() { #[tokio::test(start_paused = true)] async fn a_switch_moving_the_pmt_advances_the_pat() { let origin = crate::source::produce_origin(); - let route = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = || moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0); let publish = |pmt_pid: u16| { let (broadcast, mut catalog) = publish_live::(&origin, route()); let track = aac_rendition(&mut broadcast.broadcast().clone(), &mut catalog, "a.aac"); @@ -9100,8 +9100,8 @@ async fn a_switch_moving_the_pmt_advances_the_pat() { #[tokio::test(start_paused = true)] async fn export_stats_count_only_returned_output() { let origin = crate::source::produce_origin(); - let epoch = moq_net::Epoch::mint(); - let route = || moq_net::origin::Route::default().with_epoch(epoch.clone()); + let epoch = moq_net::Identity::random(); + let route = || moq_net::origin::Route::default().with_identity(epoch.clone(), 0); let units = |stats: stats::Export| stats.streams.values().map(|row| row.units).sum::() as usize; let (broadcast, mut catalog) = publish_live::<()>(&origin, route()); @@ -9457,12 +9457,12 @@ async fn large_pmt(streams: u16, mux_rate: Option) -> usize { /// Publish a catalog-only broadcast at `live`, for a test of what follows its end. fn publish_bare( origin: &moq_net::origin::Producer, - epoch: Option<&moq_net::Epoch>, + epoch: Option<&moq_net::Identity>, ) -> (moq_net::origin::Publication, crate::catalog::Producer<()>) { let route = moq_net::origin::Route::default(); publish_live::<()>( origin, - epoch.map_or(route.clone(), |epoch| route.with_epoch(epoch.clone())), + epoch.map_or(route.clone(), |epoch| route.with_identity(epoch.clone(), 0)), ) } @@ -9489,7 +9489,7 @@ async fn a_follower_fails_at_once_on_its_own_failure() { ]; for cause in causes { let origin = crate::source::produce_origin(); - let (broadcast, mut catalog) = publish_bare(&origin, Some(&moq_net::Epoch::mint())); + let (broadcast, mut catalog) = publish_bare(&origin, Some(&moq_net::Identity::random())); let track = aac_rendition(&mut broadcast.broadcast().clone(), &mut catalog, "a.aac"); let mut follower = follower(&origin).await.with_follow(Duration::from_secs(10)); assert!( @@ -9524,7 +9524,7 @@ async fn a_follower_waits_out_the_follow_on_a_lost_route() { ]; for cause in causes { let origin = crate::source::produce_origin(); - let (broadcast, mut catalog) = publish_bare(&origin, Some(&moq_net::Epoch::mint())); + let (broadcast, mut catalog) = publish_bare(&origin, Some(&moq_net::Identity::random())); let track = aac_rendition(&mut broadcast.broadcast().clone(), &mut catalog, "a.aac"); let follow = Duration::from_secs(10); let mut follower = follower(&origin).await.with_follow(follow); @@ -9552,7 +9552,7 @@ async fn a_follower_waits_out_the_follow_on_a_lost_route() { #[tokio::test(start_paused = true)] async fn a_follower_awaits_a_replacement_catalog_past_the_follow() { let origin = crate::source::produce_origin(); - let first = publish_bare(&origin, Some(&moq_net::Epoch::mint())); + let first = publish_bare(&origin, Some(&moq_net::Identity::random())); let follow = Duration::from_secs(10); let mut follower = follower(&origin).await.with_follow(follow).with_stitch(true); finish(first); @@ -9567,7 +9567,7 @@ async fn a_follower_awaits_a_replacement_catalog_past_the_follow() { let second = origin .publish( "live", - moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()), + moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0), ) .unwrap(); let unanswered = second.requests(); @@ -9589,7 +9589,7 @@ async fn a_follower_awaits_a_replacement_catalog_past_the_follow() { #[tokio::test(start_paused = true)] async fn a_follower_awaits_a_return_catalog_until_it_fails() { let origin = crate::source::produce_origin(); - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let first = publish_bare(&origin, Some(&epoch)); let follow = Duration::from_secs(10); let mut follower = follower(&origin).await.with_follow(follow); @@ -9602,7 +9602,7 @@ async fn a_follower_awaits_a_return_catalog_until_it_fails() { ); let second = origin - .publish("live", moq_net::origin::Route::default().with_epoch(epoch)) + .publish("live", moq_net::origin::Route::default().with_identity(epoch, 0)) .unwrap(); let silent = second.create_track(hang::Catalog::DEFAULT_NAME, None).unwrap(); assert!( @@ -9623,7 +9623,7 @@ async fn a_follower_awaits_a_return_catalog_until_it_fails() { #[tokio::test(start_paused = true)] async fn a_follower_keeps_following_when_a_return_goes_before_it_resolves() { let origin = crate::source::produce_origin(); - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let first = publish_bare(&origin, Some(&epoch)); let follow = Duration::from_secs(10); let mut follower = follower(&origin).await.with_follow(follow); @@ -9638,7 +9638,7 @@ async fn a_follower_keeps_following_when_a_return_goes_before_it_resolves() { // Back, but its catalog request is never answered before it goes again. let second = origin - .publish("live", moq_net::origin::Route::default().with_epoch(epoch)) + .publish("live", moq_net::origin::Route::default().with_identity(epoch, 0)) .unwrap(); let unanswered = second.requests(); assert!( @@ -9658,9 +9658,12 @@ async fn a_follower_keeps_following_when_a_return_goes_before_it_resolves() { /// Announce `epoch` at `live`, refusing every track request with `NotFound` until the /// returned task is aborted, which ends the broadcast. -fn publish_refusing(origin: &moq_net::origin::Producer, epoch: &moq_net::Epoch) -> tokio::task::JoinHandle<()> { +fn publish_refusing(origin: &moq_net::origin::Producer, epoch: &moq_net::Identity) -> tokio::task::JoinHandle<()> { let broadcast = origin - .publish("live", moq_net::origin::Route::default().with_epoch(epoch.clone())) + .publish( + "live", + moq_net::origin::Route::default().with_identity(epoch.clone(), 0), + ) .unwrap(); let mut dynamic = broadcast.requests(); tokio::spawn(async move { @@ -9676,7 +9679,7 @@ fn publish_refusing(origin: &moq_net::origin::Producer, epoch: &moq_net::Epoch) #[tokio::test(start_paused = true)] async fn a_follower_keeps_following_through_a_refused_return() { let origin = crate::source::produce_origin(); - let first = publish_bare(&origin, Some(&moq_net::Epoch::mint())); + let first = publish_bare(&origin, Some(&moq_net::Identity::random())); let follow = Duration::from_secs(10); let mut follower = follower(&origin).await.with_follow(follow).with_stitch(true); let start = moq_clock::now(); @@ -9689,7 +9692,7 @@ async fn a_follower_keeps_following_through_a_refused_return() { ); // Replaced, but the replacement refuses its catalog request. - let refusing = publish_refusing(&origin, &moq_net::Epoch::mint()); + let refusing = publish_refusing(&origin, &moq_net::Identity::random()); assert!( moq_clock::timeout(Duration::from_secs(2), follower.next()) .await @@ -9698,7 +9701,7 @@ async fn a_follower_keeps_following_through_a_refused_return() { ); refusing.abort(); - let mut second = publish_bare(&origin, Some(&moq_net::Epoch::mint())); + let mut second = publish_bare(&origin, Some(&moq_net::Identity::random())); std::ops::DerefMut::deref_mut(&mut second.1.modify().unwrap()); assert!( moq_clock::timeout(Duration::from_secs(1), follower.next()) @@ -9718,7 +9721,7 @@ async fn a_follower_keeps_following_through_a_refused_return() { #[tokio::test(start_paused = true)] async fn a_follower_reports_a_refused_return_at_the_follow() { let origin = crate::source::produce_origin(); - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let first = publish_bare(&origin, Some(&epoch)); let follow = Duration::from_secs(10); let mut follower = follower(&origin).await.with_follow(follow); @@ -9747,8 +9750,8 @@ async fn a_follower_reports_a_refused_return_at_the_follow() { #[tokio::test(start_paused = true)] async fn a_follower_stitches_onto_the_next_replacement_when_one_goes_before_it_resolves() { let origin = crate::source::produce_origin(); - let route = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); - let first = publish_bare(&origin, Some(&moq_net::Epoch::mint())); + let route = || moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0); + let first = publish_bare(&origin, Some(&moq_net::Identity::random())); let mut follower = follower(&origin) .await .with_stitch(true) @@ -9764,7 +9767,7 @@ async fn a_follower_stitches_onto_the_next_replacement_when_one_goes_before_it_r "resolving the replacement" ); drop((second, unanswered)); - let mut third = publish_bare(&origin, Some(&moq_net::Epoch::mint())); + let mut third = publish_bare(&origin, Some(&moq_net::Identity::random())); std::ops::DerefMut::deref_mut(&mut third.1.modify().unwrap()); assert!( moq_clock::timeout(Duration::from_secs(1), follower.next()) @@ -9789,7 +9792,7 @@ async fn a_follower_stitches_onto_the_next_replacement_when_one_goes_before_it_r #[tokio::test(start_paused = true)] async fn a_follower_keeps_a_return_awaiting_its_catalog_when_a_stitch_goes() { let origin = crate::source::produce_origin(); - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let first = publish_bare(&origin, Some(&epoch)); let follow = Duration::from_secs(10); let mut follower = follower(&origin).await.with_follow(follow).with_stitch(true); @@ -9815,7 +9818,7 @@ async fn a_follower_keeps_a_return_awaiting_its_catalog_when_a_stitch_goes() { let third = origin .publish( "live", - moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()), + moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0), ) .unwrap(); let unanswered = third.requests(); @@ -9838,7 +9841,7 @@ async fn a_follower_keeps_a_return_awaiting_its_catalog_when_a_stitch_goes() { #[tokio::test(start_paused = true)] async fn a_return_after_an_abandoned_switch_restores_priority() { let origin = crate::source::produce_origin(); - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let (old, mut old_catalog) = publish_bare(&origin, Some(&epoch)); let mut old_audio = aac_rendition(&mut old.broadcast().clone(), &mut old_catalog, "a.aac"); write_aac(&mut old_audio, 0); @@ -9861,7 +9864,7 @@ async fn a_return_after_an_abandoned_switch_restores_priority() { let replacement = origin .publish( "live", - moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()), + moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0), ) .unwrap(); let unanswered = replacement.requests(); @@ -9902,7 +9905,7 @@ async fn a_return_after_an_abandoned_switch_restores_priority() { #[tokio::test(start_paused = true)] async fn a_follower_follows_a_return_after_a_lost_route() { let origin = crate::source::produce_origin(); - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let (broadcast, mut catalog) = publish_bare(&origin, Some(&epoch)); let track = aac_rendition(&mut broadcast.broadcast().clone(), &mut catalog, "a.aac"); let mut follower = follower(&origin).await.with_follow(Duration::from_secs(10)); @@ -9937,13 +9940,13 @@ async fn a_follower_follows_a_return_after_a_lost_route() { #[tokio::test(start_paused = true)] async fn a_follower_fails_a_stitch_onto_a_replacement_that_refuses_its_catalog() { let origin = crate::source::produce_origin(); - let _first = publish_bare(&origin, Some(&moq_net::Epoch::mint())); + let _first = publish_bare(&origin, Some(&moq_net::Identity::random())); let mut follower = follower(&origin).await.with_stitch(true); let _second = origin .publish( "live", - moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()), + moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0), ) .unwrap(); let end = moq_clock::timeout(Duration::from_secs(10), follower.next()) @@ -9957,7 +9960,7 @@ async fn a_follower_fails_a_stitch_onto_a_replacement_that_refuses_its_catalog() #[tokio::test(start_paused = true)] async fn a_follower_built_after_its_route_went_follows_the_return() { let origin = crate::source::produce_origin(); - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let first = publish_bare(&origin, Some(&epoch)); let export = open_export(crate::source::resolved(&origin, "live").await) .await @@ -9998,7 +10001,7 @@ async fn a_follower_takes_its_own_instance_winning_back_for_no_replacement() { let origin = crate::source::produce_origin(); let route = |cost: u64| { moq_net::origin::Route::default() - .with_epoch(moq_net::Epoch::mint()) + .with_identity(moq_net::Identity::random(), 0) .with_cost(cost) }; @@ -10037,10 +10040,10 @@ async fn a_follower_takes_its_own_instance_winning_back_for_no_replacement() { #[tokio::test(start_paused = true)] async fn a_follower_fails_on_a_replacement_without_stitch() { let origin = crate::source::produce_origin(); - let first = publish_bare(&origin, Some(&moq_net::Epoch::mint())); + let first = publish_bare(&origin, Some(&moq_net::Identity::random())); let mut follower = follower(&origin).await.with_follow(Duration::from_secs(10)); finish(first); - let _second = publish_bare(&origin, Some(&moq_net::Epoch::mint())); + let _second = publish_bare(&origin, Some(&moq_net::Identity::random())); let start = moq_clock::now(); let err = follower.next().await.expect_err("a replacement fails the follower"); @@ -10048,7 +10051,7 @@ async fn a_follower_fails_on_a_replacement_without_stitch() { assert!(start.elapsed() < Duration::from_secs(1), "no wait for a return"); } -/// An epochless route has no instance to match, so even its own return is a replacement. +/// An unnamed route has no instance to match, so even its own return is a replacement. #[tokio::test(start_paused = true)] async fn a_follower_takes_an_epochless_return_as_a_replacement() { let origin = crate::source::produce_origin(); @@ -10069,7 +10072,7 @@ async fn a_follower_takes_an_epochless_return_as_a_replacement() { #[tokio::test(start_paused = true)] async fn a_follower_continues_on_the_same_instance_returning() { let origin = crate::source::produce_origin(); - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let first = publish_bare(&origin, Some(&epoch)); let follow = Duration::from_secs(10); let mut follower = follower(&origin).await.with_follow(follow); @@ -10110,15 +10113,15 @@ async fn drain_follower(follower: &mut super::Follower) -> (Vec, Option::new(&mut exact.broadcast().clone(), Default::default()).unwrap(); let mut track = aac_rendition(&mut exact.broadcast().clone(), &mut catalog, "a.aac"); @@ -10180,8 +10183,8 @@ async fn a_follower_continues_through_a_same_epoch_handoff_after_a_gap() { #[tokio::test(start_paused = true)] async fn a_follower_ignores_a_covering_prefix_while_the_exact_route_serves() { let origin = crate::source::produce_origin(); - let route = |epoch| moq_net::origin::Route::default().with_epoch(epoch); - let exact = origin.publish("pool/job", route(moq_net::Epoch::mint())).unwrap(); + let route = |epoch| moq_net::origin::Route::default().with_identity(epoch, 0); + let exact = origin.publish("pool/job", route(moq_net::Identity::random())).unwrap(); let catalog = crate::catalog::Producer::<()>::new(&mut exact.broadcast().clone(), Default::default()).unwrap(); let source = crate::source::resolved(&origin, "pool/job").await; let mut follower = super::Follower::new(open_export(source).await.unwrap()).unwrap(); @@ -10206,9 +10209,9 @@ async fn a_follower_ignores_a_covering_prefix_while_the_exact_route_serves() { async fn a_midstream_stitch_awaits_the_replacement_catalog() { for follow in [Duration::ZERO, Duration::from_secs(10)] { let origin = crate::source::produce_origin(); - let _first = publish_bare(&origin, Some(&moq_net::Epoch::mint())); + let _first = publish_bare(&origin, Some(&moq_net::Identity::random())); let mut follower = follower(&origin).await.with_stitch(true).with_follow(follow); - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let (_second, mut catalog) = publish_bare(&origin, Some(&epoch)); assert!( moq_clock::timeout(Duration::from_secs(60), follower.next()) @@ -10241,7 +10244,7 @@ async fn a_cancelled_replacement_after_the_follow_can_follow_again() { for format in [CatalogFormat::Hang, CatalogFormat::Msf] { let origin = crate::source::produce_origin(); - let route = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = || moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0); let first = origin.publish("live", route()).unwrap(); let (name, snapshot) = match format { CatalogFormat::Hang => ( @@ -10311,7 +10314,7 @@ async fn a_cancelled_replacement_after_the_follow_can_follow_again() { "following after the replacement left" ); - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let (_third, mut catalog) = publish_bare(&origin, Some(&epoch)); std::ops::DerefMut::deref_mut(&mut catalog.modify().unwrap()); catalog.finish().unwrap(); @@ -10329,7 +10332,7 @@ async fn a_cancelled_replacement_after_the_follow_can_follow_again() { #[tokio::test(start_paused = true)] async fn a_midstream_stitch_keeps_the_old_instance_playing_until_it_switches() { let origin = crate::source::produce_origin(); - let (broadcast, mut catalog) = publish_bare(&origin, Some(&moq_net::Epoch::mint())); + let (broadcast, mut catalog) = publish_bare(&origin, Some(&moq_net::Identity::random())); let mut track = aac_rendition(&mut broadcast.broadcast().clone(), &mut catalog, "a.aac"); let export = open_export(crate::source::resolved(&origin, "live").await) .await @@ -10341,7 +10344,7 @@ async fn a_midstream_stitch_keeps_the_old_instance_playing_until_it_switches() { let second = origin .publish( "live", - moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()), + moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0), ) .unwrap(); let _unanswered = second.requests(); @@ -10358,14 +10361,14 @@ async fn a_midstream_stitch_keeps_the_old_instance_playing_until_it_switches() { #[tokio::test(start_paused = true)] async fn a_nested_stitch_keeps_awaiting_the_pending_replacement() { let origin = crate::source::produce_origin(); - let _first = publish_bare(&origin, Some(&moq_net::Epoch::mint())); + let _first = publish_bare(&origin, Some(&moq_net::Identity::random())); let mut follower = follower(&origin) .await .with_stitch(true) .with_follow(Duration::from_secs(10)); // Switched onto a replacement whose catalog has no snapshot yet. - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let (_second, mut catalog) = publish_bare(&origin, Some(&epoch)); assert!( moq_clock::timeout(Duration::from_secs(2), follower.next()) @@ -10378,7 +10381,7 @@ async fn a_nested_stitch_keeps_awaiting_the_pending_replacement() { let third = origin .publish( "live", - moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()), + moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0), ) .unwrap(); let unanswered = third.requests(); @@ -10415,13 +10418,13 @@ async fn a_nested_stitch_keeps_awaiting_the_pending_replacement() { #[tokio::test(start_paused = true)] async fn a_midstream_stitch_catalog_closed_without_a_snapshot_fails() { let origin = crate::source::produce_origin(); - let _first = publish_bare(&origin, Some(&moq_net::Epoch::mint())); + let _first = publish_bare(&origin, Some(&moq_net::Identity::random())); let mut follower = follower(&origin) .await .with_stitch(true) .with_follow(Duration::from_secs(10)); - let (_second, mut catalog) = publish_bare(&origin, Some(&moq_net::Epoch::mint())); + let (_second, mut catalog) = publish_bare(&origin, Some(&moq_net::Identity::random())); assert!( moq_clock::timeout(Duration::from_secs(1), follower.next()) .await @@ -10445,7 +10448,7 @@ fn audio_pts(frames: &[Frame]) -> Vec { } /// A rendition the catalog lists only after another instance took the path reads the instance -/// the export resolved, never the replacement, even on an epochless route. +/// the export resolved, never the replacement, even on an unnamed route. #[tokio::test(start_paused = true)] async fn a_late_rendition_never_reads_a_replacement() { let origin = crate::source::produce_origin(); @@ -10477,7 +10480,7 @@ async fn a_late_rendition_never_reads_a_replacement() { } /// An SI entry repointed after another instance took the path reads the instance the export -/// resolved, never the replacement, even on an epochless route. +/// resolved, never the replacement, even on an unnamed route. #[tokio::test(start_paused = true)] async fn a_repointed_si_entry_never_reads_a_replacement() { let sdt_a = make_long_section(0x42, 1, 0, 0, 0, &[0xaa; 8]); @@ -11235,8 +11238,8 @@ async fn placed_sections_keep_their_source_positions() { #[tokio::test(start_paused = true)] async fn a_track_renamed_on_its_pid_across_returns_retires_the_one_before() { let origin = crate::source::produce_origin(); - let epoch = moq_net::Epoch::mint(); - let route = || moq_net::origin::Route::default().with_epoch(epoch.clone()); + let epoch = moq_net::Identity::random(); + let route = || moq_net::origin::Route::default().with_identity(epoch.clone(), 0); let rendition = |broadcast: &mut moq_net::broadcast::Producer, catalog: &mut crate::catalog::Producer, name: &str| { @@ -11301,8 +11304,8 @@ async fn a_track_renamed_on_its_pid_across_returns_retires_the_one_before() { #[tokio::test(start_paused = true)] async fn a_track_renamed_on_its_pid_across_a_return_is_quiet_from_its_listing() { let origin = crate::source::produce_origin(); - let epoch = moq_net::Epoch::mint(); - let route = || moq_net::origin::Route::default().with_epoch(epoch.clone()); + let epoch = moq_net::Identity::random(); + let route = || moq_net::origin::Route::default().with_identity(epoch.clone(), 0); let rendition = |broadcast: &mut moq_net::broadcast::Producer, catalog: &mut crate::catalog::Producer, name: &str| { @@ -11373,7 +11376,7 @@ async fn a_track_renamed_on_its_pid_across_a_return_is_quiet_from_its_listing() #[tokio::test(start_paused = true)] async fn a_switch_follows_the_last_pmt_out() { let origin = crate::source::produce_origin(); - let route = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = || moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0); let (old, mut old_catalog) = publish_live::(&origin, route()); let mut audio = aac_rendition(&mut old.broadcast().clone(), &mut old_catalog, "a.aac"); @@ -11421,7 +11424,7 @@ async fn a_switch_follows_the_last_pmt_out() { #[tokio::test(start_paused = true)] async fn a_switch_follows_the_last_pat_out() { let origin = crate::source::produce_origin(); - let route = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = || moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0); let (old, mut old_catalog) = publish_live::(&origin, route()); let mut audio = aac_rendition(&mut old.broadcast().clone(), &mut old_catalog, "a.aac"); @@ -11470,7 +11473,7 @@ async fn a_switch_follows_the_last_pat_out() { #[tokio::test(start_paused = true)] async fn a_switch_past_an_instance_that_laid_no_tables_follows_the_last_pmt_out() { let origin = crate::source::produce_origin(); - let route = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = || moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0); let follow = async |export: Export<_>| { let replacement = origin.consume().request("live", None).await.unwrap(); let stream = crate::catalog::Consumer::new(&replacement, Default::default()) @@ -11525,7 +11528,7 @@ async fn a_switch_past_an_instance_that_laid_no_tables_follows_the_last_pmt_out( #[tokio::test(start_paused = true)] async fn a_stitched_switch_keeps_the_healthy_legs_held_frames() { let origin = crate::source::produce_origin(); - let route = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = || moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0); let (old, mut old_catalog) = publish_live::<()>(&origin, route()); let mut old_audio = aac_rendition(&mut old.broadcast().clone(), &mut old_catalog, "a.aac"); let mut export = open_export(crate::source::resolved(&origin, "live").await) @@ -11563,7 +11566,7 @@ async fn a_stitched_switch_keeps_the_healthy_legs_held_frames() { async fn a_healthy_stitch_feeds_the_old_generation_until_the_new_clock_acquires() { use std::task::Poll; let origin = crate::source::produce_origin(); - let route = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = || moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0); let (old, mut old_catalog) = publish_live::<()>(&origin, route()); let mut old_audio = aac_rendition(&mut old.broadcast().clone(), &mut old_catalog, "a.aac"); let export = open_export(crate::source::resolved(&origin, "live").await) @@ -11638,7 +11641,7 @@ async fn a_healthy_stitch_feeds_the_old_generation_until_the_new_clock_acquires( async fn repeated_stitches_keep_the_delay() { use std::task::Poll; let origin = crate::source::produce_origin(); - let route = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = || moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0); // Publisher `k` stamps its media `k * 100 s` on, so each epoch has its own timeline. const SPACING: u64 = 100_000; const SWITCH: u64 = 4_000; @@ -11720,7 +11723,7 @@ async fn repeated_stitches_keep_the_delay() { #[tokio::test(start_paused = true)] async fn an_expired_follow_stops_healthy_input_but_drains_its_buffered_tail() { let origin = crate::source::produce_origin(); - let route = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = || moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0); let (old, mut old_catalog) = publish_live::<()>(&origin, route()); let mut old_audio = aac_rendition(&mut old.broadcast().clone(), &mut old_catalog, "a.aac"); let delay = Duration::from_millis(500); @@ -11885,7 +11888,7 @@ async fn a_publisher_that_leaves_drains_its_buffered_tail_before_the_end() { #[tokio::test(start_paused = true)] async fn a_replacement_leaving_before_it_commits_keeps_the_old_program() { let origin = crate::source::produce_origin(); - let route = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = || moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0); let (old, mut old_catalog) = publish_live::<()>(&origin, route()); let mut old_audio = aac_rendition(&mut old.broadcast().clone(), &mut old_catalog, "a.aac"); let mut export = open_export(crate::source::resolved(&origin, "live").await) @@ -11947,7 +11950,7 @@ async fn a_replacement_leaving_before_it_commits_keeps_the_old_program() { #[tokio::test(start_paused = true)] async fn a_newer_pending_instance_discards_the_unemitted_held_replacement() { let origin = crate::source::produce_origin(); - let route = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = || moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0); let (a, mut a_catalog) = publish_live::<()>(&origin, route()); let mut a_audio = aac_rendition(&mut a.broadcast().clone(), &mut a_catalog, "a.aac"); let mut export = open_export(crate::source::resolved(&origin, "live").await) @@ -12009,7 +12012,7 @@ async fn a_newer_pending_instance_discards_the_unemitted_held_replacement() { #[tokio::test(start_paused = true)] async fn an_empty_finished_replacement_drains_the_old_program() { let origin = crate::source::produce_origin(); - let route = || moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = || moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0); let (old, mut catalog) = publish_live::<()>(&origin, route()); let mut audio = aac_rendition(&mut old.broadcast().clone(), &mut catalog, "a.aac"); let mut export = open_export(crate::source::resolved(&origin, "live").await) @@ -12055,11 +12058,11 @@ async fn an_empty_finished_replacement_drains_the_old_program() { ); } -/// Before any PSI, no layout or failed reader should survive a same-epoch return. +/// Before any PSI, no layout or failed reader should survive a same-identity return. async fn cold_same_epoch_return(pending: bool) { let origin = crate::source::produce_origin(); - let epoch = moq_net::Epoch::mint(); - let route = || moq_net::origin::Route::default().with_epoch(epoch.clone()); + let epoch = moq_net::Identity::random(); + let route = || moq_net::origin::Route::default().with_identity(epoch.clone(), 0); let (old, mut old_catalog) = publish_live::<()>(&origin, route()); let mut old_video = h264_rendition(&mut old.broadcast().clone(), &mut old_catalog, "v.avc1"); let mut old_audio = aac_rendition(&mut old.broadcast().clone(), &mut old_catalog, "a.aac"); @@ -12077,10 +12080,10 @@ async fn cold_same_epoch_return(pending: bool) { write_aac(&mut old_audio, ms); } prefix.push(export.next().await.unwrap().unwrap()); - let next_epoch = moq_net::Epoch::mint(); + let next_epoch = moq_net::Identity::random(); let (cold, mut catalog) = publish_live::<()>( &origin, - moq_net::origin::Route::default().with_epoch(next_epoch.clone()), + moq_net::origin::Route::default().with_identity(next_epoch.clone(), 0), ); let mut video = h264_rendition(&mut cold.broadcast().clone(), &mut catalog, "v.avc1"); let mut audio = aac_rendition(&mut cold.broadcast().clone(), &mut catalog, "a.aac"); @@ -12111,7 +12114,10 @@ async fn cold_same_epoch_return(pending: bool) { old.unannounce(); epoch }; - let (back, mut catalog) = publish_live::<()>(&origin, moq_net::origin::Route::default().with_epoch(returned_epoch)); + let (back, mut catalog) = publish_live::<()>( + &origin, + moq_net::origin::Route::default().with_identity(returned_epoch, 0), + ); let mut video = h264_rendition(&mut back.broadcast().clone(), &mut catalog, "v.avc1"); let mut audio = aac_rendition(&mut back.broadcast().clone(), &mut catalog, "a.aac"); let target = origin diff --git a/rs/moq-mux/src/container/ts/export_timing_test.rs b/rs/moq-mux/src/container/ts/export_timing_test.rs index 69293dbb5bd..7d302e516b5 100644 --- a/rs/moq-mux/src/container/ts/export_timing_test.rs +++ b/rs/moq-mux/src/container/ts/export_timing_test.rs @@ -659,7 +659,7 @@ async fn an_unmarked_step_keeps_the_export_rendering() { // A publisher that dies leaves the last video frames it sent held for their DTS, and nothing // settles them until frames come again. Where the receiver is not told the source changed (a -// replacement on a protocol without an epoch, or the other half of a 1+1 pair), those come on the +// replacement on a protocol under another identity, or the other half of a 1+1 pair), those come on the // same timeline after the silence. In the field the held frames then went into a schedule that had // already gone past them, and the export ended on a missed decode deadline. diff --git a/rs/moq-mux/src/container/ts/follower.rs b/rs/moq-mux/src/container/ts/follower.rs index b9620da250c..58768acc146 100644 --- a/rs/moq-mux/src/container/ts/follower.rs +++ b/rs/moq-mux/src/container/ts/follower.rs @@ -21,7 +21,7 @@ enum Serving { Gone, /// The instance being exported, handed out again since: it came back after a gap. Return(moq_net::broadcast::Consumer), - /// Another instance: another epoch, or any return of an epochless route. + /// Another stream: a broadcast of another identity. Other(moq_net::broadcast::Consumer), } @@ -62,8 +62,8 @@ enum State { /// Carries an [`Export`] across its broadcast's returns, following the publisher instances /// serving its path through [`Instances`]. /// -/// The same publisher instance (the same epoch) returning within the [follow](Self::with_follow) -/// continues the stream. Another instance taking the path fails with +/// The same stream (the same identity) returning within the [follow](Self::with_follow) +/// continues the export. Another identity taking the path fails with /// [`Error::Replaced`](crate::Error::Replaced), unless [stitching](Self::with_stitch), which /// switches the program to it at once. Subscriptions are sticky, so without stitching a /// replaced publisher that stays up keeps the export until it ends. @@ -78,8 +78,6 @@ enum State { /// deadline but its own end. A return that refuses its catalog waits for the next change, and /// the follow running out reports that refusal. A refusal to resolve the path at all fails at /// once, as it does for every [`Instances`] reader. -/// -/// An epochless route has no instance to match, so any return of one is a replacement. pub struct Follower { export: Export>, state: State, @@ -336,7 +334,7 @@ impl Follower { self.failed = None; tracing::info!( path = %self.path, - epoch = self.export.instance().map(tracing::field::display), + identity = self.export.instance().map(tracing::field::display), "exporting broadcast" ); Ok(()) @@ -349,12 +347,12 @@ impl Follower { match self.instances.poll_next(waiter) { Poll::Ready(Ok(Event::Start(broadcast))) => { self.serving = match self.export.instance() { - // An epochless route has no instance to match, so only the broadcast - // being exported is its own. + // A broadcast not resolved through an origin has no identity to match, + // so only the broadcast being exported is its own. None if broadcast.is_clone(&self.export.source().broadcast()) => Serving::Ours, // Even the same broadcast handed out again is a return: the origin hands // one out only for an announcement that brings it. - Some(epoch) if broadcast.info().epoch.as_ref() == Some(epoch) => match self.priming { + Some(identity) if broadcast.info().identity.as_ref() == Some(identity) => match self.priming { true => Serving::Ours, false => Serving::Return(broadcast), }, diff --git a/rs/moq-mux/src/container/ts/programs.rs b/rs/moq-mux/src/container/ts/programs.rs index e0400a15ec4..1389b36d2a6 100644 --- a/rs/moq-mux/src/container/ts/programs.rs +++ b/rs/moq-mux/src/container/ts/programs.rs @@ -26,7 +26,7 @@ use crate::catalog; /// The PAT is followed as it changes. A program a later PAT adds waits for its PMT as the first /// PAT's do, and each program follows its PMT to the PID the latest PAT names. One a later PAT /// drops is retired: its tracks finish and its broadcast is unannounced, while the other -/// programs carry on. A program that returns publishes a new instance, under a new epoch. +/// programs carry on. A program that returns publishes a new stream, under a new identity. /// /// The multiplex is parsed once: framing, continuity, the PAT and the SI sections are shared, /// and each program reads only the packets on the PIDs its PAT entry and PMT claim. A packet on a @@ -606,7 +606,7 @@ impl Programs { import.handle_pat(pat)?; // Each program is its own publisher instance for this run, so a restart // replaces it rather than resuming into it. - let route = moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = moq_net::origin::Route::default().with_identity(moq_net::Identity::random(), 0); let listed = broadcast.consume().track(hang::Catalog::DEFAULT_NAME)?; self.programs.push(Program { number, @@ -729,8 +729,8 @@ mod test { two_section_pat, video_pes, }; - /// The epoch each running program publishes under, announced yet or not. - fn epochs(programs: &Programs) -> Vec<(u16, moq_net::Epoch)> { + /// The identity each running program publishes under, announced yet or not. + fn epochs(programs: &Programs) -> Vec<(u16, moq_net::Identity)> { programs .programs .iter() @@ -739,7 +739,7 @@ mod test { let route = route.as_ref().or(program.pending.as_ref().map(|(route, _)| route)); ( program.number, - route.and_then(|route| route.epoch.clone()).expect("an epoch"), + route.and_then(|route| route.identity.clone()).expect("an identity"), ) }) .collect() @@ -1542,7 +1542,7 @@ mod test { let epochs: Vec<_> = programs .programs .iter() - .map(|program| program.broadcast.route().and_then(|route| route.epoch)) + .map(|program| program.broadcast.route().and_then(|route| route.identity)) .collect(); assert_eq!(epochs.len(), 2); assert!(epochs.iter().all(Option::is_some), "every program names its instance"); diff --git a/rs/moq-mux/src/error.rs b/rs/moq-mux/src/error.rs index b8afabbe7c3..5c86b91c0aa 100644 --- a/rs/moq-mux/src/error.rs +++ b/rs/moq-mux/src/error.rs @@ -221,18 +221,18 @@ pub enum Error { #[error("broadcast reference escapes the root: {0}")] EscapingBroadcast(String), - /// A reference's `epoch` names another publisher instance than the one serving its broadcast. + /// A reference's `identity` names another stream than the one serving its broadcast. /// /// The served instance is the one the source resolved first for that path, so a later /// reference cannot move it; the reference fails instead of reading other content. - #[error("broadcast {path} serves epoch {actual}, not the referenced {expected}")] - EpochMismatch { + #[error("broadcast {path} serves identity {actual}, not the referenced {expected}")] + IdentityMismatch { /// The broadcast's path, relative to the origin root. path: String, - /// The epoch the reference names. - expected: moq_net::Epoch, - /// The epoch the resolved broadcast serves. - actual: moq_net::Epoch, + /// The identity the reference names. + expected: moq_net::Identity, + /// The identity the resolved broadcast serves. + actual: moq_net::Identity, }, /// A rendition tried to lower jitter already advertised to subscribers. diff --git a/rs/moq-mux/src/follow.rs b/rs/moq-mux/src/follow.rs index 5f6222a9ef1..abd08760a86 100644 --- a/rs/moq-mux/src/follow.rs +++ b/rs/moq-mux/src/follow.rs @@ -13,7 +13,7 @@ use std::task::Poll; pub struct Instances { feed: Feed, /// The instance a reference named, if any. - epoch: Option, + identity: Option, /// The instance handed out last, until it stops serving. current: Option, } @@ -34,7 +34,7 @@ pub enum Event { } impl Instances { - /// Follow the broadcast at `path` through `origin`, handing out only `epoch` when given. + /// Follow the broadcast at `path` through `origin`, handing out only `identity` when given. /// /// Unlike [`Source::follow`](crate::Source::follow), this needs no catalog broadcast, so a /// reader can follow the broadcast whose catalog it reads and build a @@ -42,10 +42,10 @@ impl Instances { pub fn new( origin: &moq_net::origin::Consumer, path: impl TryInto>, - epoch: Option<&moq_net::Epoch>, + identity: Option<&moq_net::Identity>, ) -> crate::Result { let announced = origin.follow(path, None)?; - Ok(Self::with(Feed::Announced(Box::new(announced)), epoch)) + Ok(Self::with(Feed::Announced(Box::new(announced)), identity)) } /// Hand out `broadcast` once and never report its end, which its owner follows. @@ -53,10 +53,10 @@ impl Instances { Self::with(Feed::Fixed(Some(broadcast)), None) } - fn with(feed: Feed, epoch: Option<&moq_net::Epoch>) -> Self { + fn with(feed: Feed, identity: Option<&moq_net::Identity>) -> Self { Self { feed, - epoch: epoch.cloned(), + identity: identity.cloned(), current: None, } } @@ -69,8 +69,8 @@ impl Instances { pub fn poll_next(&mut self, waiter: &kio::Waiter) -> Poll> { loop { let next = match &mut self.feed { - // Followed unpinned and checked here: a pinned follow waits out an epochless route, - // which an epoch reads best effort. + // Followed unpinned and checked here, so another identity taking over ends the + // rendition rather than waiting for the named one to come back. Feed::Announced(announced) => announced.poll_available(waiter).map_err(crate::Error::from), Feed::Fixed(broadcast) => match broadcast.take() { Some(broadcast) => Poll::Ready(Ok(broadcast)), @@ -79,8 +79,8 @@ impl Instances { }; if let Poll::Ready(next) = next { let broadcast = next?; - if crate::source::check_epoch(self.epoch.as_ref(), &broadcast).is_err() { - tracing::debug!(path = %broadcast.info().path, "skipping an instance of another epoch"); + if crate::source::check_identity(self.identity.as_ref(), &broadcast).is_err() { + tracing::debug!(path = %broadcast.info().path, "skipping a broadcast of another identity"); match self.current.take() { Some(_) => return Poll::Ready(Ok(Event::End)), None => continue, @@ -122,8 +122,8 @@ mod tests { } } - fn route(epoch: &moq_net::Epoch) -> moq_net::origin::Route { - moq_net::origin::Route::default().with_epoch(epoch.clone()) + fn route(epoch: &moq_net::Identity) -> moq_net::origin::Route { + moq_net::origin::Route::default().with_identity(epoch.clone(), 0) } /// The next change a follower reports, which must arrive within the origin's update hold. @@ -172,12 +172,12 @@ mod tests { assert!(!restarted.is_clone(&started)); } - /// A replacement restarts a reference that names no epoch, and ends one that names the old - /// instance until that instance serves again. + /// A replacement restarts a reference that names no identity, and ends one that names the + /// old identity until that stream serves again. #[tokio::test(start_paused = true)] - async fn a_follow_restarts_only_without_an_epoch() { + async fn a_follow_restarts_only_without_an_identity() { let origin = produce_origin(); - let (old_epoch, new_epoch) = (moq_net::Epoch::mint(), moq_net::Epoch::mint()); + let (old_epoch, new_epoch) = (moq_net::Identity::random(), moq_net::Identity::random()); let _catalog = origin.publish("a/pub", Default::default()).unwrap(); let _old = origin.publish("a/source", route(&old_epoch)).unwrap(); settle().await; @@ -190,8 +190,8 @@ mod tests { let new = origin.publish("a/source", route(&new_epoch)).unwrap(); let replaced = followed(&mut any).await.unwrap(); - assert_eq!(replaced.info().epoch.as_ref(), Some(&new_epoch)); - assert!(followed(&mut named).await.is_none(), "another epoch ends it"); + assert_eq!(replaced.info().identity.as_ref(), Some(&new_epoch)); + assert!(followed(&mut named).await.is_none(), "another identity ends it"); quiet(&mut named).await; // The old instance still serves the subscriptions already on it. assert!(!old.is_closed()); @@ -199,23 +199,9 @@ mod tests { // The replacement goes and the old instance wins again. drop(new); let back = followed(&mut named).await.unwrap(); - assert_eq!(back.info().epoch.as_ref(), Some(&old_epoch)); + assert_eq!(back.info().identity.as_ref(), Some(&old_epoch)); let back = followed(&mut any).await.unwrap(); - assert_eq!(back.info().epoch.as_ref(), Some(&old_epoch)); - } - - /// An epoch is best effort over an epochless route, which cannot prove another instance. - #[tokio::test(start_paused = true)] - async fn a_follow_reads_an_epochless_route_with_an_epoch() { - let origin = produce_origin(); - let _catalog = origin.publish("a/pub", Default::default()).unwrap(); - settle().await; - let source = resolved(&origin, "a/pub").await; - let mut named = source - .follow(Some(&Relative::new("./source").unwrap()), Some(&moq_net::Epoch::mint())) - .unwrap(); - let _sibling = origin.publish("a/source", Default::default()).unwrap(); - assert!(followed(&mut named).await.is_some()); + assert_eq!(back.info().identity.as_ref(), Some(&old_epoch)); } /// A self-reference reads only the catalog instance, whatever replaces it at the path. diff --git a/rs/moq-mux/src/json.rs b/rs/moq-mux/src/json.rs index 2aa3862b5d3..60a7e7e9cf7 100644 --- a/rs/moq-mux/src/json.rs +++ b/rs/moq-mux/src/json.rs @@ -435,7 +435,7 @@ impl crate::catalog::Entry<'_, JsonConfig> { let track = source .subscribe_track( self.config().broadcast.as_ref(), - self.config().epoch.as_ref(), + self.config().identity.as_ref(), self.config().track(self.name()), ) .await?; diff --git a/rs/moq-mux/src/source.rs b/rs/moq-mux/src/source.rs index 22a4fe038f7..eeb5461fec9 100644 --- a/rs/moq-mux/src/source.rs +++ b/rs/moq-mux/src/source.rs @@ -49,18 +49,19 @@ impl Drop for Priority { /// nothing serves it yet. Every later request for a path is served from that handle /// ([`broadcast`](Self::broadcast), [`catalog`](Self::catalog), [`resolve`](Self::resolve), /// [`bind`](Self::bind), [`subscribe_track`](Self::subscribe_track), and an export's track -/// requests). A route replaced after that is never resolved, epoch or not, so two publishers +/// requests). A route replaced after that is never resolved, one identity or not, so two publishers /// never splice into one export; a request on a held broadcast that has ended fails instead of /// waiting for the next one. Clones share the held broadcasts. /// /// A reader that follows a sibling's announcements instead, restarting on each new instance, /// uses [`follow`](Self::follow), which holds nothing. /// -/// A reference may name the instance it expects with an epoch (the catalog's optional `epoch` -/// beside `broadcast`). A held or resolved broadcast serving another epoch fails that reference -/// with [`Error::EpochMismatch`](crate::Error::EpochMismatch) rather than replacing the held -/// one or falling back to it. A broadcast resolved over a route without an epoch cannot prove -/// its instance, so there the epoch is best effort: the reference reads the held broadcast. +/// A reference may name the stream it expects with an identity (the catalog's optional +/// `identity` beside `broadcast`). A held or resolved broadcast of another identity fails that +/// reference with [`Error::IdentityMismatch`](crate::Error::IdentityMismatch) rather than +/// replacing the held one or falling back to it. A route over a wire that carries no identity +/// has one its session [minted](moq_net::Identity::is_minted), which proves nothing, so there the +/// reference is best effort: it reads the held broadcast. #[derive(Clone)] pub struct Source { origin: moq_net::origin::Consumer, @@ -203,13 +204,13 @@ impl Source { } /// The broadcast `path` is pinned to, or a lookup that waits for the path's first - /// announcement and pins its answer, either checked against `epoch` on every read. - pub(crate) fn request_path(&self, path: &moq_net::Path, epoch: Option<&moq_net::Epoch>) -> Binding { + /// announcement and pins its answer, either checked against `identity` on every read. + pub(crate) fn request_path(&self, path: &moq_net::Path, identity: Option<&moq_net::Identity>) -> Binding { let pinned = self.pins.lock().expect("pins poisoned").get(path).cloned(); let bound = match pinned { Some(pinned) => Bound::Ready(pinned), - // Unpinned even with an epoch: an epochless route still answers, best effort, and - // the read refuses a route that proves another instance. + // Unpinned even with an identity: the path's first answer is pinned for every + // reference, and each read refuses one of another identity. None => { let mut following = self.origin.follow(path, None); // Issue the request now: a handler answers it whether or not anyone reads yet. @@ -229,7 +230,7 @@ impl Source { }; Binding { bound, - epoch: epoch.cloned(), + identity: identity.cloned(), sibling: *path != self.path, } } @@ -302,7 +303,7 @@ impl Source { /// `rel` is a rendition's catalog `broadcast` field: `None` (or an empty reference) /// resolves the catalog broadcast itself; anything else fetches the referenced /// broadcast from the origin, waiting for its first announcement if nothing serves it yet. - /// `epoch` is the rendition's `epoch` field, the instance the broadcast must be (see + /// `identity` is the rendition's `identity` field, the stream the broadcast must be (see /// [`Source`]). Use it when you need the broadcast handle itself (e.g. to FETCH individual /// groups) rather than a subscription. /// @@ -312,9 +313,9 @@ impl Source { pub async fn resolve( &self, rel: Option<&moq_net::path::Relative>, - epoch: Option<&moq_net::Epoch>, + identity: Option<&moq_net::Identity>, ) -> crate::Result { - self.bind(rel, epoch)?.broadcast().await + self.bind(rel, identity)?.broadcast().await } /// Start one broadcast request now and retain its result for repeated reads. @@ -331,13 +332,13 @@ impl Source { /// intended broadcast is already in hand. /// /// Rejects an escaping reference exactly as [`resolve`](Self::resolve) does, and checks - /// `epoch` on every read. + /// `identity` on every read. pub fn bind( &self, rel: Option<&moq_net::path::Relative>, - epoch: Option<&moq_net::Epoch>, + identity: Option<&moq_net::Identity>, ) -> crate::Result { - Ok(self.request_path(&self.target(rel)?, epoch)) + Ok(self.request_path(&self.target(rel)?, identity)) } /// Resolve an optional cross-broadcast reference and subscribe to track `name`, @@ -346,7 +347,7 @@ impl Source { /// `rel` is a rendition's catalog `broadcast` field: `None` (or an empty reference) /// subscribes on the catalog broadcast; anything else is resolved as in /// [`resolve`](Self::resolve), waiting for its first announcement and checked against - /// `epoch`. A + /// `identity`. A /// reference that escapes above the origin root is /// [`Error::EscapingBroadcast`](crate::Error::EscapingBroadcast). /// @@ -356,57 +357,60 @@ impl Source { pub async fn subscribe_track( &self, rel: Option<&moq_net::path::Relative>, - epoch: Option<&moq_net::Epoch>, + identity: Option<&moq_net::Identity>, name: &str, ) -> crate::Result { - let broadcast = self.bind(rel, epoch)?.broadcast().await?; + let broadcast = self.bind(rel, identity)?.broadcast().await?; Ok(broadcast.track(name)?.subscribe(None).await?) } /// Follow the broadcast a rendition's reference names across its announcements. /// - /// Holds nothing: each instance serving a sibling is handed out as it comes online or takes + /// Holds nothing: each identity serving a sibling is handed out as it comes online or takes /// over, and its end is reported, so a reader restarts the rendition on each one. A - /// reference to the catalog broadcast itself hands out only the instance this source was + /// reference to the catalog broadcast itself hands out only the broadcast this source was /// built from and never reports its end, since a reader follows its catalog's own end and /// restarts by building a new source. /// - /// With an `epoch`, only that instance (or an epochless route, which cannot prove another - /// one) is handed out: another taking over ends the rendition rather than substituting for - /// it. A self-reference whose epoch the catalog instance contradicts fails with - /// [`Error::EpochMismatch`](crate::Error::EpochMismatch), since no other instance can come. + /// With an `identity`, only that stream is handed out: another taking over ends the + /// rendition rather than substituting for it. A self-reference whose identity the catalog + /// broadcast contradicts fails with [`Error::IdentityMismatch`](crate::Error::IdentityMismatch), + /// since no other can come. /// Rejects an escaping reference exactly as [`resolve`](Self::resolve) does. pub fn follow( &self, rel: Option<&moq_net::path::Relative>, - epoch: Option<&moq_net::Epoch>, + identity: Option<&moq_net::Identity>, ) -> crate::Result { let path = self.target(rel)?; Ok(match path == self.path { true => { let broadcast = self.broadcast(); - check_epoch(epoch, &broadcast)?; + check_identity(identity, &broadcast)?; crate::follow::Instances::fixed(broadcast) } - false => crate::follow::Instances::new(&self.origin, &path, epoch)?, + false => crate::follow::Instances::new(&self.origin, &path, identity)?, }) } } -/// Fail with [`Error::EpochMismatch`](crate::Error::EpochMismatch) when `broadcast` is proven to -/// be another instance than `epoch` names. An epochless route cannot prove its instance, so only -/// a known mismatch fails. -pub(crate) fn check_epoch( - epoch: Option<&moq_net::Epoch>, +/// Fail with [`Error::IdentityMismatch`](crate::Error::IdentityMismatch) when `broadcast` has +/// another identity than `identity` names. A broadcast not resolved through an origin has none +/// to compare, and a [minted](moq_net::Identity::is_minted) one proves nothing, so only a known +/// mismatch fails. +pub(crate) fn check_identity( + identity: Option<&moq_net::Identity>, broadcast: &moq_net::broadcast::Consumer, ) -> crate::Result<()> { let info = broadcast.info(); - match (epoch, &info.epoch) { - (Some(expected), Some(actual)) if expected != actual => Err(crate::Error::EpochMismatch { - path: info.path.to_string(), - expected: expected.clone(), - actual: actual.clone(), - }), + match (identity, &info.identity) { + (Some(expected), Some(actual)) if expected != actual && !actual.is_minted() => { + Err(crate::Error::IdentityMismatch { + path: info.path.to_string(), + expected: expected.clone(), + actual: actual.clone(), + }) + } _ => Ok(()), } } @@ -416,15 +420,15 @@ pub(crate) fn check_epoch( /// A pending request waits for a path nothing announces yet and rides out route churn until it /// resolves (see [`follow`](moq_net::origin::Consumer::follow)). Once the /// path is pinned, reading the binding answers that pin and never looks the path up again, even -/// after its publisher is replaced or goes. Issuing a request does not establish an epoch -/// relationship with a separate catalog broadcast. A binding made with an epoch refuses a -/// broadcast proven to serve another one. +/// after its publisher is replaced or goes. Issuing a request does not establish an identity +/// relationship with a separate catalog broadcast. A binding made with an identity refuses a +/// broadcast of another one. /// /// Build one with [`Source::bind`], or with [`Binding::new`] for a broadcast already in hand. pub struct Binding { bound: Bound, - /// The instance the reference named, if any. - epoch: Option, + /// The identity the reference named, if any. + identity: Option, /// The reference names another broadcast than the catalog's. sibling: bool, } @@ -447,7 +451,7 @@ impl Binding { pub fn new(broadcast: moq_net::broadcast::Consumer) -> Self { Self { bound: Bound::Ready(broadcast), - epoch: None, + identity: None, sibling: false, } } @@ -474,14 +478,14 @@ impl Binding { pinned.clone() } else { let broadcast = ready!(poll_first(&mut following, waiter))?; - // The path's first answer is pinned even when it fails this binding's epoch, so + // The path's first answer is pinned even when it fails this binding's identity, so // no later reference resolves the path independently. let mut pins = pins.lock().expect("pins poisoned"); pins.entry(path.clone()).or_insert(broadcast).clone() } } }; - Poll::Ready(check_epoch(self.epoch.as_ref(), &broadcast).map(|()| broadcast)) + Poll::Ready(check_identity(self.identity.as_ref(), &broadcast).map(|()| broadcast)) } /// The bound broadcast, awaiting the origin's answer when it hasn't arrived yet. @@ -614,7 +618,7 @@ mod tests { _ = std::future::ready(()) => {} } - let prepared = request.prepare(moq_net::Epoch::mint()).unwrap(); + let prepared = request.prepare().unwrap(); let (first, second, producer) = tokio::join!(binding.broadcast(), binding.broadcast(), async { prepared.publish().unwrap() }); @@ -751,19 +755,23 @@ mod tests { assert!(bound.is_clone(&first), "a binding honors the pin too"); } - fn route(epoch: &moq_net::Epoch) -> moq_net::origin::Route { - moq_net::origin::Route::default().with_epoch(epoch.clone()) + fn route(epoch: &moq_net::Identity) -> moq_net::origin::Route { + moq_net::origin::Route::default().with_identity(epoch.clone(), 0) } - fn is_mismatch(result: crate::Result, expected: &moq_net::Epoch) -> bool { - matches!(result, Err(crate::Error::EpochMismatch { expected: ref named, .. }) if named == expected) + fn is_mismatch(result: crate::Result, expected: &moq_net::Identity) -> bool { + matches!(result, Err(crate::Error::IdentityMismatch { expected: ref named, .. }) if named == expected) } /// An omitted or matching epoch resolves the sibling; another epoch fails instead. #[tokio::test(start_paused = true)] - async fn a_reference_epoch_must_match_the_resolved_sibling() { + async fn a_reference_identity_must_match_the_resolved_sibling() { let origin = produce_origin(); - let (catalog, sibling, other) = (moq_net::Epoch::mint(), moq_net::Epoch::mint(), moq_net::Epoch::mint()); + let (catalog, sibling, other) = ( + moq_net::Identity::random(), + moq_net::Identity::random(), + moq_net::Identity::random(), + ); let _catalog = origin.publish("a/pub", route(&catalog)).unwrap(); let _sibling = origin.publish("a/source", route(&sibling)).unwrap(); settle().await; @@ -771,7 +779,7 @@ mod tests { let rel = Relative::new("./source").unwrap(); let first = source.resolve(Some(&rel), None).await.unwrap(); - assert_eq!(first.info().epoch.as_ref(), Some(&sibling)); + assert_eq!(first.info().identity.as_ref(), Some(&sibling)); let matching = source.resolve(Some(&rel), Some(&sibling)).await.unwrap(); assert!(matching.is_clone(&first)); assert!(is_mismatch(source.resolve(Some(&rel), Some(&other)).await, &other)); @@ -786,7 +794,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn a_replaced_sibling_fails_a_reference_to_the_old_epoch() { let origin = produce_origin(); - let (old_epoch, new_epoch) = (moq_net::Epoch::mint(), moq_net::Epoch::mint()); + let (old_epoch, new_epoch) = (moq_net::Identity::random(), moq_net::Identity::random()); let _catalog = origin.publish("a/pub", Default::default()).unwrap(); let _old = origin.publish("a/source", route(&old_epoch)).unwrap(); let _new = origin.publish("a/source", route(&new_epoch)).unwrap(); @@ -800,7 +808,7 @@ mod tests { )); // The path's first answer is pinned all the same: later references read that instance. let pinned = source.resolve(Some(&rel), None).await.unwrap(); - assert_eq!(pinned.info().epoch.as_ref(), Some(&new_epoch)); + assert_eq!(pinned.info().identity.as_ref(), Some(&new_epoch)); } /// A later reference naming the replacement cannot move a pinned sibling, and a new @@ -808,7 +816,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn a_conflicting_epoch_cannot_replace_a_pin() { let origin = produce_origin(); - let (old_epoch, new_epoch) = (moq_net::Epoch::mint(), moq_net::Epoch::mint()); + let (old_epoch, new_epoch) = (moq_net::Identity::random(), moq_net::Identity::random()); let _catalog = origin.publish("a/pub", Default::default()).unwrap(); let _old = origin.publish("a/source", route(&old_epoch)).unwrap(); settle().await; @@ -834,15 +842,15 @@ mod tests { // source keeps reading its pin. let fresh = source.switched(&source.broadcast()); let replaced = fresh.resolve(Some(&rel), Some(&new_epoch)).await.unwrap(); - assert_eq!(replaced.info().epoch.as_ref(), Some(&new_epoch)); + assert_eq!(replaced.info().identity.as_ref(), Some(&new_epoch)); assert!(source.resolve(Some(&rel), None).await.unwrap().is_clone(&first)); } /// A self-reference's epoch must not contradict the catalog broadcast's own. #[tokio::test(start_paused = true)] - async fn a_self_reference_epoch_checks_the_catalog_instance() { + async fn a_self_reference_identity_checks_the_catalog_broadcast() { let origin = produce_origin(); - let (catalog, other) = (moq_net::Epoch::mint(), moq_net::Epoch::mint()); + let (catalog, other) = (moq_net::Identity::random(), moq_net::Identity::random()); let _catalog = origin.publish("a/pub", route(&catalog)).unwrap(); settle().await; let source = resolved(&origin, "a/pub").await; @@ -855,29 +863,11 @@ mod tests { assert!(source.follow(rel.as_ref(), Some(&catalog)).is_ok()); assert!(matches!( source.follow(rel.as_ref(), Some(&other)), - Err(crate::Error::EpochMismatch { .. }) + Err(crate::Error::IdentityMismatch { .. }) )); } } - /// An epochless route cannot prove its instance, so an explicit epoch is best effort there. - #[tokio::test(start_paused = true)] - async fn an_epochless_route_reads_an_epoch_reference_best_effort() { - let origin = produce_origin(); - let _catalog = origin.publish("a/pub", Default::default()).unwrap(); - let _sibling = origin.publish("a/source", Default::default()).unwrap(); - settle().await; - let source = resolved(&origin, "a/pub").await; - let epoch = moq_net::Epoch::mint(); - - let sibling = source - .resolve(Some(&Relative::new("./source").unwrap()), Some(&epoch)) - .await - .unwrap(); - assert!(sibling.info().epoch.is_none()); - assert!(source.resolve(None, Some(&epoch)).await.is_ok()); - } - /// Every self-reference serves the broadcast the source was built from, even once it /// ended and a replacement took the path. #[tokio::test(start_paused = true)] diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index af211bd30d2..28aabb9840c 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -2506,7 +2506,7 @@ mod tests { .await .expect("stats broadcast announced within 5s") .expect("stats broadcast resolves"); - assert!(broadcast.info().epoch.is_some(), "stats announce under an epoch"); + assert!(broadcast.info().identity.is_some(), "stats announce under an epoch"); // Several publish intervals (1s each by default) after the handle went out // of scope, the task is still running and its broadcast still announced: a diff --git a/rs/moq-relay/tests/integration/goaway_cluster.rs b/rs/moq-relay/tests/integration/goaway_cluster.rs index 9717a959997..792aba0a7e0 100644 --- a/rs/moq-relay/tests/integration/goaway_cluster.rs +++ b/rs/moq-relay/tests/integration/goaway_cluster.rs @@ -213,7 +213,7 @@ async fn cluster_continues_a_group_split_by_goaway_inner() { within("routed via B", async { loop { let event = announced.next().await.expect("announce update"); - // Without an epoch, B's route is another source: a restart. + // Under another identity, B's route is another source: a restart. if let moq_net::announce::Event::Start(update) | moq_net::announce::Event::Update(update) | moq_net::announce::Event::Restart(update) @@ -485,7 +485,7 @@ async fn cluster_diamond_goaway_seamless_failover_inner() { let top_origin = moq_tokio::origin::spawn(); let broadcast = top_origin.create_broadcast("diamond").expect("create broadcast"); broadcast - .announce(moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint())) + .announce(moq_net::origin::Route::default()) .expect("create broadcast"); let track = broadcast.create_track("video", timed()).expect("create track"); diff --git a/rs/moq-relay/tests/integration/upstream_links.rs b/rs/moq-relay/tests/integration/upstream_links.rs index daab024ace7..86d4e995b4f 100644 --- a/rs/moq-relay/tests/integration/upstream_links.rs +++ b/rs/moq-relay/tests/integration/upstream_links.rs @@ -80,6 +80,8 @@ fn grant(path: &str) -> Grant { grant } +const LITE_07: &str = "moq-lite-07-wip"; + async fn node(id: u64, connect: Vec) -> Node { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let mut tasks = tokio::task::JoinSet::new(); @@ -88,6 +90,9 @@ async fn node(id: u64, connect: Vec) -> Node { listen.tcp.bind = Some("127.0.0.1:0".parse().expect("parse addr")); listen.tcp.tls = Some(true); listen.tls.generate = vec!["localhost".into()]; + // Links speak lite-07, which carries each route's identity, so every edge ranks a path's + // routes alike: over an older version each edge mints identities of its own. + listen.version = vec![LITE_07.parse().expect("lite-07")]; let mut listener = listen .init(Default::default()) .expect("server init") @@ -98,6 +103,7 @@ async fn node(id: u64, connect: Vec) -> Node { let mut client = moq_tokio::connect::Config::default(); client.tls.insecure = Some(true); + client.version = vec![LITE_07.parse().expect("lite-07")]; let mut config = cluster::Config::default(); config.id = Some(id); diff --git a/rs/moq-rtc/src/egress.rs b/rs/moq-rtc/src/egress.rs index 67ddd54e3ed..44d43caf05c 100644 --- a/rs/moq-rtc/src/egress.rs +++ b/rs/moq-rtc/src/egress.rs @@ -297,7 +297,7 @@ impl Rendition { }; Target { path: source.resolve_reference(config.broadcast()), - epoch: config.epoch().cloned(), + epoch: config.identity().cloned(), track: config.track(name).to_string(), } } @@ -310,13 +310,13 @@ impl Rendition { match &self { Self::Audio(name, config) => { let track = source - .subscribe_track(config.broadcast(), config.epoch(), config.track(name)) + .subscribe_track(config.broadcast(), config.identity(), config.track(name)) .await?; codec::Track::opus(track, config) } Self::Video(name, config) => { let track = source - .subscribe_track(config.broadcast(), config.epoch(), config.track(name)) + .subscribe_track(config.broadcast(), config.identity(), config.track(name)) .await?; codec::Track::video(track, config) } @@ -339,7 +339,7 @@ fn pick_audio<'a>( #[derive(Clone, Debug, PartialEq, Eq)] struct Target { path: Option, - epoch: Option, + epoch: Option, track: String, } diff --git a/rs/moq-rtc/src/lib.rs b/rs/moq-rtc/src/lib.rs index 7c486d540fb..44646d4b791 100644 --- a/rs/moq-rtc/src/lib.rs +++ b/rs/moq-rtc/src/lib.rs @@ -151,11 +151,11 @@ mod tests { /// kill the instance. fn publish_opus( origin: &moq_net::origin::Producer, - epoch: moq_net::Epoch, + epoch: moq_net::Identity, ) -> (Box, moq_mux::catalog::Producer) { let broadcast = origin.create_broadcast("source").expect("create source broadcast"); broadcast - .announce(moq_net::origin::Route::default().with_epoch(epoch)) + .announce(moq_net::origin::Route::default().with_identity(epoch, 0)) .expect("announce source broadcast"); let catalog = moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), moq_mux::catalog::Config::default()) @@ -167,7 +167,7 @@ mod tests { /// Publish a replacement instance of `source`, returning once the origin serves it. async fn take_over(origin: &moq_net::origin::Producer) -> (Box, moq_mux::catalog::Producer) { - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let mut instances = moq_mux::follow::Instances::new(&origin.consume(), "source", Some(&epoch)).unwrap(); let (mut new, catalog) = publish_opus(origin, epoch); push(&mut *new, 20_000, NEW_PACKET); @@ -192,7 +192,7 @@ mod tests { #[tokio::test] async fn a_whep_viewer_stays_on_its_instance_until_the_route_dies() { let origin = moq_tokio::origin::spawn(); - let (mut old, old_catalog) = publish_opus(&origin, moq_net::Epoch::mint()); + let (mut old, old_catalog) = publish_opus(&origin, moq_net::Identity::random()); push(&mut *old, 20_000, OPUS_PACKET); let server = Server::new(server::Config::default()); @@ -255,7 +255,7 @@ mod tests { #[tokio::test] async fn a_whip_push_stays_on_its_instance_until_the_route_dies() { let origin = moq_tokio::origin::spawn(); - let (mut old, old_catalog) = publish_opus(&origin, moq_net::Epoch::mint()); + let (mut old, old_catalog) = publish_opus(&origin, moq_net::Identity::random()); push(&mut *old, 20_000, OPUS_PACKET); let server = Server::new(server::Config::default()); diff --git a/rs/moq-rtc/src/server/whip.rs b/rs/moq-rtc/src/server/whip.rs index f84569203a9..76cbcd5f961 100644 --- a/rs/moq-rtc/src/server/whip.rs +++ b/rs/moq-rtc/src/server/whip.rs @@ -155,8 +155,8 @@ pub async fn accept( // subscriber doesn't see a 404 in the gap before the first RTP packet. Each // offer is its own publisher instance, so a reconnect replaces a stale // session's broadcast instead of resuming into it. - let epoch = moq_net::Epoch::mint(); - handle.announce(moq_net::origin::Route::default().with_epoch(epoch.clone()))?; + let epoch = moq_net::Identity::random(); + handle.announce(moq_net::origin::Route::default().with_identity(epoch.clone(), 0))?; let resource_id = sdp::new_resource_id(); let socket = moq_ice::Socket::new(mux.socket(), mux.candidates().to_vec(), inbound)?; diff --git a/rs/moq-rtmp/src/dial.rs b/rs/moq-rtmp/src/dial.rs index 8f6e937023f..dc819804dff 100644 --- a/rs/moq-rtmp/src/dial.rs +++ b/rs/moq-rtmp/src/dial.rs @@ -590,15 +590,15 @@ struct Publisher { // Controls publication so an end can retract before the importer's fill clone drops. broadcast: moq_net::origin::Publication, // This pull's publisher instance, so pulling again replaces it rather than resuming into it. - epoch: moq_net::Epoch, + epoch: moq_net::Identity, media_deadline: moq_clock::Instant, } impl Publisher { fn new(origin: &origin::Producer, path: &str, config: moq_mux::catalog::Config) -> anyhow::Result { - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let broadcast = origin - .publish(path, moq_net::origin::Route::default().with_epoch(epoch.clone())) + .publish(path, moq_net::origin::Route::default().with_identity(epoch.clone(), 0)) .map_err(|err| anyhow::anyhow!("broadcast '{path}' could not be published: {err}"))?; let catalog = moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), config)?; let handle = broadcast.clone(); @@ -891,7 +891,7 @@ mod tests { const OLD_IDR: &[u8] = &[0x65, 0x88, 0x84, 0x21, 0x00]; const OLD_DELTA: &[u8] = &[0x41, 0x9a, 0x02, 0x03]; const NEW_IDR: &[u8] = &[0x65, 0x99, 0x99, 0x99, 0x99]; - let publish = |origin: &origin::Producer, epoch: &moq_net::Epoch, idr: &[u8]| { + let publish = |origin: &origin::Producer, epoch: &moq_net::Identity, idr: &[u8]| { let mut vseq = vec![0x17, 0x00, 0x00, 0x00, 0x00]; vseq.extend_from_slice(&[0x01, 0x42, 0xc0, 0x1f, 0xff, 0xe1, 0x00, 0x04, 0x67, 0x42, 0xc0, 0x1f]); vseq.extend_from_slice(&[0x01, 0x00, 0x04, 0x68, 0xce, 0x3c, 0x80]); @@ -899,7 +899,7 @@ mod tests { vframe.extend_from_slice(idr); let broadcast = origin.create_broadcast("live/cam0").unwrap(); broadcast - .announce(origin::Route::default().with_epoch(epoch.clone())) + .announce(origin::Route::default().with_identity(epoch.clone(), 0)) .unwrap(); let catalog = moq_mux::catalog::Producer::new( &mut broadcast.broadcast().clone(), @@ -915,7 +915,7 @@ mod tests { let upstream = moq_tokio::origin::spawn(); let downstream = moq_tokio::origin::spawn(); - let (mut old, old_catalog) = publish(&upstream, &moq_net::Epoch::mint(), OLD_IDR); + let (mut old, old_catalog) = publish(&upstream, &moq_net::Identity::random(), OLD_IDR); let (client_io, server_io) = tokio::io::duplex(64 * 1024); let ingest = downstream.clone(); let server = tokio::spawn(async move { @@ -951,7 +951,7 @@ mod tests { }; until(OLD_IDR).await; - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let mut instances = moq_mux::follow::Instances::new(&upstream.consume(), "live/cam0", Some(&epoch)).unwrap(); let (_new, _new_catalog) = publish(&upstream, &epoch, NEW_IDR); moq_clock::timeout(Duration::from_secs(5), instances.next()) diff --git a/rs/moq-rtmp/src/rml/sessions/server/mod.rs b/rs/moq-rtmp/src/rml/sessions/server/mod.rs index 179c1383fd1..c06aff70b69 100644 --- a/rs/moq-rtmp/src/rml/sessions/server/mod.rs +++ b/rs/moq-rtmp/src/rml/sessions/server/mod.rs @@ -708,7 +708,7 @@ impl ServerSession { _ => Vec::new(), }; - // A connection carries one publisher epoch or one playback, never a replacement. + // A connection carries one publisher identity or one playback, never a replacement. stream.current_state = StreamState::Completed; self.current_state = SessionState::Completed; self.request = None; diff --git a/rs/moq-rtmp/src/server.rs b/rs/moq-rtmp/src/server.rs index 4942a8a8c18..0bf0238db48 100644 --- a/rs/moq-rtmp/src/server.rs +++ b/rs/moq-rtmp/src/server.rs @@ -1513,15 +1513,15 @@ struct Publisher { broadcast: moq_net::origin::Publication, // This connection's publisher instance, so an encoder reconnecting under the // same key replaces this broadcast at once instead of resuming into it. - epoch: moq_net::Epoch, + epoch: moq_net::Identity, } impl Publisher { /// Open a broadcast at `path` under a fresh epoch and prime the importer with /// the FLV file header, so subsequent tags decode against an initialized demuxer. fn new(origin: &origin::Producer, path: &str, config: moq_mux::catalog::Config) -> anyhow::Result { - let epoch = moq_net::Epoch::mint(); - let broadcast = origin.publish(path, moq_net::origin::Route::default().with_epoch(epoch.clone()))?; + let epoch = moq_net::Identity::random(); + let broadcast = origin.publish(path, moq_net::origin::Route::default().with_identity(epoch.clone(), 0))?; let catalog = moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), config)?; let handle = broadcast.clone(); let mut importer = FlvImport::new(broadcast.broadcast().clone(), catalog.reserve()); @@ -2285,7 +2285,7 @@ mod tests { /// `idr`, and return its importer for more tags. fn publish_avc( origin: &origin::Producer, - epoch: moq_net::Epoch, + epoch: moq_net::Identity, idr: &[u8], ) -> (FlvImport, moq_mux::catalog::Producer) { let mut vseq = vec![0x17, 0x00, 0x00, 0x00, 0x00]; @@ -2295,7 +2295,9 @@ mod tests { vframe.extend_from_slice(idr); let broadcast = origin.create_broadcast("live/cam0").unwrap(); - broadcast.announce(origin::Route::default().with_epoch(epoch)).unwrap(); + broadcast + .announce(origin::Route::default().with_identity(epoch, 0)) + .unwrap(); let catalog = moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), moq_mux::catalog::Config::default()) .unwrap(); @@ -2314,7 +2316,7 @@ mod tests { const OLD_DELTA: &[u8] = &[0x41, 0x9a, 0x02, 0x03]; let origin = moq_tokio::origin::spawn(); let consumer = origin.consume(); - let (mut old, old_catalog) = publish_avc(&origin, moq_net::Epoch::mint(), &[0x65, 0x88, 0x84, 0x21, 0x00]); + let (mut old, old_catalog) = publish_avc(&origin, moq_net::Identity::random(), &[0x65, 0x88, 0x84, 0x21, 0x00]); let mut server = Server::bind("127.0.0.1:0".parse().unwrap()).await.unwrap(); let addr = server.local_addr().unwrap(); @@ -2337,7 +2339,7 @@ mod tests { assert_eq!(next().await.1[..2], [0x17, 0x00]); assert_eq!(next().await.1[..2], [0x17, 0x01]); - let epoch = moq_net::Epoch::mint(); + let epoch = moq_net::Identity::random(); let mut instances = moq_mux::follow::Instances::new(&consumer, "live/cam0", Some(&epoch)).unwrap(); let (_new, _new_catalog) = publish_avc(&origin, epoch, &[0x65, 0x99, 0x99, 0x99, 0x99]); moq_clock::timeout(Duration::from_secs(5), instances.next()) diff --git a/rs/moq-rtsp/src/client.rs b/rs/moq-rtsp/src/client.rs index cbf5f10e38a..bf322fbd45a 100644 --- a/rs/moq-rtsp/src/client.rs +++ b/rs/moq-rtsp/src/client.rs @@ -857,8 +857,8 @@ mod tests { async move { let mut ends = Vec::new(); for _ in 0..2 { - let epoch = moq_net::Epoch::mint(); - let route = moq_net::origin::Route::default().with_epoch(epoch.clone()); + let epoch = moq_net::Identity::random(); + let route = moq_net::origin::Route::default().with_identity(epoch.clone(), 0); let broadcast = origin.publish("camera", route).unwrap(); let mut catalog = moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), Default::default()) @@ -908,7 +908,7 @@ mod tests { let ends = within("the caller", caller).await.unwrap(); assert_eq!(ends[0], End::Jumped); assert_eq!(ends[1], End::Closed); - assert!(epochs[1] > epochs[0], "the restart mints a fresh epoch"); + assert_ne!(epochs[1], epochs[0], "the restart mints a fresh identity"); } /// Credentials come out of the URL, never reach a log, and still authenticate. diff --git a/rs/moq-scene/src/source.rs b/rs/moq-scene/src/source.rs index 835e7c446cb..6a7ab797835 100644 --- a/rs/moq-scene/src/source.rs +++ b/rs/moq-scene/src/source.rs @@ -618,9 +618,7 @@ mod tests { .unwrap() }; let first = audio("first", None); - publication - .announce(moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint())) - .unwrap(); + publication.announce(moq_net::origin::Route::default()).unwrap(); let mut sources = Sources::new(context); let drivers = sources.sync(&collection()); @@ -661,9 +659,7 @@ mod tests { .unwrap(); }; point(&mut catalog, "first"); - publication - .announce(moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint())) - .unwrap(); + publication.announce(moq_net::origin::Route::default()).unwrap(); let mut sources = Sources::new(context); let drivers = sources.sync(&collection()); @@ -698,9 +694,7 @@ mod tests { catalog.audio.renditions.insert("audio".into(), config); }) .unwrap(); - publication - .announce(moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint())) - .unwrap(); + publication.announce(moq_net::origin::Route::default()).unwrap(); let mut sources = Sources::new(context); let drivers = sources.sync(&collection()); diff --git a/rs/moq-sign/README.md b/rs/moq-sign/README.md index 24ddc348ea1..3e86cf5eb33 100644 --- a/rs/moq-sign/README.md +++ b/rs/moq-sign/README.md @@ -16,9 +16,9 @@ An endpoint's `SecretKey` signs; its `PublicKey` is the endpoint ID, displayed a characters (a DNS label). `SecretKey::load_or_generate(path)` keeps one key for the life of a host, as 52 z-base-32 characters in a `0600` file it never replaces implicitly. -`broadcast::Producer` wraps the `moq-net` broadcast, mints a fresh epoch, and signs each frame -over the application path, epoch, track, group, frame index, and timestamp (at the track's -timescale). Announce the broadcast with that epoch on its route: +`broadcast::Producer` wraps the `moq-net` broadcast, mints a fresh identity, and signs each frame +over the application path, identity, track, group, frame index, and timestamp (at the track's +timescale). Announce the broadcast with that identity on its route: ```rust let key = SecretKey::load_or_generate("/var/lib/moq/endpoint.key")?; @@ -26,13 +26,13 @@ let path = moq_net::Path::new(&format!("mesh/{}/agent", key.public()))?; let publication = origin.create_broadcast(&path)?; let signed = broadcast::Producer::new(publication.broadcast().clone(), key, path); let messages = signed.create_track("messages", None)?; -publication.announce(Route::default().with_epoch(signed.epoch().clone()))?; +publication.announce(Route::default().with_identity(signed.identity().clone(), 0))?; ``` A subscriber wraps the broadcast an origin resolved in a `broadcast::Consumer` with the key it -expects and the same application path; it takes the epoch from the route. A frame reaches the +expects and the same application path; it takes the identity from the route. A frame reaches the application only after it verifies. A forged or relocated frame ends its group with -`Error::Signature`, and the track keeps delivering other groups. Every hop must carry the epoch +`Error::Signature`, and the track keeps delivering other groups. Every hop must carry the identity and timestamps unchanged, which means moq-lite-07. A group's end is not signed: a relay may drop a group's tail, as MoQ already allows. diff --git a/rs/moq-sign/benches/sign.rs b/rs/moq-sign/benches/sign.rs index 06669bd31b1..cbe38bd30c0 100644 --- a/rs/moq-sign/benches/sign.rs +++ b/rs/moq-sign/benches/sign.rs @@ -36,7 +36,7 @@ async fn roundtrip( let info = moq_net::track::Info::default().with_max_age(Duration::ZERO); let track = signed.create_track("stream", info).unwrap(); publication - .announce(moq_net::origin::Route::default().with_epoch(signed.epoch().clone())) + .announce(moq_net::origin::Route::default().with_identity(signed.identity().clone(), 0)) .unwrap(); let inner = origin.consume().request(&path, None).await.unwrap(); let consumer = broadcast::Consumer::new(inner, key.public(), path).unwrap(); diff --git a/rs/moq-sign/src/broadcast.rs b/rs/moq-sign/src/broadcast.rs index 15eee2d58e6..d6ae3b5c2b0 100644 --- a/rs/moq-sign/src/broadcast.rs +++ b/rs/moq-sign/src/broadcast.rs @@ -13,14 +13,14 @@ use crate::track; /// The publishing side of a signed broadcast. /// -/// Mints a fresh epoch, so a restart never signs a sequence an earlier instance already signed. -/// Announce the wrapped broadcast with [`Self::epoch`] on its route. +/// Mints a fresh identity, so a restart never signs a sequence an earlier instance already signed. +/// Announce the wrapped broadcast with [`Self::identity`] on its route. #[derive(Clone)] pub struct Producer { inner: moq_net::broadcast::Producer, key: SecretKey, path: moq_net::Path, - epoch: moq_net::Epoch, + identity: moq_net::Identity, // Each track's lowest unsigned sequence, kept across the track's replacements. next: Arc>>>>, } @@ -32,14 +32,14 @@ impl Producer { inner, key, path, - epoch: moq_net::Epoch::mint(), + identity: moq_net::Identity::random(), next: Arc::default(), } } - /// The epoch this instance signs under, to announce on the broadcast's route. - pub fn epoch(&self) -> &moq_net::Epoch { - &self.epoch + /// The identity this instance signs under, to announce on the broadcast's route. + pub fn identity(&self) -> &moq_net::Identity { + &self.identity } /// Create a track whose frames are signed. @@ -49,7 +49,7 @@ impl Producer { /// A net error if the broadcast already holds the name or has ended. pub fn create_track(&self, name: &str, info: impl Into>) -> Result { let inner = self.inner.create_track(name, info)?; - let scope = Scope::new(&self.path, &self.epoch, name); + let scope = Scope::new(&self.path, &self.identity, name); let next = Arc::clone( self.next .lock() @@ -66,14 +66,14 @@ impl std::fmt::Debug for Producer { f.debug_struct("broadcast::Producer") .field("key", &self.key.public()) .field("path", &self.path) - .field("epoch", &self.epoch) + .field("identity", &self.identity) .finish() } } /// The subscribing side of a signed broadcast. /// -/// Every frame must verify against the expected publisher key under the epoch of the route the +/// Every frame must verify against the expected publisher key under the identity of the route the /// broadcast was resolved through. The route comes from the relay, so this checks that frames /// belong together, not that the instance is live: a relay can replay a recorded instance whole. #[derive(Clone)] @@ -81,7 +81,7 @@ pub struct Consumer { inner: moq_net::broadcast::Consumer, key: PublicKey, path: moq_net::Path, - epoch: moq_net::Epoch, + identity: moq_net::Identity, } impl Consumer { @@ -89,20 +89,27 @@ impl Consumer { /// /// # Errors /// - /// [`Error::Epoch`] if `inner` was not resolved through a route that names its epoch. + /// [`Error::Identity`] if `inner` was not resolved through a route that carries its + /// publisher's identity: one a relay [minted](moq_net::Identity::is_minted) for a wire without + /// identities names the announcement, not the publisher. pub fn new(inner: moq_net::broadcast::Consumer, key: PublicKey, path: moq_net::Path) -> Result { - let epoch = inner.info().epoch.clone().ok_or(Error::Epoch)?; + let identity = inner + .info() + .identity + .clone() + .filter(|identity| !identity.is_minted()) + .ok_or(Error::Identity)?; Ok(Self { inner, key, path, - epoch, + identity, }) } - /// The epoch frames must be signed under. - pub fn epoch(&self) -> &moq_net::Epoch { - &self.epoch + /// The identity frames must be signed under. + pub fn identity(&self) -> &moq_net::Identity { + &self.identity } /// Subscribe to a track whose frames must verify. @@ -116,7 +123,7 @@ impl Consumer { subscription: impl Into>, ) -> Result> { let inner = self.inner.track(name)?; - let scope = Scope::new(&self.path, &self.epoch, name); + let scope = Scope::new(&self.path, &self.identity, name); Ok(kio::Pending::new(track::Subscribing::new( inner.subscribe(subscription), self.key, @@ -130,7 +137,7 @@ impl std::fmt::Debug for Consumer { f.debug_struct("broadcast::Consumer") .field("key", &self.key) .field("path", &self.path) - .field("epoch", &self.epoch) + .field("identity", &self.identity) .finish() } } diff --git a/rs/moq-sign/src/error.rs b/rs/moq-sign/src/error.rs index fe75aebe24c..a251a3bbace 100644 --- a/rs/moq-sign/src/error.rs +++ b/rs/moq-sign/src/error.rs @@ -6,9 +6,9 @@ pub enum Error { #[error("signature")] Signature, - /// The broadcast was not resolved through a route that names its publisher epoch. - #[error("epoch")] - Epoch, + /// The broadcast was not resolved through a route that carries its publisher's identity. + #[error("identity")] + Identity, /// The underlying MoQ broadcast, track, or group failed. #[error(transparent)] diff --git a/rs/moq-sign/src/lib.rs b/rs/moq-sign/src/lib.rs index 52b912a0001..80f091abc06 100644 --- a/rs/moq-sign/src/lib.rs +++ b/rs/moq-sign/src/lib.rs @@ -2,17 +2,17 @@ //! //! Implements profile `moq-sign-00` from [draft-lcurley-moq-sign]. A publisher signs every //! grouped frame with its endpoint's [`SecretKey`], binding the broadcast path, its publisher -//! epoch, the track, the group, the frame index, and the timestamp. A subscriber hands a frame +//! identity, the track, the group, the frame index, and the timestamp. A subscriber hands a frame //! to the application only after it verifies against the [`PublicKey`] it expects, so a relay //! can proxy and cache without forging, retiming, relocating, or splicing content. Signatures //! authenticate content, not liveness: a relay that recorded an instance can replay it whole under -//! its epoch. +//! its identity. //! //! [draft-lcurley-moq-sign]: https://datatracker.ietf.org/doc/draft-lcurley-moq-sign/ //! //! A publisher wraps its `moq-net` broadcast in a [`broadcast::Producer`], which mints a fresh -//! epoch to announce on the broadcast's route. A subscriber wraps the broadcast an origin -//! resolved in a [`broadcast::Consumer`], which takes the epoch from that route. Both sides name +//! identity to announce on the broadcast's route. A subscriber wraps the broadcast an origin +//! resolved in a [`broadcast::Consumer`], which takes the identity from that route. Both sides name //! the broadcast by the same application path, independent of where a relay mounts it. //! //! A broadcast that needs no signature, the identity level, uses `moq-net` directly. diff --git a/rs/moq-sign/src/scope.rs b/rs/moq-sign/src/scope.rs index 23b4ede72ce..21c2426452f 100644 --- a/rs/moq-sign/src/scope.rs +++ b/rs/moq-sign/src/scope.rs @@ -16,15 +16,15 @@ const LABEL: &[u8] = b"moq-sign-00"; /// One track of one publisher instance: the identity every frame on it is signed under. #[derive(Debug)] pub(crate) struct Scope { - // The canonical prefix: label, path, epoch, and track, each length-prefixed. + // The canonical prefix: label, path, identity, and track, each length-prefixed. prefix: Vec, } impl Scope { - pub(crate) fn new(path: &moq_net::Path, epoch: &moq_net::Epoch, track: &str) -> Arc { + pub(crate) fn new(path: &moq_net::Path, identity: &moq_net::Identity, track: &str) -> Arc { let mut prefix = Vec::with_capacity(LABEL.len() + 24 + path.as_str().len() + 36 + track.len()); prefix.extend_from_slice(LABEL); - for field in [path.as_str(), epoch.as_str(), track] { + for field in [path.as_str(), identity.as_str(), track] { prefix.extend_from_slice(&(field.len() as u64).to_be_bytes()); prefix.extend_from_slice(field.as_bytes()); } diff --git a/rs/moq-sign/src/tests.rs b/rs/moq-sign/src/tests.rs index 5a71485dd41..6b057ebd5d5 100644 --- a/rs/moq-sign/src/tests.rs +++ b/rs/moq-sign/src/tests.rs @@ -29,8 +29,8 @@ fn public(value: &Value) -> PublicKey { /// A vector row's identity: its scope, group, frame, and timestamp (timescale 0 when untimed). fn identity(row: &Value) -> (Arc, u64, u64, Option) { let path = moq_net::Path::new(row["path"].as_str().unwrap()).unwrap(); - let epoch = row["epoch"].as_str().unwrap().parse().unwrap(); - let scope = Scope::new(&path, &epoch, row["track"].as_str().unwrap()); + let identity = row["identity"].as_str().unwrap().parse().unwrap(); + let scope = Scope::new(&path, &identity, row["track"].as_str().unwrap()); let timestamp = match row["timescale"].as_u64().unwrap() { 0 => None, scale => Some(moq_net::Timestamp::from_scale(row["timestamp"].as_u64().unwrap(), scale).unwrap()), @@ -165,7 +165,7 @@ fn subscribe_all() -> moq_net::track::Subscription { moq_net::track::Subscription::default().with_max_delay(Duration::from_secs(60)) } -/// A signed broadcast published at `path` on its own origin, announced under its epoch. +/// A signed broadcast published at `path` on its own origin, announced under its identity. struct Published { origin: moq_net::origin::Producer, _publication: moq_net::origin::Publication, @@ -180,7 +180,7 @@ fn publish(key: &SecretKey, path: &str) -> Published { let publication = origin.create_broadcast(&path).unwrap(); let signed = broadcast::Producer::new(publication.broadcast().clone(), key.clone(), path.clone()); publication - .announce(moq_net::origin::Route::default().with_epoch(signed.epoch().clone())) + .announce(moq_net::origin::Route::default().with_identity(signed.identity().clone(), 0)) .unwrap(); Published { origin, @@ -201,7 +201,7 @@ impl Published { /// A signed track over a net track the test can also write forged frames to. fn track(&self, key: &SecretKey, name: &str) -> (track::Producer, moq_net::track::Producer) { let net = self._publication.broadcast().create_track(name, None).unwrap(); - let scope = Scope::new(&self.path, self.signed.epoch(), name); + let scope = Scope::new(&self.path, self.signed.identity(), name); ( track::Producer::new(net.clone(), key.clone(), scope, Arc::default()), net, @@ -235,7 +235,7 @@ async fn roundtrip_through_an_origin() { ) .unwrap(); let consumer = published.consume(key.public()).await; - assert_eq!(consumer.epoch(), published.signed.epoch()); + assert_eq!(consumer.identity(), published.signed.identity()); let mut track = subscribe(&consumer, "video").await; let mut group = video.append_group().unwrap(); @@ -369,9 +369,9 @@ async fn frame_from_an_earlier_instance_is_refused() { group.finish().unwrap(); let body = raw_frame(&mut raw).await; - // A restart mints a new epoch, so the old instance's group 0 does not verify as the new one's. + // A restart mints a new identity, so the old instance's group 0 does not verify as the new one's. let new = publish(&key, "mesh/node/ssh"); - assert_ne!(new.signed.epoch(), old.signed.epoch()); + assert_ne!(new.signed.identity(), old.signed.identity()); let (_new_track, new_net) = new.track(&key, "stream"); let consumer = new.consume(key.public()).await; let mut subscribed = subscribe(&consumer, "stream").await; @@ -411,7 +411,7 @@ fn group_sequence_is_never_signed_twice() { Err(Error::Net(moq_net::Error::Duplicate)) )); - // A replacement track continues past its predecessor's sequences under the same epoch. + // A replacement track continues past its predecessor's sequences under the same identity. track.abort(moq_net::Error::Cancel).unwrap(); let replacement = signed.create_track("stream", None).unwrap(); assert!(matches!( @@ -425,8 +425,8 @@ fn group_sequence_is_never_signed_twice() { async fn group_resumed_above_frame_zero_verifies_at_its_index() { let key = SecretKey::generate(); let path = moq_net::Path::new("mesh/node/ssh").unwrap(); - let epoch = moq_net::Epoch::mint(); - let scope = Scope::new(&path, &epoch, "stream"); + let identity = moq_net::Identity::random(); + let scope = Scope::new(&path, &identity, "stream"); let broadcast = moq_net::broadcast::Info::new().produce(); let track = broadcast.create_track("stream", None).unwrap(); let net = track.append_group().unwrap(); @@ -442,11 +442,11 @@ async fn group_resumed_above_frame_zero_verifies_at_its_index() { } #[test] -fn consumer_without_an_epoch_is_refused() { +fn consumer_without_an_identity_is_refused() { let inner = moq_net::broadcast::Info::new().produce().consume(); let path = moq_net::Path::new("mesh/node/ssh").unwrap(); let result = broadcast::Consumer::new(inner, SecretKey::generate().public(), path); - assert!(matches!(result, Err(Error::Epoch))); + assert!(matches!(result, Err(Error::Identity))); } /// Runs `test/interop/sign.ts` with a JSON argument, returning its JSON stdout. @@ -473,7 +473,7 @@ fn sign_interop() { aws_lc_rs::rand::fill(seed.as_mut()).unwrap(); let key = SecretKey::from_seed(seed.clone()); let path = moq_net::Path::new(&format!("mesh/{}/agent", key.public())).unwrap(); - let epoch = moq_net::Epoch::mint(); + let identity = moq_net::Identity::random(); let payloads: [&[u8]; 3] = [b"zero", b"", &[0xff; 300]]; let expected: Vec = payloads.iter().map(hex::encode).collect(); @@ -482,7 +482,7 @@ fn sign_interop() { // timed track), delivered at `group` and `delivered`. let sign_group = |group: u64, signed_as: u64, timestamps: Option<([u64; 3], [u64; 3])>| { let track = if timestamps.is_some() { "video" } else { "data" }; - let scope = Scope::new(&path, &epoch, track); + let scope = Scope::new(&path, &identity, track); let frames: Vec = payloads .iter() .enumerate() @@ -511,7 +511,7 @@ fn sign_interop() { let output = bun(serde_json::json!({ "seed": hex::encode(seed.as_ref()), "path": path.as_str(), - "epoch": epoch.as_str(), + "identity": identity.as_str(), "rust": [ sign_group(7, 7, None), sign_group(8, 7, None), @@ -528,11 +528,11 @@ fn sign_interop() { assert_eq!(output["verified"][2], serde_json::json!(expected)); assert_eq!(output["verified"][3], "signature"); - let js_epoch = output["epoch"].as_str().unwrap().parse().unwrap(); + let js_identity = output["identity"].as_str().unwrap().parse().unwrap(); let groups = output["js"].as_array().unwrap(); assert_eq!(groups.len(), 3); for row in groups { - let scope = Scope::new(&path, &js_epoch, row["track"].as_str().unwrap()); + let scope = Scope::new(&path, &js_identity, row["track"].as_str().unwrap()); let group = row["group"].as_u64().unwrap(); let timestamp = |frame: &Value| { let scale = row["timescale"].as_u64()?; diff --git a/rs/moq-sign/src/track.rs b/rs/moq-sign/src/track.rs index 684d3e6d4ea..0886bc93356 100644 --- a/rs/moq-sign/src/track.rs +++ b/rs/moq-sign/src/track.rs @@ -11,7 +11,7 @@ use crate::scope::Scope; /// Producer for one signed track. /// -/// Group sequences only rise under an epoch, even across a replacement of the track, so no two +/// Group sequences only rise under an identity, even across a replacement of the track, so no two /// groups are signed at the same coordinates. A relay holding an aborted group and a new group at /// its sequence could otherwise splice their frames into a group that was never published. pub struct Producer { diff --git a/rs/moq-srt/src/server.rs b/rs/moq-srt/src/server.rs index ecbb0c9c4cf..a8e2c28e3bf 100644 --- a/rs/moq-srt/src/server.rs +++ b/rs/moq-srt/src/server.rs @@ -1020,7 +1020,7 @@ mod tests { impl Instance { fn publish(origin: &moq_net::origin::Producer, track: &str) -> Self { - let route = moq_net::origin::Route::default().with_epoch(moq_net::Epoch::mint()); + let route = moq_net::origin::Route::default(); let broadcast = origin.publish("live", route).unwrap(); let mut catalog = moq_mux::catalog::Producer::new( &mut broadcast.broadcast().clone(), diff --git a/rs/moq-srt/src/ts.rs b/rs/moq-srt/src/ts.rs index 10806cb64b1..14c6c4ec6bc 100644 --- a/rs/moq-srt/src/ts.rs +++ b/rs/moq-srt/src/ts.rs @@ -89,9 +89,9 @@ impl Instance { path: &str, config: moq_mux::catalog::Config, program: Option, - ) -> Result<(Self, moq_net::Epoch)> { - let epoch = moq_net::Epoch::mint(); - let broadcast = origin.publish(path, moq_net::origin::Route::default().with_epoch(epoch.clone()))?; + ) -> Result<(Self, moq_net::Identity)> { + let epoch = moq_net::Identity::random(); + let broadcast = origin.publish(path, moq_net::origin::Route::default().with_identity(epoch.clone(), 0))?; let config = config.with_catalog(moq_mux::catalog::hang::Catalog::::default()); let catalog = moq_mux::catalog::Producer::new(&mut broadcast.broadcast().clone(), config)?; let mut import = ts::Import::new(broadcast.broadcast().clone(), catalog.reserve()); @@ -128,7 +128,7 @@ impl Publisher { program: Option, ) -> Result { let importer = match program { - // `ts::Programs` mints an epoch per program. + // `ts::Programs` mints an identity per program. Some(Program::All) => { tracing::info!(%path, ?program, "publishing ingest broadcasts"); Importer::All(Box::new(ts::Programs::new( diff --git a/rs/moq-stats/Cargo.toml b/rs/moq-stats/Cargo.toml index baae174105b..2045ee20923 100644 --- a/rs/moq-stats/Cargo.toml +++ b/rs/moq-stats/Cargo.toml @@ -28,6 +28,7 @@ serde_with = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["rt"] } tracing = { workspace = true } +uuid = { workspace = true } [dev-dependencies] criterion = { package = "bench-harness", path = "../bench-harness" } diff --git a/rs/moq-stats/benches/aggregate.rs b/rs/moq-stats/benches/aggregate.rs index 0e0bcddf618..23fdf9d6df2 100644 --- a/rs/moq-stats/benches/aggregate.rs +++ b/rs/moq-stats/benches/aggregate.rs @@ -1,7 +1,6 @@ //! Cost of one merged group frame from [`moq_stats::aggregate`], swept over live nodes and //! nodes that have come and gone, with and without the grace that folds departed nodes away. -//! Compare known metadata with one touched publisher losing and recovering its epoch, -//! and with live nodes that resumed after their open pairs were closed. +//! Compare steady nodes with live nodes that resumed after their open pairs were closed. //! //! Each node reports the same broadcast paths, like a restarted node under a new name. A bounded //! aggregate's cost follows the live nodes; an unbounded one (`grace` never elapsing) also pays for @@ -18,7 +17,7 @@ use std::collections::BTreeMap; use std::time::{Duration, Instant}; -use moq_net::{Epoch, origin}; +use moq_net::{Identity, origin}; use moq_stats::{Group, Traffic, aggregate, group_track}; /// Broadcast paths each node reports. @@ -41,8 +40,7 @@ struct Body { /// default-tier egress. struct Node { source: moq_net::origin::Publication, - epoch: Epoch, - known: bool, + epoch: Identity, group: moq_json::snapshot::Producer, frame: Body, } @@ -50,9 +48,9 @@ struct Node { impl Node { fn new(origin: &origin::Producer, name: &str) -> Self { let source = origin.create_broadcast(format!(".stats/node/{name}").as_str()).unwrap(); - let epoch = Epoch::mint(); + let epoch = Identity::random(); source - .announce(origin::Route::default().with_epoch(epoch.clone())) + .announce(origin::Route::default().with_identity(epoch.clone(), 0)) .unwrap(); let track = source .create_track( @@ -68,13 +66,12 @@ impl Node { group.paths.entry(format!("acme/room-{key:02}")).or_default().publisher = traffic; } let frame = Body { - epoch: Epoch::mint().to_string(), + epoch: "01900000-0000-7000-8000-000000000001".to_string(), tiers: BTreeMap::from([(String::new(), group)]), }; Self { source, epoch, - known: true, group: moq_json::snapshot::Producer::new(track, config), frame, } @@ -110,7 +107,6 @@ async fn read_until(traffic: &mut aggregate::GroupConsumer, want: u64) { #[derive(Clone, Copy)] enum State { Known, - Mixed, Resumed, } @@ -148,7 +144,7 @@ async fn measure(live: usize, departed: usize, grace: Duration, state: State) -> } } node.source - .announce(origin::Route::default().with_epoch(node.epoch.clone())) + .announce(origin::Route::default().with_identity(node.epoch.clone(), 0)) .unwrap(); node.publish(1); total += 1; @@ -163,14 +159,6 @@ async fn measure(live: usize, departed: usize, grace: Duration, state: State) -> for frame in 0..=FRAMES { let node = &mut nodes[frame as usize % live]; let start = Instant::now(); - if matches!(state, State::Mixed) { - node.known = !node.known; - let mut route = origin::Route::default(); - if node.known { - route.epoch = Some(node.epoch.clone()); - } - node.source.announce(route).unwrap(); - } node.publish(1); total += 1; read_until(&mut traffic, total).await; @@ -200,11 +188,7 @@ fn main() { for live in [1, 16, 128] { for departed in [0, 256, 4096] { for (label, grace) in [("1s", GRACE), ("never", Duration::MAX)] { - for (metadata, state) in [ - ("known", State::Known), - ("mixed", State::Mixed), - ("resumed", State::Resumed), - ] { + for (metadata, state) in [("known", State::Known), ("resumed", State::Resumed)] { let elapsed = runtime.block_on(measure(live, departed, grace, state)); let micros = elapsed.as_secs_f64() * 1e6; println!("{live:>4} {departed:>8} {label:>5} {metadata:>8} {micros:>11.1}"); diff --git a/rs/moq-stats/benches/decode.rs b/rs/moq-stats/benches/decode.rs index 3bbea22c516..329ce8465b6 100644 --- a/rs/moq-stats/benches/decode.rs +++ b/rs/moq-stats/benches/decode.rs @@ -88,7 +88,7 @@ fn traffic(index: usize, tick: u64, changed_percent: usize) -> Traffic { /// The frames a group's producer would publish over the run, snapshot first. fn frames(broadcasts: usize, tiers: usize, changed_percent: usize) -> Vec<(Vec, bool)> { let mut encoder = Encoder::::new(snapshot::Config::default().with_deltas(true)); - let epoch = moq_net::Epoch::mint().to_string(); + let epoch = "01900000-0000-7000-8000-000000000001".to_string(); (0..TICKS + 2) .map(|tick| { let tiers = (0..tiers) diff --git a/rs/moq-stats/src/aggregate.rs b/rs/moq-stats/src/aggregate.rs index bb5d0a73eaa..01dcea60925 100644 --- a/rs/moq-stats/src/aggregate.rs +++ b/rs/moq-stats/src/aggregate.rs @@ -15,7 +15,7 @@ use moq_clock::Instant; use moq_net::kio::{self, Pending, Waiter}; use moq_net::stats::{Presence, Traffic}; use moq_net::track::Subscribing; -use moq_net::{Epoch, Path, broadcast, origin}; +use moq_net::{Identity, Path, broadcast, origin}; use crate::{ Group, GroupFrame, Prefix, PrefixFrame, PresenceFrame, Result, Totals, TotalsFrame, Usage, group_track, @@ -37,8 +37,7 @@ pub struct Config { pub prefix: Path, /// How long a departed node's traffic waits for its path to return before /// folding into the retired total (default 10 minutes). A return within it - /// under the same epoch (or again without one) resumes the node's own - /// counters; a return after it with counters intact counts its earlier + /// under the same identity resumes the node's own counters; a return after it with counters intact counts its earlier /// traffic twice, so size this above reconnect times. pub grace: Duration, } @@ -88,31 +87,29 @@ impl Default for Config { /// within [`Config::grace`] with its counters intact never looks like new /// traffic. After the grace, a departed node's contribution folds into one /// retired total and the node is forgotten, so memory follows live nodes and -/// the keys ever reported, not every node ever seen. Each route epoch is its -/// own node: a restarted producer announces a new epoch whose counters add to -/// the old epoch's kept contribution instead of regressing it, and a group +/// the keys ever reported, not every node ever seen. Each route identity is its +/// own node: a restarted producer announces a new identity whose counters add to +/// the old one's kept contribution instead of regressing it, and a group /// track's new instance, under a new [`GroupFrame::epoch`], adds to the old -/// instance's the same way. A reader stays pinned to its epoch, so a -/// replacement never feeds it. When epoch metadata disappears or returns at a -/// path (one publisher reached through mixed-version links), only the -/// immediately outgoing contribution carries across; a different known epoch -/// keeps its own entry, even when an unversioned reader resolves it first. +/// instance's the same way. A reader stays pinned to its identity, so a +/// replacement never feeds it. A wire that carries no identity has its relay +/// mint one per announcement, so a node reached through one announces anew each +/// time it is announced again. /// /// A group's per-label sessions are the exception: they are a gauge, so a /// departed node stops counting them immediately. /// /// The totals, presence, and prefix counters never regress: a node's counter -/// going down is that node resetting (a restart on a route without an epoch, -/// or a presence or prefix track held again from zero), so its previous +/// going down is that node resetting (a presence or prefix track held again +/// from zero), so its previous /// readout is kept as a baseline the new one adds to. A prefix's children are /// the exception: they sum the live nodes' current children, so a child leaves /// the merged map with the last node carrying it while its tail stays in the /// rollup. A node that resumes after its departure closed its open pairs /// reopens them as new starts rather than lowering their ends. A group's /// per-path traffic also reopens pairs when a node resumes without regressing, -/// but keeps the reset contract of a single node: a route without an epoch -/// that returns within the grace with a lower counter regresses the merged -/// counter. +/// but keeps the reset contract of a single node: one that returns within the +/// grace with a lower counter regresses the merged counter. pub struct Consumer { origin: origin::Consumer, config: Config, @@ -625,7 +622,7 @@ impl Shape for Keyed { #[derive(Default)] struct Grouped { /// The track instance the last frame came from. - epoch: Option, + epoch: Option, totals: Keyed, paths: Keyed, labels: Keyed, @@ -721,10 +718,8 @@ struct Node { /// The announced path, relative to the announce cursor: what a re-resolve /// after the reader ends requests again. path: Path, - /// The publisher instance every initial and retry resolution must serve. - epoch: Option, - /// The last known publisher identity, retained while route metadata vanishes. - identity: Option, + /// The identity every initial and retry resolution must serve. + identity: Identity, shape: S, /// When the path unannounced, starting its grace; cleared by a reannounce. /// Only an unannounced node folds, so a still-announced node whose reader @@ -774,10 +769,10 @@ struct Merged { name: String, config: moq_json::snapshot::consumer::Config, /// One entry per live or recently departed node broadcast, keyed by - /// absolute announced path and route epoch, so each epoch is its own entry. - nodes: HashMap<(Path, Option), Node>, - /// The immediately outgoing identity, including while it remains in grace. - last_announced: HashMap>, + /// absolute announced path and route identity, so each identity is its own entry. + nodes: HashMap<(Path, Identity), Node>, + /// The identity last announced at each path, including while it remains in grace. + last_announced: HashMap, /// How long a departed node waits before folding into `retired`. grace: Duration, /// The summed last frames of every node whose grace elapsed. @@ -820,15 +815,16 @@ impl Merged { Poll::Ready(Some(moq_net::announce::Event::End(update))) => { changed |= self.apply_announce(update, false) } - // Another publisher instance: the one tracked at the path ends, then the - // new one starts, so its reader resolves afresh. + // Another identity: the one tracked at the path ends, then the new one + // starts, so its reader resolves afresh. Poll::Ready(Some(moq_net::announce::Event::Restart(update))) => { - let mut ended = update.clone(); // An announced prefix is a path beneath the root, so rooting it fits. let absolute = self.announce.absolute(&update.prefix).ok(); - ended.route.epoch = - absolute.and_then(|absolute| self.last_announced.get(&absolute).cloned().flatten()); - changed |= self.apply_announce(ended, false); + if let Some(previous) = absolute.and_then(|absolute| self.last_announced.get(&absolute).cloned()) { + let mut ended = update.clone(); + ended.route.identity = Some(previous); + changed |= self.apply_announce(ended, false); + } changed |= self.apply_announce(update, true); } Poll::Ready(None) => return Poll::Ready(Ok(None)), @@ -847,10 +843,10 @@ impl Merged { let grace = self.grace; let retired = &mut self.retired; let last_announced = &mut self.last_announced; - self.nodes.retain(|(path, epoch), node| { + self.nodes.retain(|(path, identity), node| { changed |= advance(node, origin, config, name, waiter); if node.fold_expired(retired, grace, now) { - if last_announced.get(path) == Some(epoch) { + if last_announced.get(path) == Some(identity) { last_announced.remove(path); } false @@ -889,7 +885,10 @@ impl Merged { } // Publisher replacement can race reader polling on another worker, so every // reader pins the identity it contributes to, independent of announce order. - let key = (absolute, update.route.epoch); + let key = ( + absolute, + update.route.identity.expect("a delivered route has an identity"), + ); if !active { // Unannounce: keep the cumulative counters, close the open pairs, @@ -904,29 +903,7 @@ impl Merged { }; } - if let Some(previous) = self.last_announced.insert(key.0.clone(), key.1.clone()) { - // An unversioned route cannot identify a restart. Carry only the - // immediately outgoing contribution across loss or recovery of metadata; - // distinct known epochs remain separate, even while several are in grace. - if previous != key.1 - && (previous.is_none() || key.1.is_none()) - && let Some(mut node) = self.nodes.remove(&(key.0.clone(), previous)) - { - node.depart(); - if key.1.is_some() && node.identity.is_some() && node.identity != key.1 { - // Recovery identifies a distinct known publisher. Keep the outgoing - // lineage in grace and re-arm the incoming identity's own entry. - node.epoch = node.identity.clone(); - self.nodes.insert((key.0.clone(), node.identity.clone()), node); - } else { - node.epoch = key.1.clone(); - if key.1.is_some() { - node.identity = key.1.clone(); - } - self.nodes.insert(key.clone(), node); - } - } - } + self.last_announced.insert(key.0.clone(), key.1.clone()); // A route update on a node already tracked (a reprice, or a takeover // with different metadata) keeps the live reader: existing // subscriptions survive a takeover, and the reader re-resolves through @@ -942,17 +919,16 @@ impl Merged { node.fold_expired(&mut self.retired, self.grace, moq_clock::now()); node.departed = None; if matches!(node.reader, Reader::Ended) { - node.reader = resolve(&self.origin, &node.path, node.epoch.clone()); + node.reader = resolve(&self.origin, &node.path, node.identity.clone()); } } Entry::Vacant(entry) => { - let epoch = entry.key().1.clone(); + let identity = entry.key().1.clone(); entry.insert(Node { - reader: resolve(&self.origin, &path, epoch.clone()), + reader: resolve(&self.origin, &path, identity.clone()), listing: None, offered: false, - identity: epoch.clone(), - epoch, + identity, path, shape: S::default(), departed: None, @@ -1013,21 +989,6 @@ fn advance( match &mut node.reader { Reader::Resolving(pending) => match pending.poll_ok(waiter) { - // An unpinned retry on a known lineage can reach a replacement publisher - // before its announcement drains here. Its counters belong to its own entry, - // which that announcement creates, so this lineage keeps its last frame. - Poll::Ready(Ok(broadcast)) - if node.epoch.is_none() - && node.identity.is_some() - && broadcast - .info() - .epoch - .as_ref() - .is_some_and(|epoch| node.identity.as_ref() != Some(epoch)) => - { - tracing::debug!(name, "stats: node resolved another publisher"); - return changed | node.depart(); - } Poll::Ready(Ok(broadcast)) if S::LISTED => match broadcast.tracks() { Ok(listing) => { node.listing = Some(listing); @@ -1061,7 +1022,7 @@ fn advance( return changed; } rearmed = true; - node.reader = resolve(origin, &node.path, node.epoch.clone()); + node.reader = resolve(origin, &node.path, node.identity.clone()); continue; }; if !node.offered { @@ -1124,7 +1085,7 @@ fn advance( return changed; } rearmed = true; - node.reader = resolve(origin, &node.path, node.epoch.clone()); + node.reader = resolve(origin, &node.path, node.identity.clone()); } Poll::Pending => return changed, }, @@ -1133,9 +1094,9 @@ fn advance( } } -/// Start resolving `path` (relative to the announce cursor) into a broadcast. -fn resolve(origin: &origin::Consumer, path: &Path, epoch: Option) -> Reader { - let pending = origin.request(path, epoch); +/// Start resolving `path` (relative to the announce cursor) into a broadcast of `identity`. +fn resolve(origin: &origin::Consumer, path: &Path, identity: Identity) -> Reader { + let pending = origin.request(path, identity); Reader::Resolving(Box::new(pending)) } @@ -1251,7 +1212,7 @@ mod tests { /// The group epoch every hand-published group frame carries, so a new /// [`NodeBroadcast`] at the same path is the same track instance returning. - fn group_epoch() -> Epoch { + fn group_epoch() -> Identity { "01900000-0000-7000-8000-0000000000ff".parse().unwrap() } @@ -1351,7 +1312,8 @@ mod tests { /// group track carrying default-tier egress per path, so a test controls /// the exact frames and can fail one node's reader alone (the /// registry-driven producer only publishes whole broadcasts). Its route - /// carries no epoch, so a new one at the same path is that node returning. + /// carries one identity per node name, so a new one at the same path is that + /// node returning. /// Dropping it unannounces the node. #[allow(dead_code, reason = "held for their drop, never read")] struct NodeBroadcast { @@ -1365,7 +1327,10 @@ mod tests { fn new(origin: &origin::Producer, group: &str, node: &str) -> Self { let path = format!(".stats/node/{node}"); let source = origin.create_broadcast(path.as_str()).expect("create broadcast"); - source.announce(origin::Route::default()).expect("announce"); + let identity = format!("node-{node}").parse().unwrap(); + source + .announce(origin::Route::default().with_identity(identity, 0)) + .expect("announce"); let track = source .create_track(group_track(moq_net::Path::new(group).unwrap()), timed()) .expect("create track"); @@ -1557,238 +1522,6 @@ mod tests { } } - #[test] - fn outgoing_epoch_metadata_migrates_without_choosing_a_grace_entry() { - let origin = produce_origin(); - let path = Path::from_static(".stats/node/a"); - let a: Epoch = "01900000-0000-7000-8000-000000000001".parse().unwrap(); - let b: Epoch = "01900000-0000-7000-8000-000000000002".parse().unwrap(); - let c: Epoch = "01900000-0000-7000-8000-000000000003".parse().unwrap(); - let d: Epoch = "01900000-0000-7000-8000-000000000004".parse().unwrap(); - let mut merged = Merged::>::new(origin.consume(), &Config::new(), "traffic".into()); - let update = |epoch: Option| moq_net::announce::Announce { - prefix: path.clone(), - captures: None, - route: { - let mut route = origin::Route::default(); - route.epoch = epoch; - route - }, - }; - for (epoch, bytes) in [(a.clone(), 100), (b.clone(), 30), (c.clone(), 7)] { - merged.apply_announce(update(Some(epoch.clone())), true); - merged - .nodes - .get_mut(&(path.clone(), Some(epoch.clone()))) - .unwrap() - .shape - .last = Some(BTreeMap::from([("room".into(), { - let mut traffic = Traffic::default(); - traffic.bytes = bytes; - traffic - })])); - merged.apply_announce(update(Some(epoch)), false); - } - assert_eq!(merged.merged()["room"].bytes, 137); - merged.apply_announce(update(None), true); - assert_eq!( - merged.nodes.len(), - 3, - "only the immediately outgoing C becomes unversioned" - ); - assert!(merged.nodes.contains_key(&(path.clone(), Some(a.clone())))); - assert!(merged.nodes.contains_key(&(path.clone(), Some(b.clone())))); - assert!(!merged.nodes.contains_key(&(path.clone(), Some(c.clone())))); - assert_eq!( - merged.merged()["room"].bytes, - 137, - "metadata loss preserves the last snapshot" - ); - - // A fresh unversioned snapshot replaces only the outgoing contribution. - merged.nodes.get_mut(&(path.clone(), None)).unwrap().shape.last = Some(BTreeMap::from([("room".into(), { - let mut traffic = Traffic::default(); - traffic.bytes = 8; - traffic - })])); - merged.apply_announce(update(None), false); - merged.apply_announce(update(Some(a.clone())), true); - assert_eq!( - merged.nodes.len(), - 3, - "recovering A retains both outgoing C and grace B" - ); - assert!(merged.nodes.contains_key(&(path.clone(), Some(c)))); - assert_eq!( - merged.nodes[&(path.clone(), Some(a.clone()))] - .shape - .last - .as_ref() - .unwrap()["room"] - .bytes, - 100 - ); - assert_eq!( - merged.merged()["room"].bytes, - 138, - "retained A is not overwritten by outgoing C" - ); - - merged.apply_announce(update(Some(a.clone())), false); - merged.apply_announce(update(None), true); - merged.apply_announce(update(None), false); - merged.apply_announce(update(Some(d.clone())), true); - assert_eq!( - merged.nodes.len(), - 4, - "a distinct recovered epoch gets its own contribution" - ); - assert!(merged.nodes.contains_key(&(path.clone(), Some(a)))); - assert!(merged.nodes.contains_key(&(path.clone(), Some(d)))); - assert!(!merged.nodes.contains_key(&(path, None))); - assert_eq!( - merged.merged()["room"].bytes, - 138, - "known grace entries remain counted once each" - ); - } - - /// A known replacement must not overwrite the outgoing known epoch's counters. - #[test] - fn replacement_between_announce_drain_and_reader_retry() { - replacement_while_reader_retries(false); - } - - /// Missing metadata must not let the outgoing lineage consume a recovered known epoch. - #[test] - fn unknown_replacement_between_announce_drain_and_reader_retry() { - replacement_while_reader_retries(true); - } - - /// Arrange replacement on another worker after announcements drain and before retrying. - fn replacement_while_reader_retries(drop_epoch: bool) { - use std::sync::Barrier; - - let now = moq_clock::now(); - // The clock never advances here, so nothing may wait out the update hold. - let mut config = origin::Config::default(); - config.update_hold = std::time::Duration::ZERO; - let (origin, mut driver) = origin::Producer::new(config); - let epoch_a: Epoch = "01900000-0000-7000-8000-000000000001".parse().unwrap(); - let epoch_b: Epoch = "01900000-0000-7000-8000-000000000002".parse().unwrap(); - let mut old = NodeBroadcast::new(&origin, "acme", "a"); - old.source - .announce(origin::Route::default().with_epoch(epoch_a.clone())) - .unwrap(); - old.publish("acme/room", 100); - let name = group_track(moq_net::Path::from_static("acme")); - let mut merged = Merged::::new(origin.consume(), &Config::new(), name.clone()); - let initial = futures::executor::block_on(kio::wait(|waiter| { - driver.poll(now, waiter).unwrap(); - merged.poll_next(waiter) - })) - .unwrap() - .unwrap(); - assert_eq!(initial[""].paths["acme/room"].publisher.bytes, 100); - if drop_epoch { - old.source.announce(origin::Route::default()).unwrap(); - let unversioned = futures::executor::block_on(kio::wait(|waiter| { - driver.poll(now, waiter).unwrap(); - merged.poll_next(waiter) - })) - .unwrap() - .unwrap(); - assert_eq!(unversioned[""].paths["acme/room"].publisher.bytes, 100); - assert!(matches!( - merged.nodes[&(Path::from_static(".stats/node/a"), None)].reader, - Reader::Active(_) - )); - } - assert!(merged.announce.poll_next(&Waiter::noop()).is_pending()); - - let ready = Barrier::new(2); - let release = Barrier::new(2); - std::thread::scope(|scope| { - let worker = scope.spawn(|| { - let mut new = NodeBroadcast::new(&origin, "acme", "a"); - new.source - .announce(origin::Route::default().with_epoch(epoch_b.clone())) - .unwrap(); - new.publish("acme/room", 30); - let mut pending = origin.consume().request(".stats/node/a", None); - let front = futures::executor::block_on(kio::wait(|waiter| { - driver.poll(now, waiter).unwrap(); - pending.poll_ok(waiter) - })) - .unwrap(); - let pending = front.track(&name).unwrap().subscribe(None); - let subscriber = futures::executor::block_on(kio::wait(|waiter| { - driver.poll(now, waiter).unwrap(); - pending.poll_ok(waiter) - })) - .unwrap(); - let mut warm = moq_json::snapshot::Consumer::::new(subscriber, Default::default()); - let frame = futures::executor::block_on(kio::wait(|waiter| { - driver.poll(now, waiter).unwrap(); - warm.poll_latest(waiter) - })) - .unwrap() - .unwrap(); - assert_eq!(frame.value.tiers[""].paths["acme/room"].publisher.bytes, 30); - ready.wait(); - release.wait(); - (new, front, warm, driver) - }); - ready.wait(); - let key = (Path::from_static(".stats/node/a"), (!drop_epoch).then_some(epoch_a)); - let node = merged.nodes.get_mut(&key).unwrap(); - advance(node, &merged.origin, &merged.config, &name, &Waiter::noop()); - let paths = node.shape.paths.last.as_ref().unwrap(); - let old_bytes = paths[&(String::new(), "acme/room".to_string())].publisher.bytes; - // Release before asserting, so a failed regression also joins its worker. - release.wait(); - let (_new, _front, _warm, mut driver) = worker.join().unwrap(); - let combined = futures::executor::block_on(kio::wait(|waiter| { - driver.poll(now, waiter).unwrap(); - merged.poll_next(waiter) - })) - .unwrap() - .unwrap(); - assert_eq!(combined[""].paths["acme/room"].publisher.bytes, 130); - assert_eq!(old_bytes, 100, "the replacement frame overwrote the old epoch"); - }); - } - - #[tokio::test(start_paused = true)] - async fn unversioned_restart_regresses_the_total() { - // A node whose route carries no epoch (an older producer, or a session - // older than moq-lite 07) returns at the same path with a fresh counter: - // it replaces its contribution wholesale, and the total regresses once, - // the fresh-segment contract. - let origin = produce_origin(); - let mut node_a = NodeBroadcast::new(&origin, "acme", "a"); - let mut node_b = NodeBroadcast::new(&origin, "acme", "b"); - node_a.publish("acme/room", 100); - node_b.publish("acme/room", 40); - - let agg = Consumer::new(origin.consume(), Config::new()); - let mut traffic = traffic(&agg); - read_until_bytes(&mut traffic, "acme/room", 140).await; - - drop(node_a); - settle(&mut traffic).await; - let mut node_a = NodeBroadcast::new(&origin, "acme", "a"); - node_a.publish("acme/room", 30); - - // An earlier frame may retire A's live gauges first. - loop { - let frame = traffic.next().await.expect("read").expect("frame"); - if frame.get("acme/room").map(|t| t.bytes) == Some(70) { - break; - } - } - } - #[tokio::test(start_paused = true)] async fn reader_failure_keeps_the_traffic_total() { // A node's reader failing while its broadcast is still announced keeps @@ -2232,9 +1965,9 @@ mod tests { } #[tokio::test(start_paused = true)] - async fn totals_never_regress_on_an_unversioned_restart() { - // A node returning without an epoch with a lower counter reset: its old - // readout becomes a baseline, so the merged totals keep growing. + async fn totals_never_regress_on_a_restart() { + // A node returning under a new identity with a lower counter: its old + // readout stays as its own contribution, so the merged totals keep growing. let origin = produce_origin(); let mut node_a = TotalsNode::new(&origin, "a"); let mut node_b = TotalsNode::new(&origin, "b"); @@ -2256,21 +1989,21 @@ mod tests { read_until(&mut totals.inner, grew, |frame| frame[""].publisher.bytes == 175).await; } - /// A node announced at `.stats/node/a` with no epoch, ready to observe - /// frames by hand. - fn hand_merged() -> (Merged>, (Path, Option)) { + /// A node announced at `.stats/node/a`, ready to observe frames by hand. + fn hand_merged() -> (Merged>, (Path, Identity)) { let origin = produce_origin(); let path = Path::from_static(".stats/node/a"); + let identity: Identity = "a".parse().unwrap(); let mut merged = Merged::>::new(origin.consume(), &Config::new(), "p".into()); merged.apply_announce( moq_net::announce::Announce { prefix: path.clone(), captures: None, - route: origin::Route::default(), + route: origin::Route::default().with_identity(identity.clone(), 0), }, true, ); - (merged, (path, None)) + (merged, (path, identity)) } #[test] @@ -2287,11 +2020,11 @@ mod tests { moq_net::announce::Announce { prefix: path.clone(), captures: None, - route: origin::Route::default(), + route: origin::Route::default().with_identity("b".parse().unwrap(), 0), }, true, ); - let second = (path, None); + let second = (path, "b".parse().unwrap()); merged .nodes .get_mut(&second) @@ -2449,9 +2182,8 @@ mod tests { async fn traffic_resume_reopens_closed_pairs_without_going_down() { let origin = produce_origin(); let mut node = NodeBroadcast::new(&origin, "acme", "a"); - let mut route = origin::Route::default(); - route.epoch = Some("01900000-0000-7000-8000-000000000001".parse().unwrap()); - node.source.announce(route.clone()).expect("announce epoch"); + let route = origin::Route::default().with_identity("01900000-0000-7000-8000-000000000001".parse().unwrap(), 0); + node.source.announce(route.clone()).expect("announce identity"); let mut raw = Traffic::default(); raw.announces_started = 5; raw.announces_ended = 3; diff --git a/rs/moq-stats/src/lib.rs b/rs/moq-stats/src/lib.rs index 595814811d3..a35590d01ba 100644 --- a/rs/moq-stats/src/lib.rs +++ b/rs/moq-stats/src/lib.rs @@ -18,10 +18,10 @@ //! //! A [`Producer`] publishes one broadcast per node at `/node/` //! (default prefix `.stats`); parse announce paths back with -//! [`parse_node_path`]. Each announcement carries a fresh [`Epoch`] on its +//! [`parse_node_path`]. Each announcement carries a fresh [`Identity`] on its //! route, so a restarted node is a new broadcast at the same path; a reader -//! tells them apart by the announced route's epoch. The broadcast carries a -//! `totals.json` track of each [`Tier`]'s [`Totals`] for the epoch, and a +//! tells them apart by the announced route's identity. The broadcast carries a +//! `totals.json` track of each [`Tier`]'s [`Totals`] for the identity, and a //! reader may request any stats label's `