diff --git a/demo/web/src/index.ts b/demo/web/src/index.ts index 76ab77652b..c70b95d171 100644 --- a/demo/web/src/index.ts +++ b/demo/web/src/index.ts @@ -190,7 +190,7 @@ discovery.run((effect) => { for (;;) { const entry = await Promise.race([effect.cancel, announced.next()]); if (!entry) break; - const path = entry.path; + const path = entry.prefix; // Only catalog-backed broadcasts are watchable streams; this skips the relay's // `.stats` broadcast (see the stats dashboard demo for that one). if (!path.endsWith(".hang") && !path.endsWith(".msf")) continue; diff --git a/demo/web/src/stats.ts b/demo/web/src/stats.ts index 08867ecc9b..a66d621617 100644 --- a/demo/web/src/stats.ts +++ b/demo/web/src/stats.ts @@ -131,7 +131,7 @@ discovery.run((effect) => { for (;;) { const entry = await Promise.race([effect.cancel, announced.next()]); if (!entry) break; - const path = entry.path; + const path = entry.prefix; const node = Net.Path.stripPrefix(prefix, path); if (!node) continue; diff --git a/doc/lib/rs/index.md b/doc/lib/rs/index.md index 4479517b87..14d04377d3 100644 --- a/doc/lib/rs/index.md +++ b/doc/lib/rs/index.md @@ -52,7 +52,7 @@ let consumer = origin.consume(); let mut announced = consumer.announced(); while let Some(update) = announced.next().await { if !update.kind.is_active() { continue } - let broadcast = consumer.request_broadcast(&update.path).await?; + let broadcast = consumer.request_broadcast(&update.prefix).await?; let catalog = broadcast .track(hang::Catalog::DEFAULT_NAME)? .subscribe(hang::Catalog::default_subscription()) diff --git a/js/hang/src/catalog/data.test.ts b/js/hang/src/catalog/data.test.ts index 6089bca9cd..dc2003997c 100644 --- a/js/hang/src/catalog/data.test.ts +++ b/js/hang/src/catalog/data.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import { compressionSupported } from "./compression.ts"; import { modeSupported } from "./mode.ts"; +import type { RelativeBroadcast } from "./path.ts"; import { RootSchema } from "./root.ts"; // The `json` and `binary` sections list application data tracks. Each entry says how to read the @@ -28,8 +29,8 @@ test("data tracks parse with their mode and compression", () => { expect(parsed.json?.tracks.chat?.schema).toBe("https://example.com/chat.schema.json"); expect(parsed.json?.tracks.status?.mode).toBe("snapshot"); - // Normalized like Rust PathRelative, the same as a media rendition's reference. - expect(parsed.json?.tracks.status?.broadcast).toBe("source"); + // Normalized like Rust path::Relative, the same as a media rendition's reference. + expect(parsed.json?.tracks.status?.broadcast).toBe("source" as RelativeBroadcast); expect(parsed.json?.tracks.status?.compression).toBeUndefined(); expect(parsed.binary?.tracks.thumbnail?.mode).toBe("snapshot"); diff --git a/js/hang/src/catalog/path.ts b/js/hang/src/catalog/path.ts index edb59f216c..05c6fc7d71 100644 --- a/js/hang/src/catalog/path.ts +++ b/js/hang/src/catalog/path.ts @@ -4,11 +4,17 @@ import * as z from "@zod/mini"; /** * Zod schema for a relative broadcast reference stored in a catalog (a rendition's * `broadcast` field, e.g. "./source"). Normalizes the input the same way the Rust - * `PathRelative` type does so JS and Rust agree byte-for-byte after deserialization. + * `path::Relative` type does so JS and Rust agree byte-for-byte after deserialization. * Resolve it against the catalog broadcast's own path with `Path.tryResolve`, which returns * `undefined` for a reference that walks above the root: hang requires rejecting such a catalog. */ -export const RelativeBroadcastSchema = z.pipe(z.string(), z.transform(Path.normalizeRelative)); +export const RelativeBroadcastSchema: z.ZodMiniType = z.pipe( + z.string(), + z.transform(Path.normalizeRelative), +); -/** A normalized relative broadcast reference. */ -export type RelativeBroadcast = z.infer; +/** + * A normalized relative broadcast reference: the same brand as `Path.Relative`, spelled + * out here so the catalog schemas' inferred types stay nameable from this package. + */ +export type RelativeBroadcast = string & { __brand: "Relative" }; diff --git a/js/hang/src/catalog/root.test.ts b/js/hang/src/catalog/root.test.ts index ea3ca4b6a6..a62dbe1181 100644 --- a/js/hang/src/catalog/root.test.ts +++ b/js/hang/src/catalog/root.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import * as z from "@zod/mini"; import { ARCHIVE_VERSION } from "./archive.ts"; +import type { RelativeBroadcast } from "./path.ts"; import { RootSchema } from "./root.ts"; // The base catalog carries the media sections (`video`/`audio`) and the data track sections @@ -38,8 +39,8 @@ test("rendition broadcast reference is parsed and normalized", () => { }; const parsed = RootSchema.parse(catalog); if (!parsed.video || !("renditions" in parsed.video)) throw new Error("missing video section"); - // Normalized like Rust PathRelative: redundant `.` and empty segments are dropped. - expect(parsed.video.renditions.video?.broadcast).toBe("source"); + // Normalized like Rust path::Relative: redundant `.` and empty segments are dropped. + expect(parsed.video.renditions.video?.broadcast).toBe("source" as RelativeBroadcast); }); test("rendition parent broadcast reference stays distinct from empty", () => { @@ -56,7 +57,7 @@ test("rendition parent broadcast reference stays distinct from empty", () => { }; const parsed = RootSchema.parse(catalog); if (!parsed.video || !("renditions" in parsed.video)) throw new Error("missing video section"); - expect(parsed.video.renditions.video?.broadcast).toBe("."); + expect(parsed.video.renditions.video?.broadcast).toBe("." as RelativeBroadcast); }); test("rendition without broadcast reference stays undefined", () => { diff --git a/js/net/examples/discovery.ts b/js/net/examples/discovery.ts index b532a26196..5001a58326 100644 --- a/js/net/examples/discovery.ts +++ b/js/net/examples/discovery.ts @@ -11,10 +11,10 @@ async function main() { // Discover broadcasts announced by the server for await (const announcement of announced) { if (announcement.kind === "retracted") continue; - console.log("New stream available:", announcement.path); + console.log("New stream available:", announcement.prefix); // Subscribe to new streams - const _broadcast = origin.request(announcement.path, { announced: true }); + const _broadcast = origin.request(announcement.prefix, { announced: true }); // Do something with the broadcast } diff --git a/js/net/src/announced.test.ts b/js/net/src/announced.test.ts index 2844152586..68adf0d212 100644 --- a/js/net/src/announced.test.ts +++ b/js/net/src/announced.test.ts @@ -19,11 +19,11 @@ test("next streams every appended event in order", async () => { const consumer = producer.consume(); const route = Route.default; - producer.append({ path: p("a"), captures: undefined, kind: "announced", route }); - producer.append({ path: p("a"), captures: undefined, kind: "retracted", route }); + producer.append({ prefix: p("a"), captures: undefined, kind: "announced", route }); + producer.append({ prefix: p("a"), captures: undefined, kind: "retracted", route }); - expect(await consumer.next()).toEqual({ path: p("a"), captures: undefined, kind: "announced", route }); - expect(await consumer.next()).toEqual({ path: p("a"), captures: undefined, kind: "retracted", route }); + expect(await consumer.next()).toEqual({ prefix: p("a"), captures: undefined, kind: "announced", route }); + expect(await consumer.next()).toEqual({ prefix: p("a"), captures: undefined, kind: "retracted", route }); }); test("the consumer is an async iterable of the same events", async () => { @@ -31,8 +31,8 @@ test("the consumer is an async iterable of the same events", async () => { const consumer = producer.consume(); const route = Route.default; - producer.append({ path: p("a"), captures: undefined, kind: "announced", route }); - producer.append({ path: p("a"), captures: undefined, kind: "retracted", route }); + producer.append({ prefix: p("a"), captures: undefined, kind: "announced", route }); + producer.append({ prefix: p("a"), captures: undefined, kind: "retracted", route }); const events = consumer[Symbol.asyncIterator](); expect((await events.next()).value?.kind).toBe("announced"); @@ -50,11 +50,11 @@ test("a same-name re-announce is a distinct update", async () => { // than collapsing it. Deciding what a repeat means belongs to the session layer, which resolves // a restart into either nothing (a route change) or an end + start (a new publisher). const route = Route.default; - producer.append({ path: p("a"), captures: undefined, kind: "announced", route }); - producer.append({ path: p("a"), captures: undefined, kind: "announced", route }); + producer.append({ prefix: p("a"), captures: undefined, kind: "announced", route }); + producer.append({ prefix: p("a"), captures: undefined, kind: "announced", route }); - expect(await consumer.next()).toEqual({ path: p("a"), captures: undefined, kind: "announced", route }); - expect(await consumer.next()).toEqual({ path: p("a"), captures: undefined, kind: "announced", route }); + expect(await consumer.next()).toEqual({ prefix: p("a"), captures: undefined, kind: "announced", route }); + expect(await consumer.next()).toEqual({ prefix: p("a"), captures: undefined, kind: "announced", route }); }); test("closing resolves next with undefined", async () => { diff --git a/js/net/src/announced.ts b/js/net/src/announced.ts index a735645342..8c0f25527f 100644 --- a/js/net/src/announced.ts +++ b/js/net/src/announced.ts @@ -8,7 +8,7 @@ import type { Route } from "./hop.js"; import type * as Path from "./path.js"; /** - * What an {@link Update} reports about its path. + * What an {@link Update} reports about its prefix. * * @public */ @@ -17,10 +17,11 @@ export type Kind = "announced" | "updated" | "retracted"; /** * A route announcement, update, or retraction. * - * A route claims that {@link path} and every path beneath it can be served; it - * carries no broadcast. By convention a publisher announces each broadcast's exact - * path, so enumerating routes enumerates broadcasts; resolve one with the origin's - * `request(path)`. Narrow with a {@link Path.Pattern} locally to follow a subset. + * An announcement is always a prefix, never a broadcast: a route claims that + * {@link prefix} and every path beneath it can be served. By convention a publisher + * announces each broadcast's exact path, so enumerating routes enumerates broadcasts; + * resolve one with the origin's `request(path)`. Narrow with a {@link Path.Pattern} + * locally to follow a subset. * * @public */ @@ -28,10 +29,10 @@ export interface Update { /** * The prefix the route covers, relative to the origin (for a session, its URL path). */ - path: Path.Valid; + prefix: Path.Valid; /** What the filter's wildcards stood for, when this prefix pins all of them. */ captures: Path.Pattern[] | undefined; - /** Whether the path was announced, re-priced, or retracted. */ + /** Whether the prefix was announced, re-priced, or retracted. */ kind: Kind; /** Hops and cost of the route; on a retraction, its last advertised values. */ route: Route; diff --git a/js/net/src/connection/forward.test.ts b/js/net/src/connection/forward.test.ts index 96dda996b9..d9d9b74481 100644 --- a/js/net/src/connection/forward.test.ts +++ b/js/net/src/connection/forward.test.ts @@ -75,7 +75,7 @@ test("a discovery failure under a live session downgrades the origin", async () forwardAnnounced(session.session, origin); // The relay announces a broadcast, which lands in the table. - session.announces.append({ path, captures: undefined, kind: "announced", route: Route.default }); + session.announces.append({ prefix: path, captures: undefined, kind: "announced", route: Route.default }); await settle(); expect(origin.discovery.peek()).toBe(true); expect(wireOf(origin).routes(path)).toBe(true); @@ -116,7 +116,7 @@ test("a request outlives the discovery failure that fed it", async () => { forwardAnnounced(session.session, origin); // Announced, so the table routes it and no blind answer is needed. - session.announces.append({ path, captures: undefined, kind: "announced", route: Route.default }); + session.announces.append({ prefix: path, captures: undefined, kind: "announced", route: Route.default }); await settle(); const request = origin.request(path); diff --git a/js/net/src/connection/forward.ts b/js/net/src/connection/forward.ts index 4a2004a87a..ff977a61c1 100644 --- a/js/net/src/connection/forward.ts +++ b/js/net/src/connection/forward.ts @@ -62,17 +62,17 @@ export function forwardAnnounced(conn: Established, origin: OriginProducer): voi if (!event) break; if (isActive(event.kind)) { - const existing = inserted.get(event.path); + const existing = inserted.get(event.prefix); if (existing) { existing.update(event.route); } else { - const handle = originWire.receive(event.path, event.route); - inserted.set(event.path, handle); + const handle = originWire.receive(event.prefix, event.route); + inserted.set(event.prefix, handle); void drive(handle, conn); } } else { - const handle = inserted.get(event.path); - inserted.delete(event.path); + const handle = inserted.get(event.prefix); + inserted.delete(event.prefix); handle?.close(); } } diff --git a/js/net/src/connection/pool.ts b/js/net/src/connection/pool.ts index 7b5473138e..68339cab00 100644 --- a/js/net/src/connection/pool.ts +++ b/js/net/src/connection/pool.ts @@ -296,8 +296,8 @@ export class Connection { for (;;) { const entry = await Promise.race([effect.cancel, upstream.next()]); if (!entry) break; - if (Announce.isActive(entry.kind)) active.set(entry.path, entry); - else active.delete(entry.path); + if (Announce.isActive(entry.kind)) active.set(entry.prefix, entry); + else active.delete(entry.prefix); producer.append(entry); } } finally { diff --git a/js/net/src/connection/reload.test.ts b/js/net/src/connection/reload.test.ts index 946648cca3..4a1ab6f938 100644 --- a/js/net/src/connection/reload.test.ts +++ b/js/net/src/connection/reload.test.ts @@ -568,7 +568,7 @@ test("closing an announce consumer during upstream teardown does not append retr const errors = spyOn(console, "error").mockImplementation(() => {}); try { upstream.append({ - path: Path.from("alice/camera.hang"), + prefix: Path.from("alice/camera.hang"), captures: undefined, kind: "announced", route: Route.default, diff --git a/js/net/src/connection/reload.ts b/js/net/src/connection/reload.ts index e71aedb66d..c34ec5ca4d 100644 --- a/js/net/src/connection/reload.ts +++ b/js/net/src/connection/reload.ts @@ -472,8 +472,8 @@ export class Reload { for (;;) { const entry = await Promise.race([effect.cancel, upstream.next()]); if (!entry) break; - if (Announce.isActive(entry.kind)) active.set(entry.path, entry); - else active.delete(entry.path); + if (Announce.isActive(entry.kind)) active.set(entry.prefix, entry); + else active.delete(entry.prefix); producer.append(entry); } } catch { diff --git a/js/net/src/ietf/subscriber.test.ts b/js/net/src/ietf/subscriber.test.ts index adb819178d..6e40f92103 100644 --- a/js/net/src/ietf/subscriber.test.ts +++ b/js/net/src/ietf/subscriber.test.ts @@ -86,7 +86,7 @@ test("an unsolicited announcement lands", async () => { ); const next = await announced.next(); - expect(next?.path).toBe(Path.from("surprise")); + expect(next?.prefix).toBe(Path.from("surprise")); expect(next?.kind).toBe("announced"); // The handler holds the request open until the peer drops it, and withdraws the @@ -96,7 +96,7 @@ test("an unsolicited announcement lands", async () => { peer.close(); await handler; expect(await announced.next()).toMatchObject({ - path: Path.from("surprise"), + prefix: Path.from("surprise"), kind: "retracted", }); }); @@ -132,7 +132,7 @@ async function inlineNamespace(stream: Stream, path: Path.Valid, cluster?: Clust async function syncInline(stream: Stream, announced: announce.Consumer, cluster?: Cluster.Advert): Promise { await inlineNamespace(stream, Path.from("sentinel"), cluster); expect(await announced.next()).toMatchObject({ - path: Path.from("sentinel"), + prefix: Path.from("sentinel"), kind: "announced", }); } @@ -159,7 +159,7 @@ test("an announcement survives the first of its two sources ending", async () => new PublishNamespace({ requestId: 0n, trackNamespace: Path.from("both") }), request, ); - expect(await announced.next()).toMatchObject({ path: Path.from("both"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("both"), kind: "announced" }); await inlineNamespace(subscription, Path.from("both")); await syncInline(subscription, announced); @@ -195,7 +195,7 @@ test("an announcement ends once its last source does", async () => { new PublishNamespace({ requestId: 0n, trackNamespace: Path.from("both") }), request, ); - expect(await announced.next()).toMatchObject({ path: Path.from("both"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("both"), kind: "announced" }); await inlineNamespace(subscription, Path.from("both")); await syncInline(subscription, announced); @@ -209,7 +209,7 @@ test("an announcement ends once its last source does", async () => { await subscription.writer.u53(SubscribeNamespaceEntryDone.id); await new SubscribeNamespaceEntryDone({ suffix: Path.from("both") }).encode(subscription.writer, VERSION); - expect(await announced.next()).toMatchObject({ path: Path.from("both"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("both"), kind: "retracted" }); }); /** @@ -230,13 +230,13 @@ test("a subscription that dies releases what it advertised", async () => { await acceptSubscribeNamespace(pair.client); await inlineNamespace(streamA, Path.from("orphan")); - expect(await doomed.next()).toMatchObject({ path: Path.from("orphan"), kind: "announced" }); - expect(await survivor.next()).toMatchObject({ path: Path.from("orphan"), kind: "announced" }); + expect(await doomed.next()).toMatchObject({ prefix: Path.from("orphan"), kind: "announced" }); + expect(await survivor.next()).toMatchObject({ prefix: Path.from("orphan"), kind: "announced" }); // The stream that advertised it goes away without a NAMESPACE_DONE. streamA.writer.close(); - expect(await survivor.next()).toMatchObject({ path: Path.from("orphan"), kind: "retracted" }); + expect(await survivor.next()).toMatchObject({ prefix: Path.from("orphan"), kind: "retracted" }); }); /** @@ -258,7 +258,7 @@ test("a duplicate legacy publish_namespace is still refused", async () => { new PublishNamespace({ requestId: 0n, trackNamespace: Path.from("twice") }), first, ); - expect(await announced.next()).toMatchObject({ path: Path.from("twice"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("twice"), kind: "announced" }); // The same namespace again, on its own request. const second = await Stream.open(pair.server, { version: Version.DRAFT_15 }); @@ -273,7 +273,7 @@ test("a duplicate legacy publish_namespace is still refused", async () => { peer.close(); await handler; - expect(await announced.next()).toMatchObject({ path: Path.from("twice"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("twice"), kind: "retracted" }); }); /** @@ -332,7 +332,7 @@ test("concurrent legacy publish_namespace requests take one reference", async () second, ); - expect(await announced.next()).toMatchObject({ path: Path.from("raced"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("raced"), kind: "announced" }); await two; // Only one reference was taken, so the surviving request ending retracts the path. @@ -341,7 +341,7 @@ test("concurrent legacy publish_namespace requests take one reference", async () peer.close(); await one; - expect(await announced.next()).toMatchObject({ path: Path.from("raced"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("raced"), kind: "retracted" }); }); /** The Hop IDs a cluster-negotiated session declared, ours first. */ @@ -380,10 +380,10 @@ test("an inline NAMESPACE that starts looping back is retracted", async () => { const subscription = await acceptSubscribeNamespace(pair.client); await inlineNamespace(subscription, Path.from("theirs"), { hops: [PEER], cost: 0n }); - expect(await announced.next()).toMatchObject({ path: Path.from("theirs"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("theirs"), kind: "announced" }); await inlineNamespace(subscription, Path.from("theirs"), { hops: [SELF, PEER], cost: 0n }); - expect(await announced.next()).toMatchObject({ path: Path.from("theirs"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("theirs"), kind: "retracted" }); }); /** @@ -401,14 +401,14 @@ test("a repeated NAMESPACE reprices in place", async () => { await inlineNamespace(subscription, Path.from("theirs"), { hops: [PEER], cost: 4n }); expect(await announced.next()).toMatchObject({ - path: Path.from("theirs"), + prefix: Path.from("theirs"), kind: "announced", route: { hops: [PEER], cost: { warm: 4n, cold: 4n } }, }); await inlineNamespace(subscription, Path.from("theirs"), { hops: [PEER], cost: 0n }); expect(await announced.next()).toMatchObject({ - path: Path.from("theirs"), + prefix: Path.from("theirs"), kind: "updated", route: { hops: [PEER], cost: { warm: 0n, cold: 0n } }, }); @@ -533,7 +533,7 @@ test("a PUBLISH_NAMESPACE update that starts looping back is detached", async () }), request, ); - expect(await announced.next()).toMatchObject({ path: Path.from("theirs"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("theirs"), kind: "announced" }); const peer = await nextStream(pair.client); if (!peer) throw new Error("no PUBLISH_NAMESPACE stream"); @@ -547,7 +547,7 @@ test("a PUBLISH_NAMESPACE update that starts looping back is detached", async () VERSION, ); - expect(await announced.next()).toMatchObject({ path: Path.from("theirs"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("theirs"), kind: "retracted" }); expect(await peer.reader.u53()).toBe(RequestOk.id); await RequestOk.decode(peer.reader, VERSION); @@ -557,7 +557,7 @@ test("a PUBLISH_NAMESPACE update that starts looping back is detached", async () peer.writer, VERSION, ); - expect(await announced.next()).toMatchObject({ path: Path.from("theirs"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("theirs"), kind: "announced" }); expect(await peer.reader.u53()).toBe(RequestOk.id); await RequestOk.decode(peer.reader, VERSION); @@ -588,7 +588,7 @@ test("a PUBLISH_NAMESPACE repricing is acknowledged in place", async () => { request, ); expect(await announced.next()).toMatchObject({ - path: Path.from("theirs"), + prefix: Path.from("theirs"), kind: "announced", route: { hops: [PEER], cost: { warm: 4n, cold: 4n } }, }); @@ -603,7 +603,7 @@ test("a PUBLISH_NAMESPACE repricing is acknowledged in place", async () => { expect(await peer.reader.u53()).toBe(RequestOk.id); await RequestOk.decode(peer.reader, VERSION); expect(await announced.next()).toMatchObject({ - path: Path.from("theirs"), + prefix: Path.from("theirs"), kind: "updated", route: { hops: [PEER], cost: { warm: 0n, cold: 0n } }, }); @@ -611,7 +611,7 @@ test("a PUBLISH_NAMESPACE repricing is acknowledged in place", async () => { // Still announced: the stream ending is what retracts it. peer.close(); await handler; - expect(await announced.next()).toMatchObject({ path: Path.from("theirs"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("theirs"), kind: "retracted" }); }); /** @@ -636,7 +636,7 @@ test("a PUBLISH_NAMESPACE update that changes the publisher is refused", async ( }), request, ); - expect(await announced.next()).toMatchObject({ path: Path.from("theirs"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("theirs"), kind: "announced" }); const peer = await nextStream(pair.client); if (!peer) throw new Error("no PUBLISH_NAMESPACE stream"); @@ -653,7 +653,7 @@ test("a PUBLISH_NAMESPACE update that changes the publisher is refused", async ( // The refusal closed the stream, which withdrew the advertisement. await handler; - expect(await announced.next()).toMatchObject({ path: Path.from("theirs"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("theirs"), kind: "retracted" }); }); /** @@ -675,7 +675,7 @@ test("a repeated PUBLISH_NAMESPACE is a protocol violation", async () => { }); const request = await Stream.open(pair.server, { version: VERSION }); const handler = subscriber.runPublishNamespace(advert, request); - expect(await announced.next()).toMatchObject({ path: Path.from("theirs"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("theirs"), kind: "announced" }); const peer = await nextStream(pair.client); if (!peer) throw new Error("no PUBLISH_NAMESPACE stream"); @@ -683,7 +683,7 @@ test("a repeated PUBLISH_NAMESPACE is a protocol violation", async () => { await advert.encode(peer.writer, VERSION); await expect(handler).rejects.toThrow(ProtocolViolation); - expect(await announced.next()).toMatchObject({ path: Path.from("theirs"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("theirs"), kind: "retracted" }); }); /** diff --git a/js/net/src/ietf/subscriber.ts b/js/net/src/ietf/subscriber.ts index 97b4a4103f..768bf35149 100644 --- a/js/net/src/ietf/subscriber.ts +++ b/js/net/src/ietf/subscriber.ts @@ -162,7 +162,7 @@ export class Subscriber { for (const [active, info] of this.#announced) { if (!scopeOverlaps(scope, active)) continue; announced.append({ - path: active, + prefix: active, captures: scopeCaptures(scope, active), kind: "announced", route: info.route, @@ -193,7 +193,7 @@ export class Subscriber { console.debug(`announced: broadcast=${path} active=true`); for (const [consumer, scope] of this.#announcedConsumers) { if (!scopeOverlaps(scope, path)) continue; - consumer.append({ path, captures: scopeCaptures(scope, path), kind: "announced", route }); + consumer.append({ prefix: path, captures: scopeCaptures(scope, path), kind: "announced", route }); } } @@ -209,7 +209,7 @@ export class Subscriber { console.debug(`announced: broadcast=${path} rerouted`); for (const [consumer, scope] of this.#announcedConsumers) { if (!scopeOverlaps(scope, path)) continue; - consumer.append({ path, captures: scopeCaptures(scope, path), kind: "updated", route }); + consumer.append({ prefix: path, captures: scopeCaptures(scope, path), kind: "updated", route }); } } @@ -235,7 +235,7 @@ export class Subscriber { if (!scopeOverlaps(scope, path)) continue; try { consumer.append({ - path, + prefix: path, captures: scopeCaptures(scope, path), kind: "retracted", route: existing.route, diff --git a/js/net/src/integration.test.ts b/js/net/src/integration.test.ts index 1026115ba7..03e64f9672 100644 --- a/js/net/src/integration.test.ts +++ b/js/net/src/integration.test.ts @@ -91,14 +91,14 @@ async function runPublishSubscribeFlow(protocol: string, version?: number) { const announced = client.announced(); const entry = await announced.next(); if (!entry) throw new Error("expected entry"); - expect(entry.path).toBe("test" as Path.Valid); + expect(entry.prefix).toBe("test" as Path.Valid); expect(entry.kind).toBe("announced"); // Scoped discovery only echoes the suffix on the wire, but presents the whole path. const prefixed = client.announced(Path.Pattern.subtree(Path.from("root"))); const prefixedEntry = await prefixed.next(); if (!prefixedEntry) throw new Error("expected prefixed entry"); - expect(prefixedEntry.path).toBe("root/child" as Path.Valid); + expect(prefixedEntry.prefix).toBe("root/child" as Path.Valid); expect(prefixedEntry.kind).toBe("announced"); // Client consumes the broadcast and subscribes to a track @@ -375,28 +375,28 @@ test("integration: lite draft-06 announce lifecycle", async () => { const announced = client.announced(); let entry = await announced.next(); if (!entry) throw new Error("expected announce"); - expect(entry.path).toBe("first" as Path.Valid); + expect(entry.prefix).toBe("first" as Path.Valid); expect(entry.kind).toBe("announced"); // A live announce. const second = publish(origin, Path.from("second")); entry = await announced.next(); if (!entry) throw new Error("expected announce"); - expect(entry.path).toBe("second" as Path.Valid); + expect(entry.prefix).toBe("second" as Path.Valid); expect(entry.kind).toBe("announced"); // Unannounce: retracted by announce id on the wire. second.close(); entry = await announced.next(); if (!entry) throw new Error("expected unannounce"); - expect(entry.path).toBe("second" as Path.Valid); + expect(entry.prefix).toBe("second" as Path.Valid); expect(entry.kind).toBe("retracted"); // Re-announce the same path: a fresh announce assigning a fresh id. const secondAgain = publish(origin, Path.from("second")); entry = await announced.next(); if (!entry) throw new Error("expected re-announce"); - expect(entry.path).toBe("second" as Path.Valid); + expect(entry.prefix).toBe("second" as Path.Valid); expect(entry.kind).toBe("announced"); // Cleanup @@ -1730,7 +1730,7 @@ async function runOriginFlow(protocol: string, version?: number) { // The announcement lands in the client's origin. const reader = clientOrigin.consume(); const announced = reader.announced(); - expect(await announced.next()).toMatchObject({ path: Path.from("test"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("test"), kind: "announced" }); // Consuming through the origin reaches the wire. const remote = await routed(reader, Path.from("test")); @@ -1740,7 +1740,7 @@ async function runOriginFlow(protocol: string, version?: number) { // Unpublishing retracts the entry over the wire and out of the origin. broadcast.close(); - expect(await announced.next()).toMatchObject({ path: Path.from("test"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("test"), kind: "retracted" }); await until(() => !wireOf(reader).routes(Path.from("test"))); await serving; @@ -2090,7 +2090,7 @@ test("create then announce is discoverable on the wire", async () => { const pending = announced.next(); broadcast.announce(); const entry = await pending; - expect(entry?.path).toBe("later" as Path.Valid); + expect(entry?.prefix).toBe("later" as Path.Valid); expect(entry?.kind).toBe("announced"); announced.close(); @@ -2120,7 +2120,7 @@ test("a handle serves a request under live/** over the wire", async () => { const announced = client.announced(); const entry = await announced.next(); - expect(entry?.path).toBe("live" as Path.Valid); + expect(entry?.prefix).toBe("live" as Path.Valid); expect(entry?.kind).toBe("announced"); const remote = wireOf(client).consume(Path.from("live/cam")); diff --git a/js/net/src/lite/subscriber.test.ts b/js/net/src/lite/subscriber.test.ts index eb791694ed..a26242cf66 100644 --- a/js/net/src/lite/subscriber.test.ts +++ b/js/net/src/lite/subscriber.test.ts @@ -151,7 +151,7 @@ test("a max-length chain plus withheld responder is dropped", async () => { ), ); expect(await announced.next()).toMatchObject({ - path: Path.from("room"), + prefix: Path.from("room"), kind: "announced", route: { hops: [PUBLISHER_A, UNKNOWN_HOP] }, }); @@ -174,7 +174,7 @@ test("an unidentified responder keeps hop 0 on a nonempty chain", async () => { ), ); expect(await announced.next()).toMatchObject({ - path: Path.from("room"), + prefix: Path.from("room"), kind: "announced", route: { hops: [PUBLISHER_A, UNKNOWN_HOP] }, }); @@ -193,7 +193,7 @@ test("a received empty hop list is filled with hop 0 and marked anonymous", asyn encodeAnnounceBroadcast(w, { status: "active", suffix: Path.from("room"), hops: [] }, Version.DRAFT_06), ); expect(await announced.next()).toMatchObject({ - path: Path.from("room"), + prefix: Path.from("room"), kind: "announced", route: { hops: [UNKNOWN_HOP] }, }); @@ -216,7 +216,7 @@ test("a received chain with hop 0 is marked anonymous", async () => { ), ); expect(await announced.next()).toMatchObject({ - path: Path.from("room"), + prefix: Path.from("room"), kind: "announced", }); @@ -237,7 +237,7 @@ test("a restart from the same publisher is a route change, not a republish", asy Version.DRAFT_06, ), ); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "announced" }); // Same publisher over an identical route. In-flight subscriptions resume across it, // and there is no metadata to forward, so the subscriber must not surface a restart. @@ -246,19 +246,19 @@ test("a restart from the same publisher is a route change, not a republish", asy // A different publisher took the path: nothing carries over, so this one does surface, // as an end before the start. Reaching it proves the identical reroute above emitted nothing. await send((w) => encodeAnnounceBroadcast(w, { status: "restart", id: 0n, hops: [PUBLISHER_B] }, Version.DRAFT_06)); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "retracted" }); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "announced" }); // A third publisher takes over. The replacement above has to leave its own publisher on // record, or this one reads as a first announcement and skips the end. await send((w) => encodeAnnounceBroadcast(w, { status: "restart", id: 0n, hops: [PUBLISHER_C] }, Version.DRAFT_06)); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "retracted" }); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "announced" }); // And the new owner's own reroute is still transparent. await send((w) => encodeAnnounceBroadcast(w, { status: "restart", id: 0n, hops: [PUBLISHER_C] }, Version.DRAFT_06)); await send((w) => encodeAnnounceBroadcast(w, { status: "endedId", id: 0n }, Version.DRAFT_06)); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "retracted" }); announced.close(); subscriber.close(); @@ -278,7 +278,7 @@ test("a restart that re-prices the same publisher emits the new route", async () ), ); expect(await announced.next()).toMatchObject({ - path: Path.from("room"), + prefix: Path.from("room"), kind: "announced", route: { hops: [PUBLISHER_A, PEER], cost: { warm: 0n, cold: 0n } }, }); @@ -291,7 +291,7 @@ test("a restart that re-prices the same publisher emits the new route", async () ), ); expect(await announced.next()).toMatchObject({ - path: Path.from("room"), + prefix: Path.from("room"), kind: "updated", route: { hops: [PUBLISHER_A, PEER], cost: { warm: 4n, cold: 4n } }, }); @@ -310,13 +310,13 @@ test("a lite-05 duplicate announce follows the same restart rule", async () => { encodeAnnounceBroadcast(w, { status: "active", suffix: Path.from("room"), hops }, Version.DRAFT_05); await send(active([PUBLISHER_A])); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "announced" }); // On lite-05 a restart travels as a duplicate ANNOUNCE rather than its own message. await send(active([PUBLISHER_A])); await send(active([PUBLISHER_B])); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "retracted" }); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "announced" }); announced.close(); subscriber.close(); @@ -336,12 +336,12 @@ test("a restart from an unidentified publisher replaces rather than reroutes", a await send((w) => encodeAnnounceBroadcast(w, { status: "active", suffix: Path.from("room"), hops: [] }, Version.DRAFT_06), ); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "announced" }); // Nobody is named on either side, so this is not provably the same content. await send((w) => encodeAnnounceBroadcast(w, { status: "restart", id: 0n, hops: [] }, Version.DRAFT_06)); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "retracted" }); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "announced" }); announced.close(); subscriber.close(); @@ -502,11 +502,11 @@ test("a restart replaces an announce that was skipped as a reflected loop", asyn // The id stays live, so the peer may restart it into a route that is usable here. await send((w) => encodeAnnounceBroadcast(w, { status: "restart", id: 0n, hops: [PUBLISHER_A] }, Version.DRAFT_06)); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "announced" }); // Retiring the id ends what the restart attached, and nothing else. await send((w) => encodeAnnounceBroadcast(w, { status: "endedId", id: 0n }, Version.DRAFT_06)); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "retracted" }); announced.close(); subscriber.close(); @@ -536,13 +536,13 @@ test("retiring an id whose announce was skipped ends nothing", async () => { Version.DRAFT_06, ), ); - expect(await announced.next()).toMatchObject({ path: Path.from("lobby"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("lobby"), kind: "announced" }); // Retire the skipped one, then the live one. The first must surface nothing, so the // only end a consumer sees is "lobby". await send((w) => encodeAnnounceBroadcast(w, { status: "endedId", id: 0n }, Version.DRAFT_06)); await send((w) => encodeAnnounceBroadcast(w, { status: "endedId", id: 1n }, Version.DRAFT_06)); - expect(await announced.next()).toMatchObject({ path: Path.from("lobby"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("lobby"), kind: "retracted" }); announced.close(); subscriber.close(); @@ -556,10 +556,10 @@ test("a draft-02 initial announcement can still be retracted", async () => { // ANNOUNCE_INIT carries the initial set. These are advertisements like any other, so // the peer may retract one later and the consumer has to hear about it. await send((w) => new AnnounceInit([Path.from("room")]).encode(w, Version.DRAFT_02)); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "announced" }); await send((w) => encodeAnnounceBroadcast(w, { status: "ended", suffix: Path.from("room") }, Version.DRAFT_02)); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "retracted" }); announced.close(); subscriber.close(); @@ -581,7 +581,7 @@ test("a duplicate start is reported even when its own route reflects", async () Version.DRAFT_06, ), ); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "announced" }); // A second start for that path, carrying a route that loops back through us. Skipping // it must not pre-empt the violation: the peer sent two starts with no end between @@ -637,7 +637,7 @@ test("a violation on a very long path still ends the session", async () => { const long = Path.from("x".repeat(2000)); const active: AnnounceBroadcast = { status: "active", suffix: long, hops: [PUBLISHER_A] }; await send((w) => encodeAnnounceBroadcast(w, active, Version.DRAFT_06)); - expect(await announced.next()).toMatchObject({ path: long, kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: long, kind: "announced" }); await send((w) => encodeAnnounceBroadcast(w, active, Version.DRAFT_06)); await expect(announced.next()).rejects.toThrow("duplicate announce"); @@ -658,7 +658,7 @@ test("a draft-04 duplicate start is a violation, not a restart", async () => { const active: AnnounceBroadcast = { status: "active", suffix: Path.from("room"), hops: [PUBLISHER_A] }; await send((w) => encodeAnnounceBroadcast(w, active, Version.DRAFT_04)); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "announced" }); await send((w) => encodeAnnounceBroadcast(w, active, Version.DRAFT_04)); @@ -677,7 +677,7 @@ test("a draft-05 duplicate start is still a restart", async () => { const active: AnnounceBroadcast = { status: "active", suffix: Path.from("room"), hops: [PUBLISHER_A] }; await send((w) => encodeAnnounceBroadcast(w, active, Version.DRAFT_05)); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "announced" }); // Same publisher over a new route: transparent, and emphatically not an error. await send((w) => encodeAnnounceBroadcast(w, active, Version.DRAFT_05)); @@ -685,8 +685,8 @@ test("a draft-05 duplicate start is still a restart", async () => { // A different publisher does surface, which is what proves the reroute above passed. const replaced: AnnounceBroadcast = { status: "active", suffix: Path.from("room"), hops: [PUBLISHER_B] }; await send((w) => encodeAnnounceBroadcast(w, replaced, Version.DRAFT_05)); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "retracted" }); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "announced" }); announced.close(); subscriber.close(); diff --git a/js/net/src/lite/subscriber.ts b/js/net/src/lite/subscriber.ts index 9b3428a2cb..7612fa88e9 100644 --- a/js/net/src/lite/subscriber.ts +++ b/js/net/src/lite/subscriber.ts @@ -245,7 +245,7 @@ export class Subscriber { advertised.set(path, { publisher: undefined, live, route, captures }); if (!live) continue; console.debug(`announced: broadcast=${path} active=true`); - announced.append({ path, captures, kind: "announced", route }); + announced.append({ prefix: path, captures, kind: "announced", route }); } break; } @@ -343,7 +343,12 @@ export class Subscriber { if (!previous?.live) return; this.#consumes.evict(path); console.debug(`announced: broadcast=${path} active=false`); - announced.append({ path, captures: previous.captures, kind: "retracted", route: previous.route }); + announced.append({ + prefix: path, + captures: previous.captures, + kind: "retracted", + route: previous.route, + }); }; // In Lite05+ the sender's origin arrives via AnnounceOk, not in each hop @@ -415,7 +420,7 @@ export class Subscriber { if (!routesEqual(previous.route, route)) { advertised.set(path, { publisher, live: true, route, captures }); console.debug(`announced: broadcast=${path} rerouted`); - announced.append({ path, captures, kind: "updated", route }); + announced.append({ prefix: path, captures, kind: "updated", route }); } else { console.debug(`announced: broadcast=${path} rerouted`); } @@ -433,7 +438,7 @@ export class Subscriber { advertised.set(path, { publisher, live: true, route, captures }); console.debug(`announced: broadcast=${path} active=true`); - announced.append({ path, captures, kind: "announced", route }); + announced.append({ prefix: path, captures, kind: "announced", route }); } announced.close(); diff --git a/js/net/src/origin.test.ts b/js/net/src/origin.test.ts index d457a1e70d..973cf75f98 100644 --- a/js/net/src/origin.test.ts +++ b/js/net/src/origin.test.ts @@ -198,16 +198,16 @@ test("announced streams the table under a scope with origin-relative paths", asy const announced = consumer.announced(Path.Pattern.subtree(Path.from("room"))); // The initial state arrives first, named from the origin rather than the scope. - expect(await announced.next()).toMatchObject({ path: Path.from("room/a"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room/a"), kind: "announced" }); // Additions under the scope stream in; paths outside it are invisible. const b = publish(origin, Path.from("room/b")); publish(origin, Path.from("lobby/c")); - expect(await announced.next()).toMatchObject({ path: Path.from("room/b"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room/b"), kind: "announced" }); // Removals retract. b.close(); - expect(await announced.next()).toMatchObject({ path: Path.from("room/b"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room/b"), kind: "retracted" }); // The stream ends when the origin closes. origin.close(); @@ -231,10 +231,10 @@ test("a remote entry resolves by path and retracts on dispose", async () => { // Announced streams include remote entries. const announced = consumer.announced(); - expect(await announced.next()).toMatchObject({ path: path, kind: "announced", route: Route.default }); + expect(await announced.next()).toMatchObject({ prefix: path, kind: "announced", route: Route.default }); dispose(); - expect(await announced.next()).toMatchObject({ path: path, kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: path, kind: "retracted" }); expect(wireOf(consumer).routes(path)).toBe(false); announced.close(); @@ -252,10 +252,10 @@ test("announced keeps a broader covering route at its prefix", async () => { // The scope filters the route without changing the prefix it claims. const scope = Path.Pattern.subtree(Path.from("room/alice")); const announced = consumer.announced(scope); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "announced" }); dispose(); - expect(await announced.next()).toMatchObject({ path: Path.from("room"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("room"), kind: "retracted" }); announced.close(); upstream.close(); @@ -283,7 +283,7 @@ test("a local publish shadows a remote entry", async () => { // One path, one announcement, even though both tables route it. const announced = consumer.announced(); - expect(await announced.next()).toMatchObject({ path: path, kind: "announced", route: Route.default }); + expect(await announced.next()).toMatchObject({ prefix: path, kind: "announced", route: Route.default }); // Dropping the local publish falls back to the remote entry without a retraction. local.close(); @@ -407,12 +407,12 @@ test("disposing the newest remote route promotes the fallback", async () => { const disposeNewer = serve(origin, path, provider(newer)); const announced = consumer.announced(); - expect(await announced.next()).toMatchObject({ path: path, kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: path, kind: "announced" }); // The newer session dies: consumers see a retract then the promoted fallback. disposeNewer(); - expect(await announced.next()).toMatchObject({ path: path, kind: "retracted" }); - expect(await announced.next()).toMatchObject({ path: path, kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: path, kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: path, kind: "announced" }); const handle = await routed(consumer, path); const track = handle?.track("chat").subscribe(); @@ -480,7 +480,7 @@ test("requests never appear in announced or the table", async () => { const announced = consumer.announced(); publish(origin, Path.from("real")); - expect(await announced.next()).toMatchObject({ path: Path.from("real"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("real"), kind: "announced" }); announced.close(); request.close(); @@ -494,12 +494,12 @@ test("a republish retracts then re-announces the path", async () => { publish(origin, path); const announced = consumer.announced(); - expect(await announced.next()).toMatchObject({ path: path, kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: path, kind: "announced" }); // A new broadcast takes the path: consumers must let go of the superseded one. publish(origin, path); - expect(await announced.next()).toMatchObject({ path: path, kind: "retracted" }); - expect(await announced.next()).toMatchObject({ path: path, kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: path, kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: path, kind: "announced" }); announced.close(); origin.close(); @@ -857,17 +857,17 @@ test("createBroadcast is unadvertised until announce", async () => { const announced = consumer.announced(); const pending = announced.next(); broadcast.announce(); - expect(await pending).toMatchObject({ path, kind: "announced", route: Route.default }); + expect(await pending).toMatchObject({ prefix: path, kind: "announced", route: Route.default }); broadcast.announce({ cost: 4n }); expect(await announced.next()).toMatchObject({ - path, + prefix: path, kind: "updated", route: { hops: [], cost: { warm: 4n, cold: 4n } }, }); broadcast.unannounce(); - expect(await announced.next()).toMatchObject({ path: path, kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: path, kind: "retracted" }); expect(wireOf(consumer).routes(path)).toBe(true); announced.close(); @@ -880,7 +880,7 @@ test("a handle serves a request under live", async () => { const consumer = origin.consume(); const handle = origin.dynamic(Path.from("live")); - expect(await consumer.announced().next()).toMatchObject({ path: Path.from("live"), kind: "announced" }); + expect(await consumer.announced().next()).toMatchObject({ prefix: Path.from("live"), kind: "announced" }); const waiting = handle.requested().next(); const request = consumer.request(Path.from("live/cam")); @@ -907,11 +907,11 @@ test("a re-priced route is delivered as an update", async () => { const origin = new Producer(); const handle = origin.dynamic(Path.from("live")); const announced = origin.consume().announced(); - expect(await announced.next()).toMatchObject({ path: Path.from("live"), kind: "announced" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("live"), kind: "announced" }); handle.update({ cost: 5n }); - expect(await announced.next()).toMatchObject({ path: Path.from("live"), kind: "updated" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("live"), kind: "updated" }); handle.close(); - expect(await announced.next()).toMatchObject({ path: Path.from("live"), kind: "retracted" }); + expect(await announced.next()).toMatchObject({ prefix: Path.from("live"), kind: "retracted" }); announced.close(); origin.close(); }); @@ -1116,7 +1116,7 @@ test("announced filters by arbitrary patterns and reports captures", async () => const announced = consumer.announced(Path.Pattern.parse("room/*")); const update = await announced.next(); - expect(update?.path).toBe(Path.from("room/alice")); + expect(update?.prefix).toBe(Path.from("room/alice")); expect(update?.captures?.map((capture) => capture.text)).toEqual(["alice"]); announced.close(); diff --git a/js/net/src/origin.ts b/js/net/src/origin.ts index d831cba725..ec02db38b9 100644 --- a/js/net/src/origin.ts +++ b/js/net/src/origin.ts @@ -1031,14 +1031,24 @@ export class Consumer { for (const [path, snap] of active) { const cur = next.get(path); if (!cur || cur.identity !== snap.identity) - producer.append({ path, captures: snap.captures, kind: "retracted", route: snap.route }); + producer.append({ + prefix: path, + captures: snap.captures, + kind: "retracted", + route: snap.route, + }); } for (const [path, snap] of next) { const prev = active.get(path); if (!prev || prev.identity !== snap.identity) { - producer.append({ path, captures: snap.captures, kind: "announced", route: snap.route }); + producer.append({ + prefix: path, + captures: snap.captures, + kind: "announced", + route: snap.route, + }); } else if (!routesEqual(prev.route, snap.route)) { - producer.append({ path, captures: snap.captures, kind: "updated", route: snap.route }); + producer.append({ prefix: path, captures: snap.captures, kind: "updated", route: snap.route }); } } active = next; diff --git a/js/net/src/path.test.ts b/js/net/src/path.test.ts index 92bfe8b02d..96ef51fd72 100644 --- a/js/net/src/path.test.ts +++ b/js/net/src/path.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "bun:test"; import * as Path from "./path.ts"; +/** Brand a literal as a relative reference; the tests feed raw strings on purpose. */ +const asRel = (s: string) => s as Path.Relative; + test("Path constructor trims leading and trailing slashes", () => { expect(Path.from("/foo/bar/")).toBe("foo/bar" as Path.Valid); expect(Path.from("///foo/bar///")).toBe("foo/bar" as Path.Valid); @@ -191,48 +194,48 @@ test("from sanitizes multiple arguments with slashes", () => { }); test("resolve replaces the base name", () => { - expect(Path.resolve(Path.from("a/b"), "c")).toBe(Path.from("a/c")); - expect(Path.resolve(Path.from("a/b"), "c/d")).toBe(Path.from("a/c/d")); - expect(Path.resolve(Path.from("foo.hang/catalog.pro"), "./transcode.pro")).toBe( + expect(Path.resolve(Path.from("a/b"), asRel("c"))).toBe(Path.from("a/c")); + expect(Path.resolve(Path.from("a/b"), asRel("c/d"))).toBe(Path.from("a/c/d")); + expect(Path.resolve(Path.from("foo.hang/catalog.pro"), asRel("./transcode.pro"))).toBe( Path.from("foo.hang/transcode.pro"), ); }); test("resolve with empty rel returns base", () => { - expect(Path.resolve(Path.from("a/b"), "")).toBe(Path.from("a/b")); + expect(Path.resolve(Path.from("a/b"), asRel(""))).toBe(Path.from("a/b")); }); test("resolve single dotdot pops one segment", () => { - expect(Path.resolve(Path.from("a/b/c"), "../d")).toBe(Path.from("a/d")); - expect(Path.resolve(Path.from("a/b/c"), "..")).toBe(Path.from("a")); + expect(Path.resolve(Path.from("a/b/c"), asRel("../d"))).toBe(Path.from("a/d")); + expect(Path.resolve(Path.from("a/b/c"), asRel(".."))).toBe(Path.from("a")); }); test("resolve multiple dotdot pops multiple segments", () => { - expect(Path.resolve(Path.from("a/b/c"), "../../x")).toBe(Path.from("x")); - expect(Path.resolve(Path.from("a/b/c"), "../../../x")).toBe(Path.from("x")); + expect(Path.resolve(Path.from("a/b/c"), asRel("../../x"))).toBe(Path.from("x")); + expect(Path.resolve(Path.from("a/b/c"), asRel("../../../x"))).toBe(Path.from("x")); }); test("resolve excess dotdot clamps at empty", () => { - expect(Path.resolve(Path.from("a"), "../../../foo")).toBe(Path.from("foo")); - expect(Path.resolve(Path.from("a"), "..")).toBe(Path.from("")); + expect(Path.resolve(Path.from("a"), asRel("../../../foo"))).toBe(Path.from("foo")); + expect(Path.resolve(Path.from("a"), asRel(".."))).toBe(Path.from("")); }); test("relative inverts resolve", () => { // Nested under the base: the base's own last segment is replaced, so it repeats. - expect(Path.relative(Path.from("foo/bar/baz"), Path.from("foo/bar"))).toBe("bar/baz"); + expect(Path.relative(Path.from("foo/bar/baz"), Path.from("foo/bar"))).toBe(asRel("bar/baz")); // Sibling. - expect(Path.relative(Path.from("foo/baz"), Path.from("foo/bar"))).toBe("baz"); + expect(Path.relative(Path.from("foo/baz"), Path.from("foo/bar"))).toBe(asRel("baz")); // Different subtree. - expect(Path.relative(Path.from("foo/baz/bar"), Path.from("foo/bar/baz"))).toBe("../baz/bar"); + expect(Path.relative(Path.from("foo/baz/bar"), Path.from("foo/bar/baz"))).toBe(asRel("../baz/bar")); // The base's parent, which only `.` can name. - expect(Path.relative(Path.from("a/b"), Path.from("a/b/transcode.hang"))).toBe("."); - expect(Path.relative(Path.from("a/b"), Path.from("a/b/one/two/transcode.hang"))).toBe("../.."); + expect(Path.relative(Path.from("a/b"), Path.from("a/b/transcode.hang"))).toBe(asRel(".")); + expect(Path.relative(Path.from("a/b"), Path.from("a/b/one/two/transcode.hang"))).toBe(asRel("../..")); // Roots. - expect(Path.relative(Path.from("foo/bar"), Path.empty())).toBe("foo/bar"); - expect(Path.relative(Path.empty(), Path.from("foo"))).toBe("."); + expect(Path.relative(Path.from("foo/bar"), Path.empty())).toBe(asRel("foo/bar")); + expect(Path.relative(Path.empty(), Path.from("foo"))).toBe(asRel(".")); // The base itself, which only the empty reference names. - expect(Path.relative(Path.from("a/b"), Path.from("a/b"))).toBe(""); - expect(Path.relative(Path.empty(), Path.empty())).toBe(""); + expect(Path.relative(Path.from("a/b"), Path.from("a/b"))).toBe(asRel("")); + expect(Path.relative(Path.empty(), Path.empty())).toBe(asRel("")); }); test("relative rejects unnameable targets", () => { @@ -243,12 +246,12 @@ test("relative rejects unnameable targets", () => { expect(Path.relative(Path.from("a/.."), Path.from("a/b"))).toBeUndefined(); // A base is always nameable by itself, however its last segment is spelled. - expect(Path.relative(Path.from("a/.."), Path.from("a/.."))).toBe(""); + expect(Path.relative(Path.from("a/.."), Path.from("a/.."))).toBe(asRel("")); // Dot segments inside the shared prefix are never emitted, so they are fine. const rel = Path.relative(Path.from("a/../b/x"), Path.from("a/../b/c")); - expect(rel).toBe("x"); - expect(Path.resolve(Path.from("a/../b/c"), rel as string)).toBe(Path.from("a/../b/x")); + expect(rel).toBe(asRel("x")); + expect(Path.resolve(Path.from("a/../b/c"), rel as Path.Relative)).toBe(Path.from("a/../b/x")); }); test("relative round trips through resolve", () => { @@ -273,37 +276,37 @@ test("relative round trips through resolve", () => { }); test("resolve with empty base", () => { - expect(Path.resolve(Path.empty(), "foo")).toBe(Path.from("foo")); - expect(Path.resolve(Path.empty(), "..")).toBe(Path.from("")); + expect(Path.resolve(Path.empty(), asRel("foo"))).toBe(Path.from("foo")); + expect(Path.resolve(Path.empty(), asRel(".."))).toBe(Path.from("")); }); test("resolve dot names the base parent", () => { - expect(Path.resolve(Path.from("a/b"), ".")).toBe(Path.from("a")); - expect(Path.resolve(Path.from("a/b"), "./c")).toBe(Path.from("a/c")); - expect(Path.resolve(Path.from("a/b"), "./../c")).toBe(Path.from("c")); - expect(Path.resolve(Path.from("a/b"), "foo/./bar")).toBe(Path.from("a/foo/bar")); + expect(Path.resolve(Path.from("a/b"), asRel("."))).toBe(Path.from("a")); + expect(Path.resolve(Path.from("a/b"), asRel("./c"))).toBe(Path.from("a/c")); + expect(Path.resolve(Path.from("a/b"), asRel("./../c"))).toBe(Path.from("c")); + expect(Path.resolve(Path.from("a/b"), asRel("foo/./bar"))).toBe(Path.from("a/foo/bar")); }); test("resolve self-reference via sibling name equals base", () => { - expect(Path.resolve(Path.from("a/b"), "./b")).toBe(Path.from("a/b")); + expect(Path.resolve(Path.from("a/b"), asRel("./b"))).toBe(Path.from("a/b")); }); test("tryResolve distinguishes the root from an escape", () => { - expect(Path.tryResolve(Path.from("top"), ".")).toBe(Path.empty()); - expect(Path.tryResolve(Path.from("top"), "..")).toBeUndefined(); - expect(Path.tryResolve(Path.from("a/b"), "..")).toBe(Path.empty()); - expect(Path.tryResolve(Path.from("a/b"), "../..")).toBeUndefined(); + expect(Path.tryResolve(Path.from("top"), asRel("."))).toBe(Path.empty()); + expect(Path.tryResolve(Path.from("top"), asRel(".."))).toBeUndefined(); + expect(Path.tryResolve(Path.from("a/b"), asRel(".."))).toBe(Path.empty()); + expect(Path.tryResolve(Path.from("a/b"), asRel("../.."))).toBeUndefined(); }); test("normalizeRelative preserves an all-dot reference", () => { - expect(Path.normalizeRelative("")).toBe(""); - expect(Path.normalizeRelative(".")).toBe("."); - expect(Path.normalizeRelative("././")).toBe("."); - expect(Path.normalizeRelative("./foo")).toBe("foo"); - expect(Path.normalizeRelative("foo//bar")).toBe("foo/bar"); - expect(Path.normalizeRelative("foo/./bar")).toBe("foo/bar"); - expect(Path.normalizeRelative("/foo/")).toBe("foo"); - expect(Path.normalizeRelative("../foo")).toBe("../foo"); + expect(Path.normalizeRelative("")).toBe(asRel("")); + expect(Path.normalizeRelative(".")).toBe(asRel(".")); + expect(Path.normalizeRelative("././")).toBe(asRel(".")); + expect(Path.normalizeRelative("./foo")).toBe(asRel("foo")); + expect(Path.normalizeRelative("foo//bar")).toBe(asRel("foo/bar")); + expect(Path.normalizeRelative("foo/./bar")).toBe(asRel("foo/bar")); + expect(Path.normalizeRelative("/foo/")).toBe(asRel("foo")); + expect(Path.normalizeRelative("../foo")).toBe(asRel("../foo")); }); test("parts splits a path into its components", () => { diff --git a/js/net/src/path.ts b/js/net/src/path.ts index 77a0b42aa4..f9de73ac06 100644 --- a/js/net/src/path.ts +++ b/js/net/src/path.ts @@ -5,8 +5,10 @@ * preventing issues like "foo" matching "foobar". * * Paths are automatically trimmed of leading and trailing slashes on creation, - * making all slashes implicit at boundaries. - * All paths are RELATIVE; you cannot join with a leading slash to make an absolute path. + * making all slashes implicit at boundaries. A path names a point in the origin's + * tree from its root; a leading slash never escapes that root. The same type names + * an exact broadcast and the prefix a route or announcement covers, so a broadcast's + * own path is the prefix it announces. See {@link Relative} for `..`-style references. * * {@link Pattern} and {@link Patterns} are re-exported from `@moq/pattern`, the owner of * the v1 grammar and algebra. Literal path construction and wire decoding retain @@ -34,6 +36,15 @@ */ export type Valid = string & { __brand: "Name" }; +/** + * A relative reference from one broadcast to another, as a hang catalog carries it. + * + * It may contain `..` and is meaningful only once {@link resolve}d against a base + * {@link Valid} path. It never crosses the wire on its own. Build one with + * {@link normalizeRelative} or {@link relative}. + */ +export type Relative = string & { __brand: "Relative" }; + /** * Maximum number of slash-separated parts in a path. * @@ -184,15 +195,15 @@ export function empty(): Valid { * normalizes to `.` because it names the base's parent, while empty names the base. * `..` is preserved and only interpreted by {@link resolve}. * - * Mirrors the Rust `PathRelative::new` normalization, so JS and Rust agree + * Mirrors the Rust `path::Relative::new` normalization, so JS and Rust agree * byte-for-byte on the stored form. Two callers comparing normalized strings can * detect equivalent references while preserving the distinction between `""` and `"."`. */ -export function normalizeRelative(rel: string): string { +export function normalizeRelative(rel: string): Relative { const raw = rel.split("/"); const normalized = raw.filter((s) => s !== "" && s !== ".").join("/"); - return normalized === "" && raw.includes(".") ? "." : normalized; + return (normalized === "" && raw.includes(".") ? "." : normalized) as Relative; } /** @@ -213,7 +224,7 @@ export function normalizeRelative(rel: string): string { * Path.resolve(Path.from("a/b/c"), "../source"); // "a/source" * ``` */ -export function resolve(base: Valid, rel: string): Valid { +export function resolve(base: Valid, rel: Relative): Valid { if (rel === "") return base; const segments = base === "" ? [] : base.split("/"); @@ -240,7 +251,7 @@ export function resolve(base: Valid, rel: string): Valid { * path from excess `..` segments. Use it for untrusted catalog references that * must not be clamped to the root. */ -export function tryResolve(base: Valid, rel: string): Valid | undefined { +export function tryResolve(base: Valid, rel: Relative): Valid | undefined { if (rel === "") return base; const segments = base === "" ? [] : base.split("/"); @@ -288,10 +299,10 @@ export function tryResolve(base: Valid, rel: string): Valid | undefined { * Path.relative(Path.from("a/.."), Path.from("a/b")); // undefined * ``` */ -export function relative(target: Valid, base: Valid): string | undefined { +export function relative(target: Valid, base: Valid): Relative | undefined { // Only the empty reference can name a base whose last segment is itself `.` or `..`, // since resolution replaces that segment rather than emitting it. - if (target === base) return ""; + if (target === base) return "" as Relative; // Resolution replaces the base's last segment, so walk from its parent. const dir = base === "" ? [] : base.split("/"); @@ -313,7 +324,7 @@ export function relative(target: Valid, base: Valid): string | undefined { .concat(down); // An empty reference resolves to the base itself, so name the parent explicitly. - return rel.length === 0 ? "." : rel.join("/"); + return (rel.length === 0 ? "." : rel.join("/")) as Relative; } /** Path patterns: grammar and algebra owned by `@moq/pattern`. */ diff --git a/js/room/src/room.test.ts b/js/room/src/room.test.ts index e9a421e176..7ddc3140c0 100644 --- a/js/room/src/room.test.ts +++ b/js/room/src/room.test.ts @@ -18,7 +18,7 @@ test("room restores the announce prefix and reconciles local identity changes", announced(scope: Net.Path.Pattern) { expect(scope.equals(Net.Path.Pattern.subtree(Net.Path.from("room-a")))).toBe(true); let update: Net.Announce.Update | undefined = { - path: Net.Path.from("room-a/bob/camera.hang"), + prefix: Net.Path.from("room-a/bob/camera.hang"), captures: [Net.Path.Pattern.literal(Net.Path.from("bob/camera.hang"))], kind: "announced", route: { hops: [], cost: { warm: 0n, cold: 0n } }, diff --git a/js/watch/src/broadcast.test.ts b/js/watch/src/broadcast.test.ts index 81f76dc116..d5128ea51f 100644 --- a/js/watch/src/broadcast.test.ts +++ b/js/watch/src/broadcast.test.ts @@ -61,10 +61,10 @@ describe("relativeBroadcast", () => { const { source, owner } = broadcast("a/b", ["a/b", "a/source", "a/sub"]); const effect = new Effect(); try { - expect(source.relativeBroadcast(effect, "./source")).toBeDefined(); - expect(source.relativeBroadcast(effect, "sub")).toBeDefined(); + expect(source.relativeBroadcast(effect, Path.normalizeRelative("./source"))).toBeDefined(); + expect(source.relativeBroadcast(effect, Path.normalizeRelative("sub"))).toBeDefined(); // Nothing routes an unpublished sibling, so the reference stays pending. - expect(source.relativeBroadcast(effect, "./missing")).toBeUndefined(); + expect(source.relativeBroadcast(effect, Path.normalizeRelative("./missing"))).toBeUndefined(); } finally { effect.close(); source.close(); @@ -79,11 +79,11 @@ describe("relativeBroadcast", () => { // Clamping would subscribe to an unrelated `x` instead of dropping the rendition; // `x` is published, so a defined result here would prove the clamp bug. withoutWarnings(() => { - expect(source.relativeBroadcast(effect, "../../x")).toBeUndefined(); - expect(source.relativeBroadcast(effect, "../..")).toBeUndefined(); + expect(source.relativeBroadcast(effect, Path.normalizeRelative("../../x"))).toBeUndefined(); + expect(source.relativeBroadcast(effect, Path.normalizeRelative("../.."))).toBeUndefined(); }); // Popping to exactly the root stops at it, and the root still names a broadcast. - expect(source.relativeBroadcast(effect, "..")).toBeDefined(); + expect(source.relativeBroadcast(effect, Path.normalizeRelative(".."))).toBeDefined(); } finally { effect.close(); source.close(); @@ -185,8 +185,8 @@ describe("relativeBroadcast", () => { const own = source.out.active.peek(); expect(own).toBeDefined(); expect(source.relativeBroadcast(effect, undefined)).toBe(own); - expect(source.relativeBroadcast(effect, "")).toBe(own); - expect(source.relativeBroadcast(effect, "./b")).toBe(own); + expect(source.relativeBroadcast(effect, Path.normalizeRelative(""))).toBe(own); + expect(source.relativeBroadcast(effect, Path.normalizeRelative("./b"))).toBe(own); } finally { effect.close(); source.close(); diff --git a/js/watch/src/broadcast.ts b/js/watch/src/broadcast.ts index 15b5276713..1d70537d18 100644 --- a/js/watch/src/broadcast.ts +++ b/js/watch/src/broadcast.ts @@ -41,7 +41,7 @@ function assertResolvable(base: Moq.Path.Valid, catalog: Catalog.Root): Catalog. } type ReferencedRendition = { - broadcast?: string; + broadcast?: Path.Relative; }; // Either the catalog's own broadcast, or a sibling to consume by path. @@ -49,14 +49,14 @@ type RelativeTarget = { local: true } | { local: false; path: Moq.Path.Valid }; function filterRenditions( renditions: Record, - usable: (rel: string | undefined) => boolean, + usable: (rel: Path.Relative | undefined) => boolean, ): Record { return Object.fromEntries(Object.entries(renditions).filter(([, config]) => usable(config.broadcast))); } // Every section carrying renditions must be listed here, same as `findEscaping`; one left // out silently exempts its renditions from the reachability filter. -function filterCatalog(catalog: Catalog.Root, usable: (rel: string | undefined) => boolean): Catalog.Root { +function filterCatalog(catalog: Catalog.Root, usable: (rel: Path.Relative | undefined) => boolean): Catalog.Root { return { ...catalog, video: catalog.video @@ -191,8 +191,8 @@ export class Broadcast { if (!entry) break; this.#announced.mutate((active) => { if (!active) return; - if (Announce.isActive(entry.kind)) active.add(entry.path); - else active.delete(entry.path); + if (Announce.isActive(entry.kind)) active.add(entry.prefix); + else active.delete(entry.prefix); }); } }); @@ -204,7 +204,7 @@ export class Broadcast { // arrive, so a rendition appears once its broadcast does. #runFiltered(effect: Effect): void { const raw = effect.get(this.#raw); - const usable = (rel: string | undefined) => this.#relativeTarget(effect, rel) !== undefined; + const usable = (rel: Path.Relative | undefined) => this.#relativeTarget(effect, rel) !== undefined; effect.set(this.#out.catalog, raw ? filterCatalog(raw, usable) : undefined); } @@ -344,7 +344,7 @@ export class Broadcast { // Where a rendition's `broadcast` reference points once resolved and gated on the announcement // stream, or `undefined` when it names nothing consumable right now. Playback and rendition // selection both go through this so they cannot disagree about what is reachable. - #relativeTarget(effect: Effect, rel: string | undefined): RelativeTarget | undefined { + #relativeTarget(effect: Effect, rel: Path.Relative | undefined): RelativeTarget | undefined { if (!rel) return { local: true }; const base = effect.get(this.in.name); @@ -389,7 +389,7 @@ export class Broadcast { * reference resolves lazily and reacts to `enabled` / connection / announcement * changes exactly like the catalog broadcast. */ - relativeBroadcast(effect: Effect, rel: string | undefined): Moq.Broadcast.Consumer | undefined { + relativeBroadcast(effect: Effect, rel: Path.Relative | undefined): Moq.Broadcast.Consumer | undefined { const target = this.#relativeTarget(effect, rel); if (!target) return undefined; if (target.local) return effect.get(this.out.active); diff --git a/js/watch/src/video/source.test.ts b/js/watch/src/video/source.test.ts index b268792ddd..869a8930f6 100644 --- a/js/watch/src/video/source.test.ts +++ b/js/watch/src/video/source.test.ts @@ -133,8 +133,8 @@ describe("Source error signal", () => { // and selecting the fallback would leave a publisher bug to surface later as a track // that never fills. it("selects nothing when an escaping rendition rejects the catalog", async () => { - const invalidVideo = { ...config("avc1.640028"), broadcast: "../../source" }; - const validVideo = { ...config("avc1.640028"), broadcast: "./source" }; + const invalidVideo = { ...config("avc1.640028"), broadcast: Path.normalizeRelative("../../source") }; + const validVideo = { ...config("avc1.640028"), broadcast: Path.normalizeRelative("./source") }; const audioConfig = Catalog.AudioConfigSchema.parse({ codec: "opus", container: { kind: "legacy" }, @@ -149,8 +149,8 @@ describe("Source error signal", () => { video: { renditions: { invalid: invalidVideo, fallback: validVideo } }, audio: { renditions: { - invalid: { ...audioConfig, broadcast: "../../source" }, - fallback: { ...audioConfig, broadcast: "./source" }, + invalid: { ...audioConfig, broadcast: Path.normalizeRelative("../../source") }, + fallback: { ...audioConfig, broadcast: Path.normalizeRelative("./source") }, }, }, }, diff --git a/quest/m1/README.md b/quest/m1/README.md index 8e04209164..f973d22a0a 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -28,7 +28,6 @@ does not require it. The transport line in m2 assumes the single noq stack. ## Quests - [Bindings announce match](/quest/m1/api-origin-scopes.md) - every binding takes a pattern scope and reports the announce match with its captures -- [PathPrefixes](/quest/m1/api-path-prefixes.md) - the unused moq_net::PathPrefixes type is deleted before the release - [Rendition ownership](/quest/m1/api-mux-rendition.md) - one handle publishes a media track and reports its estimate, instead of five - [Cluster -01](/quest/m1/cluster-01/README.md) - rs/moq-net and js/net speak the revised cluster extension (HOP_ID, REQUEST_UPDATE repricing) and -01 is published - [API review gate](/quest/m1/api-review-gate.md) - each `api-*` quest above is landed or deferred by the maintainer before the merge PR opens diff --git a/quest/m1/api-path-prefixes.md b/quest/m1/api-path-prefixes.md deleted file mode 100644 index a8d7a1ab2d..0000000000 --- a/quest/m1/api-path-prefixes.md +++ /dev/null @@ -1,18 +0,0 @@ -# [XS] Delete moq_net::PathPrefixes - -## Goal - -`moq_net::PathPrefixes` (`rs/moq-net/src/path/mod.rs`, re-exported from -`lib.rs`) is gone. Origin scopes are `Patterns` now, and once the pattern -union PR lands nothing in the repository constructs a `PathPrefixes`. A public -type with no consumer is surface the release would have to keep. - -## Plan - -Delete the type, its re-export, and its tests; move any helper the origin -still uses onto `Patterns`. `just check` across the workspace finds any -straggler. Public API: breaking on moq-net, so on dev. Wire: none. - -## Required - -- PR #3746 has merged: it removes the last internal use, `PathPrefixes::from_patterns` diff --git a/quest/m1/api-review-gate.md b/quest/m1/api-review-gate.md index fc61463d85..efd1ced990 100644 --- a/quest/m1/api-review-gate.md +++ b/quest/m1/api-review-gate.md @@ -17,7 +17,6 @@ file is deleted on completion) or deletes the quest with a note in quest is deleted too. No code. The list: [Bindings announce match](/quest/m1/api-origin-scopes.md), -[PathPrefixes](/quest/m1/api-path-prefixes.md), [Rendition ownership](/quest/m1/api-mux-rendition.md). ## Related diff --git a/rs/hang/examples/subscribe.rs b/rs/hang/examples/subscribe.rs index aa469f2ac1..eb1614df05 100644 --- a/rs/hang/examples/subscribe.rs +++ b/rs/hang/examples/subscribe.rs @@ -44,8 +44,8 @@ async fn run_subscribe(consumer: moq_net::origin::Consumer) -> anyhow::Result<() // Wait for a route to be announced, then resolve the broadcast at its path. // The convention is that a publisher announces each broadcast's exact path. let update = consumer.announced().next().await.context("origin closed")?; - anyhow::ensure!(update.kind.is_active(), "route retracted: {}", update.path); - let path = update.path; + anyhow::ensure!(update.kind.is_active(), "route retracted: {}", update.prefix); + let path = update.prefix; tracing::info!(%path, "broadcast announced"); let broadcast = consumer.request_broadcast(&path).await?; diff --git a/rs/hang/src/catalog/archive.rs b/rs/hang/src/catalog/archive.rs index e3c0135c26..2cf3a5e8e2 100644 --- a/rs/hang/src/catalog/archive.rs +++ b/rs/hang/src/catalog/archive.rs @@ -28,7 +28,7 @@ pub struct Archive { /// The MoQ broadcast the archive is served back from, relative to this catalog, if any. #[serde(default)] - pub replay: Option, + pub replay: Option, /// The object-store URL the recording objects live under, if exposed by the publisher. #[serde(default)] @@ -78,7 +78,7 @@ mod test { #[test] fn recording_roundtrips_replay_store_and_version() { let mut archive = Archive::new("timeline.z"); - archive.replay = Some(moq_net::PathRelativeOwned::new("./recordings/clip")); + archive.replay = Some(moq_net::path::RelativeOwned::new("./recordings/clip")); archive.store = Some("https://objects.example/rec/".parse().unwrap()); archive.version = Some(Archive::VERSION); diff --git a/rs/hang/src/catalog/audio/mod.rs b/rs/hang/src/catalog/audio/mod.rs index 2530c029f0..6afa7cef18 100644 --- a/rs/hang/src/catalog/audio/mod.rs +++ b/rs/hang/src/catalog/audio/mod.rs @@ -76,7 +76,7 @@ pub struct AudioConfig { /// Resolve it with [`Path::resolve`](moq_net::Path::resolve): a reference that walks /// above the root names no broadcast, so the catalog is rejected. #[serde(default)] - pub broadcast: Option, + pub broadcast: Option, /// Human-readable rendition name for track pickers. #[serde(default)] diff --git a/rs/hang/src/catalog/binary.rs b/rs/hang/src/catalog/binary.rs index 13ad085f77..7a89406785 100644 --- a/rs/hang/src/catalog/binary.rs +++ b/rs/hang/src/catalog/binary.rs @@ -70,7 +70,7 @@ pub struct BinaryConfig { /// broadcast that served this catalog (e.g. `./source`). If unset, the track lives in the same /// broadcast as the catalog. #[serde(default)] - pub broadcast: Option, + pub broadcast: Option, /// Whether the track is a latest-value blob or an append log. Always stated: see [`Mode`]. #[serde_as(as = "serde_with::DisplayFromStr")] diff --git a/rs/hang/src/catalog/json.rs b/rs/hang/src/catalog/json.rs index c0ef21794d..cdc85a0397 100644 --- a/rs/hang/src/catalog/json.rs +++ b/rs/hang/src/catalog/json.rs @@ -68,7 +68,7 @@ pub struct JsonConfig { /// broadcast that served this catalog (e.g. `./source`). If unset, the track lives in the same /// broadcast as the catalog. #[serde(default)] - pub broadcast: Option, + pub broadcast: Option, /// Whether the track is a latest-value document or an append log. Always stated: see [`Mode`]. #[serde_as(as = "serde_with::DisplayFromStr")] diff --git a/rs/hang/src/catalog/root.rs b/rs/hang/src/catalog/root.rs index 741b273476..4e498f2fbf 100644 --- a/rs/hang/src/catalog/root.rs +++ b/rs/hang/src/catalog/root.rs @@ -403,7 +403,7 @@ mod test { #[test] fn rendition_with_empty_broadcast_normalizes() { - // An empty-string broadcast field should normalize to an empty PathRelative so the + // An empty-string broadcast field should normalize to an empty `path::Relative` so the // consumer can treat it identically to a missing field. let encoded = r#"{ "video": { @@ -653,7 +653,7 @@ mod test { fn archive_roundtrips_at_the_root() { let mut archive = crate::catalog::Archive::new("timeline.z"); archive.duration_max = Some(2000); - archive.replay = Some(moq_net::PathRelativeOwned::new("recordings/clip")); + archive.replay = Some(moq_net::path::RelativeOwned::new("recordings/clip")); archive.version = Some(crate::catalog::Archive::VERSION); let catalog = Catalog::<()> { @@ -716,7 +716,7 @@ mod test { chat.schema = Some("https://example.com/chat.schema.json".to_string()); let mut status = JsonConfig::new(Mode::Snapshot); - status.broadcast = Some(moq_net::PathRelativeOwned::new("source")); + status.broadcast = Some(moq_net::path::RelativeOwned::new("source")); let mut thumbnail = BinaryConfig::new(Mode::Snapshot); thumbnail.mime = Some("image/jpeg".to_string()); diff --git a/rs/hang/src/catalog/text/mod.rs b/rs/hang/src/catalog/text/mod.rs index 2e34d3968b..acd2e325af 100644 --- a/rs/hang/src/catalog/text/mod.rs +++ b/rs/hang/src/catalog/text/mod.rs @@ -75,7 +75,7 @@ pub struct TextConfig { /// broadcast that served this catalog (e.g. `../source`). If unset, the track lives in the same /// broadcast as the catalog. #[serde(default)] - pub broadcast: Option, + pub broadcast: Option, /// The serialization format of each cue payload. #[serde_as(as = "DisplayFromStr")] diff --git a/rs/hang/src/catalog/video/mod.rs b/rs/hang/src/catalog/video/mod.rs index 223ca73b30..1c75126d0e 100644 --- a/rs/hang/src/catalog/video/mod.rs +++ b/rs/hang/src/catalog/video/mod.rs @@ -152,7 +152,7 @@ pub struct VideoConfig { /// Resolve it with [`Path::resolve`](moq_net::Path::resolve): a reference that walks /// above the root names no broadcast, so the catalog is rejected. #[serde(default)] - pub broadcast: Option, + pub broadcast: Option, /// Human-readable rendition name for track pickers. #[serde(default)] diff --git a/rs/libmoq/src/consume.rs b/rs/libmoq/src/consume.rs index 5499db2ccd..f0fda7bf06 100644 --- a/rs/libmoq/src/consume.rs +++ b/rs/libmoq/src/consume.rs @@ -55,7 +55,7 @@ struct ConsumeBroadcast { async fn resolve( broadcast: moq_net::broadcast::Consumer, origin: Option, - reference: Option, + reference: Option, ) -> Result { let Some(reference) = reference.filter(|reference| !reference.is_empty()) else { return Ok(broadcast); diff --git a/rs/libmoq/src/origin.rs b/rs/libmoq/src/origin.rs index 6fc8a2d1f9..f5e331219f 100644 --- a/rs/libmoq/src/origin.rs +++ b/rs/libmoq/src/origin.rs @@ -103,7 +103,7 @@ impl Origin { let announced_id = State::lock() .origin .announced - .insert((update.path.to_string(), update.kind.is_active()))?; + .insert((update.prefix.to_string(), update.kind.is_active()))?; callback.call(announced_id); } } diff --git a/rs/libmoq/src/test.rs b/rs/libmoq/src/test.rs index e19923e25b..a45cbd32ed 100644 --- a/rs/libmoq/src/test.rs +++ b/rs/libmoq/src/test.rs @@ -2844,7 +2844,7 @@ fn consume_audio_follows_a_sibling_broadcast_reference() { .renditions .get_mut(&name) .unwrap() - .broadcast = Some(moq_net::PathRelative::new("./source").into_owned()); + .broadcast = Some(moq_net::path::Relative::new("./source").into_owned()); } let consume = request_broadcast(origin, b"a/pub"); diff --git a/rs/moq-bench/src/connection.rs b/rs/moq-bench/src/connection.rs index 452a14cfee..1502ab5c87 100644 --- a/rs/moq-bench/src/connection.rs +++ b/rs/moq-bench/src/connection.rs @@ -339,7 +339,7 @@ async fn subscribe( if !update.kind.is_active() { continue; } - let path = update.path.to_string(); + let path = update.prefix.to_string(); if own.contains(&path) || !seen.insert(path.clone()) { continue; } @@ -366,7 +366,7 @@ async fn subscribe( if !update.kind.is_active() { continue; } - let path = update.path.to_string(); + let path = update.prefix.to_string(); if own.contains(&path) || !seen.insert(path.clone()) { continue; } diff --git a/rs/moq-bench/src/host.rs b/rs/moq-bench/src/host.rs index 1849d4d3b7..8350ab68a7 100644 --- a/rs/moq-bench/src/host.rs +++ b/rs/moq-bench/src/host.rs @@ -14,6 +14,7 @@ //! its `/proc` entry. Combine with the load generator's `--output` to compute CPU //! per connection and CPU per message (see the README). +#[cfg(target_os = "linux")] mod duration; #[cfg(target_os = "linux")] diff --git a/rs/moq-boy/src/input.rs b/rs/moq-boy/src/input.rs index 9764e33e89..cf6e658ea7 100644 --- a/rs/moq-boy/src/input.rs +++ b/rs/moq-boy/src/input.rs @@ -70,10 +70,10 @@ pub async fn handle_viewers( break; }; - let viewer_id = update.path.to_string(); + let viewer_id = update.prefix.to_string(); if update.kind.is_active() { - let Ok(broadcast) = viewer_origin.request_broadcast(&update.path).await else { + let Ok(broadcast) = viewer_origin.request_broadcast(&update.prefix).await else { continue; }; tracing::info!(%viewer_id, "viewer connected"); diff --git a/rs/moq-cli/src/complete.rs b/rs/moq-cli/src/complete.rs index e63f6ac422..104f64fac2 100644 --- a/rs/moq-cli/src/complete.rs +++ b/rs/moq-cli/src/complete.rs @@ -486,7 +486,7 @@ fn broadcasts(_ctx: CompleteCtx<'_>) -> CompletionFuture<'static> { let mut until = deadline; while let Ok(Some(update)) = timeout_at(until, announced.next()).await { until = deadline.min(Instant::now() + SETTLE); - let path = update.path.to_string(); + let path = update.prefix.to_string(); // The root broadcast is the connection path itself, which an unset // `--broadcast` already names; there is no word to insert for it. if path.is_empty() { diff --git a/rs/moq-ffi/src/consumer.rs b/rs/moq-ffi/src/consumer.rs index 9c15509d8f..84da7e0076 100644 --- a/rs/moq-ffi/src/consumer.rs +++ b/rs/moq-ffi/src/consumer.rs @@ -132,7 +132,7 @@ impl MoqBroadcastConsumer { ) -> Result { // Normalize before testing emptiness: this is a caller-supplied string, and one made only // of slashes normalizes to the empty reference, which names this broadcast. - let reference = reference.map(moq_net::PathRelative::new); + let reference = reference.map(moq_net::path::Relative::new); // An absent or empty reference names the catalog's own broadcast, which we already hold. // Short-circuiting also keeps a standalone broadcast usable: the common case needs no origin. diff --git a/rs/moq-ffi/src/media.rs b/rs/moq-ffi/src/media.rs index 4e7407571e..b3318bed09 100644 --- a/rs/moq-ffi/src/media.rs +++ b/rs/moq-ffi/src/media.rs @@ -447,19 +447,19 @@ mod test { let mut catalog = moq_mux::catalog::hang::Catalog::default(); let mut video = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - video.broadcast = Some(moq_net::PathRelative::new("./source").into_owned()); + video.broadcast = Some(moq_net::path::Relative::new("./source").into_owned()); catalog.video.renditions.insert("video".to_string(), video); let local = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); catalog.video.renditions.insert("local".to_string(), local); let mut audio = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Opus, 48_000, 2); - audio.broadcast = Some(moq_net::PathRelative::new("../elsewhere").into_owned()); + audio.broadcast = Some(moq_net::path::Relative::new("../elsewhere").into_owned()); catalog.audio.renditions.insert("audio".to_string(), audio); let converted = convert_catalog(&catalog); - // `PathRelative` strips redundant `.` segments on creation, so the reference crosses as + // `path::Relative` strips redundant `.` segments on creation, so the reference crosses as // the normalized form the catalog itself holds. It round-trips: rebuilding a - // `PathRelative` from it is a no-op. + // `path::Relative` from it is a no-op. assert_eq!(converted.video["video"].broadcast.as_deref(), Some("source")); assert_eq!(converted.video["local"].broadcast, None); assert_eq!(converted.audio["audio"].broadcast.as_deref(), Some("../elsewhere")); diff --git a/rs/moq-ffi/src/origin.rs b/rs/moq-ffi/src/origin.rs index 1ea84675a0..ec256072ae 100644 --- a/rs/moq-ffi/src/origin.rs +++ b/rs/moq-ffi/src/origin.rs @@ -135,7 +135,7 @@ impl Announced { async fn next(&mut self) -> Result>, MoqError> { match self.inner.next().await { Some(update) => Ok(Some(Arc::new(MoqAnnounceUpdate { - prefix: update.path.to_string(), + prefix: update.prefix.to_string(), route: update.route.into(), active: update.kind.is_active(), }))), diff --git a/rs/moq-hls/src/export/mod.rs b/rs/moq-hls/src/export/mod.rs index 5fb94d44e9..f1f4f43635 100644 --- a/rs/moq-hls/src/export/mod.rs +++ b/rs/moq-hls/src/export/mod.rs @@ -428,7 +428,7 @@ mod tests { source, }; let mut config = hang::catalog::VideoConfig::new(hang::catalog::VideoCodec::VP8); - config.broadcast = Some(moq_net::PathRelative::new("../../source").to_owned()); + config.broadcast = Some(moq_net::path::Relative::new("../../source").to_owned()); let mut catalog = moq_mux::catalog::hang::Catalog::default(); catalog.archive = Some(hang::catalog::Archive::new(hang::timeline::DEFAULT_NAME)); catalog.video.renditions.insert("video".to_string(), config); @@ -1448,7 +1448,7 @@ mod tests { // A relative reference replaces the base's last segment, so "media" is a sibling of the // catalog broadcast "live". let mut config = video_config(); - config.broadcast = Some(moq_net::PathRelative::new("media").to_owned()); + config.broadcast = Some(moq_net::path::Relative::new("media").to_owned()); let (rendition, watcher) = export(&upstream, &config); tokio::time::timeout(Duration::from_secs(5), rendition.playable()) @@ -1587,7 +1587,7 @@ mod tests { source, }; let mut config = video_config(); - config.broadcast = Some(moq_net::PathRelative::new("media").to_owned()); + config.broadcast = Some(moq_net::path::Relative::new("media").to_owned()); let (rendition, watcher) = export(&upstream, &config); accept_sibling(&old_server, &old_media).await; @@ -1681,7 +1681,7 @@ mod tests { source, }; let mut config = video_config(); - config.broadcast = Some(moq_net::PathRelative::new("media").to_owned()); + config.broadcast = Some(moq_net::path::Relative::new("media").to_owned()); let (rendition, watcher) = export(&upstream, &config); accept_sibling(&server, &media).await; @@ -1723,7 +1723,7 @@ mod tests { source, }; let mut config = video_config(); - config.broadcast = Some(moq_net::PathRelative::new("media").to_owned()); + config.broadcast = Some(moq_net::path::Relative::new("media").to_owned()); let (rendition, watcher) = export(&upstream, &config); accept_sibling(&old_server, &old_media).await; diff --git a/rs/moq-hls/src/export/rendition.rs b/rs/moq-hls/src/export/rendition.rs index b99830eac1..862429c8c4 100644 --- a/rs/moq-hls/src/export/rendition.rs +++ b/rs/moq-hls/src/export/rendition.rs @@ -73,7 +73,7 @@ struct Media { handle: Mutex, /// When set, a Dropped sibling is rebound through this source rather than keeping the /// replaced publisher's rows listed. - sibling: Option<(moq_mux::Source, moq_net::PathRelativeOwned)>, + sibling: Option<(moq_mux::Source, moq_net::path::RelativeOwned)>, } struct Handle { @@ -83,7 +83,7 @@ struct Handle { } impl Media { - fn bind(upstream: &Upstream, rel: Option<&moq_net::PathRelativeOwned>) -> moq_mux::Result { + fn bind(upstream: &Upstream, rel: Option<&moq_net::path::RelativeOwned>) -> moq_mux::Result { Ok(Self { handle: Mutex::new(Handle { binding: Arc::new(upstream.bind(rel)?), @@ -139,15 +139,15 @@ impl Media { /// A catalog `broadcast` reference that names a different path than the catalog itself. fn sibling( upstream: &Upstream, - rel: Option<&moq_net::PathRelativeOwned>, -) -> Option<(moq_mux::Source, moq_net::PathRelativeOwned)> { + rel: Option<&moq_net::path::RelativeOwned>, +) -> Option<(moq_mux::Source, moq_net::path::RelativeOwned)> { let target = upstream.source.resolve_reference(rel)?; if upstream.source.resolve_reference(None).as_ref() == Some(&target) { return None; } Some(( upstream.source.clone(), - rel.cloned().unwrap_or_else(moq_net::PathRelative::empty), + rel.cloned().unwrap_or_else(moq_net::path::Relative::empty), )) } diff --git a/rs/moq-hls/src/export/upstream.rs b/rs/moq-hls/src/export/upstream.rs index 1ae02aec59..9b7affeaf5 100644 --- a/rs/moq-hls/src/export/upstream.rs +++ b/rs/moq-hls/src/export/upstream.rs @@ -24,7 +24,7 @@ impl Upstream { /// and subsequent media fetches reuse that request's result. /// /// Fails when the reference escapes above the origin root and so names no broadcast at all. - pub fn bind(&self, rel: Option<&moq_net::PathRelativeOwned>) -> moq_mux::Result { + pub fn bind(&self, rel: Option<&moq_net::path::RelativeOwned>) -> moq_mux::Result { if self.source.resolve_reference(rel) == self.source.resolve_reference(None) { Ok(moq_mux::Binding::new(self.broadcast.clone())) } else { @@ -55,7 +55,7 @@ mod tests { assert!(!upstream.source.broadcast().await.unwrap().is_closed()); for reference in [None, Some(""), Some("live"), Some("./live"), Some("../a/live")] { - let rel = reference.map(|value| moq_net::PathRelative::new(value).to_owned()); + let rel = reference.map(|value| moq_net::path::Relative::new(value).to_owned()); let bound = upstream.bind(rel.as_ref()).unwrap().broadcast().await.unwrap(); assert!( bound.is_closed(), diff --git a/rs/moq-hls/src/server/routes.rs b/rs/moq-hls/src/server/routes.rs index 72bd73ea7c..03274f3063 100644 --- a/rs/moq-hls/src/server/routes.rs +++ b/rs/moq-hls/src/server/routes.rs @@ -543,7 +543,7 @@ mod tests { let reserved = catalog.reserve(); let mut registration = reserved.video("video0").unwrap(); let mut config = video_config(); - config.broadcast = Some(moq_net::PathRelative::new("../source").to_owned()); + config.broadcast = Some(moq_net::path::Relative::new("../source").to_owned()); registration.set(config).unwrap(); drop(reserved); let recorder = catalog.enroll("video0").unwrap(); diff --git a/rs/moq-mux/src/catalog/hang/consumer.rs b/rs/moq-mux/src/catalog/hang/consumer.rs index b782992deb..42a8a05b85 100644 --- a/rs/moq-mux/src/catalog/hang/consumer.rs +++ b/rs/moq-mux/src/catalog/hang/consumer.rs @@ -152,7 +152,7 @@ mod test { fn referencing(rel: &str) -> hang::catalog::AudioConfig { let mut config = opus(); - config.broadcast = Some(moq_net::PathRelative::new(rel).into_owned()); + config.broadcast = Some(moq_net::path::Relative::new(rel).into_owned()); config } @@ -321,7 +321,7 @@ mod test { published.audio.renditions.insert("here".to_string(), opus()); let mut text = hang::catalog::TextConfig::new(hang::catalog::TextFormat::Vtt); - text.broadcast = Some(moq_net::PathRelative::new("../../../elsewhere").into_owned()); + text.broadcast = Some(moq_net::path::Relative::new("../../../elsewhere").into_owned()); published.text.renditions.insert("captions".to_string(), text); match publish_catalog(published) { @@ -340,7 +340,7 @@ mod test { let mut published = Catalog::<()>::default(); published.audio.renditions.insert("here".to_string(), opus()); - let escaping = moq_net::PathRelative::new("../../../elsewhere").into_owned(); + let escaping = moq_net::path::Relative::new("../../../elsewhere").into_owned(); match section { "json" => { let mut config = hang::catalog::JsonConfig::new(hang::catalog::Mode::Stream); diff --git a/rs/moq-mux/src/container/source.rs b/rs/moq-mux/src/container/source.rs index 9e340fe62c..07a7ba13e4 100644 --- a/rs/moq-mux/src/container/source.rs +++ b/rs/moq-mux/src/container/source.rs @@ -381,7 +381,7 @@ pub(crate) fn build_video_transform(config: &VideoConfig) -> Option) -> AudioConfig { let mut config = AudioConfig::new(AudioCodec::Opus, 48_000, 2); config.container = Container::Legacy; - config.broadcast = broadcast.map(|b| PathRelative::new(b).into_owned()); + config.broadcast = broadcast.map(|b| Relative::new(b).into_owned()); config } diff --git a/rs/moq-mux/src/json.rs b/rs/moq-mux/src/json.rs index a49c8999f2..026c489d88 100644 --- a/rs/moq-mux/src/json.rs +++ b/rs/moq-mux/src/json.rs @@ -455,7 +455,7 @@ mod test { let mut broadcast = moq_net::broadcast::Info::new().produce(); let mut existing = JsonConfig::new(Mode::Snapshot); - existing.broadcast = Some(moq_net::PathRelativeOwned::new("source")); + existing.broadcast = Some(moq_net::path::RelativeOwned::new("source")); let mut seed = crate::catalog::hang::Catalog::<()>::default(); seed.json.tracks.insert("chat".to_string(), existing.clone()); diff --git a/rs/moq-mux/src/source.rs b/rs/moq-mux/src/source.rs index a5fd11a05f..19a1d286db 100644 --- a/rs/moq-mux/src/source.rs +++ b/rs/moq-mux/src/source.rs @@ -69,7 +69,7 @@ impl Source { /// A missing or empty reference returns the catalog broadcast path. A valid reference /// may return the empty root path, which still names a broadcast. `None` means the /// reference walked above the root and names nothing. - pub fn resolve_reference(&self, rel: Option<&moq_net::PathRelative<'_>>) -> Option { + pub fn resolve_reference(&self, rel: Option<&moq_net::path::Relative<'_>>) -> Option { match rel.filter(|rel| !rel.is_empty()) { Some(rel) => self.path.try_resolve(rel), None => Some(self.path.clone()), @@ -80,7 +80,7 @@ impl Source { /// /// The erroring counterpart to [`Self::resolve_reference`], for the consumer side, where /// an escaping reference is a fault to report rather than a rendition to drop. - fn target(&self, rel: Option<&moq_net::PathRelative<'_>>) -> crate::Result { + fn target(&self, rel: Option<&moq_net::path::Relative<'_>>) -> crate::Result { self.resolve_reference(rel).ok_or_else(|| { let rel = rel.map_or("", |rel| rel.as_str()); tracing::error!(rel, catalog = %self.path, "broadcast reference escapes the root"); @@ -99,7 +99,7 @@ impl Source { /// above the origin root, naming no broadcast. pub(crate) fn request( &self, - rel: Option<&moq_net::PathRelative<'_>>, + rel: Option<&moq_net::path::Relative<'_>>, ) -> crate::Result> { Ok(self.origin.request_broadcast(&self.target(rel)?)) } @@ -112,7 +112,7 @@ impl Source { /// the same policy rather than a different one. pub(crate) fn try_request( &self, - rel: Option<&moq_net::PathRelative<'_>>, + rel: Option<&moq_net::path::Relative<'_>>, ) -> Option> { Some(self.origin.request_broadcast(&self.resolve_reference(rel)?)) } @@ -169,7 +169,7 @@ impl Source { /// no broadcast, so there is nothing to resolve. pub async fn resolve( &self, - rel: Option<&moq_net::PathRelative<'_>>, + rel: Option<&moq_net::path::Relative<'_>>, ) -> crate::Result { Ok(self.request(rel)?.await?) } @@ -184,7 +184,7 @@ impl Source { /// when the intended broadcast is already in hand. /// /// Rejects an escaping reference exactly as [`resolve`](Self::resolve) does. - pub fn bind(&self, rel: Option<&moq_net::PathRelative<'_>>) -> crate::Result { + pub fn bind(&self, rel: Option<&moq_net::path::Relative<'_>>) -> crate::Result { Ok(Binding(Bound::Requested(self.request(rel)?.into_inner()))) } @@ -201,7 +201,7 @@ impl Source { /// use it to honor cross-broadcast renditions without reimplementing the path math. pub async fn subscribe_track( &self, - rel: Option<&moq_net::PathRelative<'_>>, + rel: Option<&moq_net::path::Relative<'_>>, name: &str, ) -> crate::Result { let broadcast = self.request(rel)?.await?; @@ -250,35 +250,35 @@ impl Binding { } trait BroadcastConfig { - fn broadcast(&self) -> Option<&moq_net::PathRelativeOwned>; + fn broadcast(&self) -> Option<&moq_net::path::RelativeOwned>; } impl BroadcastConfig for hang::catalog::VideoConfig { - fn broadcast(&self) -> Option<&moq_net::PathRelativeOwned> { + fn broadcast(&self) -> Option<&moq_net::path::RelativeOwned> { self.broadcast.as_ref() } } impl BroadcastConfig for hang::catalog::AudioConfig { - fn broadcast(&self) -> Option<&moq_net::PathRelativeOwned> { + fn broadcast(&self) -> Option<&moq_net::path::RelativeOwned> { self.broadcast.as_ref() } } impl BroadcastConfig for hang::catalog::TextConfig { - fn broadcast(&self) -> Option<&moq_net::PathRelativeOwned> { + fn broadcast(&self) -> Option<&moq_net::path::RelativeOwned> { self.broadcast.as_ref() } } impl BroadcastConfig for hang::catalog::JsonConfig { - fn broadcast(&self) -> Option<&moq_net::PathRelativeOwned> { + fn broadcast(&self) -> Option<&moq_net::path::RelativeOwned> { self.broadcast.as_ref() } } impl BroadcastConfig for hang::catalog::BinaryConfig { - fn broadcast(&self) -> Option<&moq_net::PathRelativeOwned> { + fn broadcast(&self) -> Option<&moq_net::path::RelativeOwned> { self.broadcast.as_ref() } } @@ -320,7 +320,7 @@ pub(crate) fn announced(broadcast: &moq_net::broadcast::Consumer) -> Source { mod tests { use super::*; use hang::catalog::{H264, VideoConfig}; - use moq_net::PathRelative; + use moq_net::path::Relative; /// Let the origin's spawned attach task run: a created broadcast becomes /// routable asynchronously, shortly after `create_broadcast` returns. @@ -388,7 +388,7 @@ mod tests { .expect("no reference is always resolvable") .await .expect("catalog broadcast should resolve"); - let empty = PathRelative::empty(); + let empty = Relative::empty(); source .request(Some(&empty)) .expect("empty reference is always resolvable") @@ -423,7 +423,7 @@ mod tests { let source = Source::new(origin.consume(), "a/pub"); // Names the catalog within its own parent. - let rel = PathRelative::new("./pub"); + let rel = Relative::new("./pub"); source .subscribe_track(Some(&rel), "video") .await @@ -450,7 +450,7 @@ mod tests { // above the root. A lone `..` stops at the root, which still names a broadcast, so // it is not in this set. for reference in ["../../elsewhere", "../..", "../../.."] { - let rel = PathRelative::new(reference); + let rel = Relative::new(reference); assert!( source.resolve_reference(Some(&rel)).is_none(), "{reference} should escape" @@ -482,9 +482,9 @@ mod tests { level: 0x1e, inline: false, }); - escaped.broadcast = Some(PathRelative::new("../../source").to_owned()); + escaped.broadcast = Some(Relative::new("../../source").to_owned()); let mut sibling = escaped.clone(); - sibling.broadcast = Some(PathRelative::new("./source").to_owned()); + sibling.broadcast = Some(Relative::new("./source").to_owned()); let mut catalog = hang::Catalog::default(); catalog.video.renditions.insert("escaped".to_string(), escaped); @@ -504,9 +504,9 @@ mod tests { let source = Source::new(origin.consume(), "a/pub"); let mut escaped = hang::catalog::TextConfig::new(hang::catalog::TextFormat::Vtt); - escaped.broadcast = Some(PathRelative::new("../../source").to_owned()); + escaped.broadcast = Some(Relative::new("../../source").to_owned()); let mut sibling = escaped.clone(); - sibling.broadcast = Some(PathRelative::new("./source").to_owned()); + sibling.broadcast = Some(Relative::new("./source").to_owned()); let mut catalog = hang::Catalog::default(); catalog.text.renditions.insert("escaped".to_string(), escaped); @@ -532,7 +532,7 @@ mod tests { let source = Source::new(origin.consume(), "a/pub"); // The reference resolves to `a/source`, whose "video" track answers the subscribe. - let rel = PathRelative::new("./source"); + let rel = Relative::new("./source"); source .subscribe_track(Some(&rel), "video") .await @@ -552,7 +552,7 @@ mod tests { settle().await; let source = Source::new(origin.consume(), "a/source/transcode"); - let rel = PathRelative::new("."); + let rel = Relative::new("."); source .subscribe_track(Some(&rel), "video") .await @@ -572,7 +572,7 @@ mod tests { settle().await; let source = Source::new(origin.consume(), "top"); - let rel = PathRelative::new("."); + let rel = Relative::new("."); source .subscribe_track(Some(&rel), "video") .await diff --git a/rs/moq-net/src/fuzz.rs b/rs/moq-net/src/fuzz.rs index 3b5410e306..7dac710949 100644 --- a/rs/moq-net/src/fuzz.rs +++ b/rs/moq-net/src/fuzz.rs @@ -13,9 +13,10 @@ use bytes::Buf; use crate::{ - Path, PathRelative, Pattern, + Path, Pattern, coding::{Decode, Encode, VarInt}, ietf, lite, + path::Relative, }; /// One fuzz target body: it returns whether the input decoded, which is what @@ -307,7 +308,7 @@ pub fn path(data: &[u8]) -> bool { // Resolving arbitrary references must stay inside the clamped/unclamped contract: // `try_resolve` only refuses by walking above the root, so whenever it answers, it // answers the same as `resolve`. - let rel = PathRelative::new(base.as_str()); + let rel = Relative::new(base.as_str()); if let Some(resolved) = target.try_resolve(&rel) { assert_eq!(resolved, target.resolve(&rel), "try_resolve disagreed with resolve"); } diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 027d9bb214..3a57e8c327 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1826,7 +1826,7 @@ where return stream.writer.closed().await; } NamespaceEvent::Update(Some(update)) => { - let path = update.path; + let path = update.prefix; let suffix = path .strip_prefix(&prefix) .expect("origin returned invalid prefix") diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index a4ddb6e0db..0fdd33dd92 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -102,9 +102,7 @@ pub use error::*; /// The session direction a client advertises in its SETUP (moq-lite-05+). pub use lite::Role; pub use model::*; -pub use path::{ - AsPath, InvalidPattern, Path, PathOwned, PathPrefixes, PathRelative, PathRelativeOwned, Pattern, Patterns, -}; +pub use path::{AsPath, InvalidPattern, Path, PathOwned, Pattern, Patterns}; pub use server::Server; pub use session::Session; pub use version::*; diff --git a/rs/moq-net/src/lite/publisher.rs b/rs/moq-net/src/lite/publisher.rs index 148d3bf4d9..d13020aed8 100644 --- a/rs/moq-net/src/lite/publisher.rs +++ b/rs/moq-net/src/lite/publisher.rs @@ -602,11 +602,11 @@ impl AnnounceRun { } } - /// Where an update travels on this stream: its path relative to the requested + /// Where an update travels on this stream: its prefix relative to the requested /// prefix, which the origin's scope guarantees it sits under. fn suffix(&self, update: &announce::Update) -> crate::PathOwned { update - .path + .prefix .strip_prefix(&self.prefix) .expect("origin returned a route outside the requested prefix") .to_owned() @@ -689,7 +689,7 @@ impl AnnounceRun { // Send ANNOUNCE_INIT as the first message with all currently active routes. // We use `try_next()` to synchronously get the initial updates. while let Some(update) = announced.try_next() { - let absolute = origin.absolute(&update.path); + let absolute = origin.absolute(&update.prefix); let suffix = self.suffix(&update); if update.kind.is_active() { @@ -717,7 +717,7 @@ impl AnnounceRun { // forward the stored chain as-is (no self push here). let mut initial: Vec<(crate::PathOwned, Hops, crate::origin::Cost)> = Vec::new(); while let Some(update) = announced.try_next() { - let absolute = origin.absolute(&update.path); + let absolute = origin.absolute(&update.prefix); let suffix = self.suffix(&update); if update.kind.is_active() { @@ -796,7 +796,7 @@ impl AnnounceRun { continue; }; - let absolute = origin.absolute(&update.path); + let absolute = origin.absolute(&update.prefix); let suffix = self.suffix(&update); if !update.kind.is_active() { diff --git a/rs/moq-net/src/model/origin.rs b/rs/moq-net/src/model/origin.rs index 7ba2e9b607..13da4958e7 100644 --- a/rs/moq-net/src/model/origin.rs +++ b/rs/moq-net/src/model/origin.rs @@ -668,7 +668,7 @@ impl OriginConsumerState { } }; Some(AnnounceUpdate { - path: prefix, + prefix, captures, route: Route { hops: meta.0, @@ -1026,21 +1026,22 @@ impl AnnounceKind { /// A route announcement, update, or retraction, delivered by [`AnnounceConsumer`]. /// -/// An announcement carries no broadcast: it advertises that [`path`](Self::path) -/// and every path beneath it are servable. Resolve a specific path with +/// An announcement is always a prefix, never a broadcast: it advertises that +/// [`prefix`](Self::prefix) and every path beneath it are servable. A broadcast +/// announces its own path, so the prefix usually names one, but resolve it with /// [`Consumer::request_broadcast`]; the application decides which paths name /// broadcasts, and filters with a [`Pattern`] locally when it wants a subset. #[derive(Clone, Debug)] pub struct AnnounceUpdate { /// The prefix the route covers, relative to the consuming cursor's root. - pub path: PathOwned, + pub prefix: PathOwned, /// What the scope's wildcards stood for when the announced prefix pins all of /// them. `None` for an overlap-only route or a scope without a complete match. pub captures: Option>, /// The route serving the prefix. On a retraction this carries its last /// advertised metadata. pub route: Route, - /// Whether the path was announced, re-priced, or retracted. + /// Whether the prefix was announced, re-priced, or retracted. pub kind: AnnounceKind, } @@ -3532,7 +3533,7 @@ impl Consumer { let mut announced = consumer.untagged().announced(); loop { let update = announced.next().await?; - if update.kind.is_active() && path.has_prefix(&update.path) { + if update.kind.is_active() && path.has_prefix(&update.prefix) { return Some(update.route); } } @@ -3816,14 +3817,14 @@ impl AnnounceConsumer { /// Drive the egress announce guards for one update. fn hand_out(&mut self, update: AnnounceUpdate) -> AnnounceUpdate { - let absolute = self.root.join(&update.path).to_owned(); + let absolute = self.root.join(&update.prefix).to_owned(); if update.kind.is_active() { let scope = self.stats.egress(&absolute); self.guards - .entry(update.path.clone()) + .entry(update.prefix.clone()) .or_insert_with(|| scope.announce()); } else { - self.guards.remove(&update.path); + self.guards.remove(&update.prefix); } update } @@ -3885,14 +3886,14 @@ impl AnnounceConsumer { state.is_closed() || state.ended } - /// Returns the prefix that is automatically stripped from emitted paths. + /// Returns the root that is automatically stripped from emitted prefixes. pub fn root(&self) -> &Path<'_> { &self.root } - /// Converts a relative path to an absolute path. - pub fn absolute(&self, path: impl AsPath) -> Path<'_> { - self.root.join(path) + /// Converts an emitted prefix back to one rooted at the origin. + pub fn absolute(&self, prefix: impl AsPath) -> Path<'_> { + self.root.join(prefix) } } @@ -3922,7 +3923,7 @@ impl AnnounceConsumer { pub fn assert_next_active(&mut self, expected: impl AsPath) -> Route { let expected = expected.as_path(); let update = self.next().now_or_never().expect("next blocked").expect("no next"); - assert_eq!(update.path, expected, "wrong prefix"); + assert_eq!(update.prefix, expected, "wrong prefix"); assert!(update.kind.is_active(), "should be an active route"); update.route } @@ -3931,7 +3932,7 @@ impl AnnounceConsumer { pub fn assert_try_next_active(&mut self, expected: impl AsPath) -> Route { let expected = expected.as_path(); let update = self.try_next().expect("no next"); - assert_eq!(update.path, expected, "wrong prefix"); + assert_eq!(update.prefix, expected, "wrong prefix"); assert!(update.kind.is_active(), "should be an active route"); update.route } @@ -3940,13 +3941,13 @@ impl AnnounceConsumer { pub fn assert_next_ended(&mut self, expected: impl AsPath) { let expected = expected.as_path(); let update = self.next().now_or_never().expect("next blocked").expect("no next"); - assert_eq!(update.path, expected, "wrong prefix"); + assert_eq!(update.prefix, expected, "wrong prefix"); assert_eq!(update.kind, AnnounceKind::Retracted, "should be a retraction"); } pub fn assert_next_wait(&mut self) { if let Some(res) = self.next().now_or_never() { - panic!("next should block: got {:?}", res.map(|u| u.path)); + panic!("next should block: got {:?}", res.map(|u| u.prefix)); } } } @@ -4471,17 +4472,17 @@ mod tests { let mut announced = consumer.announced(); let first = announced.next().now_or_never().expect("next").expect("announce"); - assert_eq!(first.path.as_str(), ""); + assert_eq!(first.prefix.as_str(), ""); assert_eq!(first.kind, AnnounceKind::Announced); assert_eq!(first.captures, Some(Vec::new())); drop(exact); let retracted = announced.next().now_or_never().expect("next").expect("retract"); - assert_eq!(retracted.path.as_str(), ""); + assert_eq!(retracted.prefix.as_str(), ""); assert_eq!(retracted.kind, AnnounceKind::Retracted); assert_eq!(retracted.captures, Some(Vec::new())); let replacement = announced.next().now_or_never().expect("next").expect("announce"); - assert_eq!(replacement.path.as_str(), ""); + assert_eq!(replacement.prefix.as_str(), ""); assert_eq!(replacement.kind, AnnounceKind::Announced); assert_eq!(replacement.captures, None); } @@ -4549,14 +4550,14 @@ mod tests { let mut announced = producer.consume().announced(); let update = announced.next().now_or_never().expect("next").expect("no next"); - assert_eq!(update.path.as_str(), "live"); + assert_eq!(update.prefix.as_str(), "live"); assert_eq!(update.kind, AnnounceKind::Announced); assert_eq!(update.route.cost, Cost::new(1)); announced.assert_next_wait(); drop(second); let update = announced.next().now_or_never().expect("next").expect("no next"); - assert_eq!(update.path.as_str(), "live"); + assert_eq!(update.prefix.as_str(), "live"); assert_eq!(update.kind, AnnounceKind::Updated); assert_eq!(update.route.cost, Cost::new(3)); @@ -4657,7 +4658,7 @@ mod tests { .now_or_never() .expect("next") .expect("no next"); - assert_eq!(update.path.as_str(), "live"); + assert_eq!(update.prefix.as_str(), "live"); assert_eq!(update.kind, AnnounceKind::Announced); assert!(StreamExt::next(&mut announced).now_or_never().is_none()); drop(server); @@ -5838,7 +5839,7 @@ mod tests { let alice = producer.create_broadcast("room/alice/chat").unwrap(); alice.announce(Route::default()).unwrap(); let update = announced.try_next().expect("alice's chat"); - assert_eq!(update.path.as_str(), "room/alice/chat"); + assert_eq!(update.prefix.as_str(), "room/alice/chat"); assert_eq!(update.captures, Some(vec!["alice".parse::().unwrap()])); let audio = producer.create_broadcast("room/alice/audio").unwrap(); @@ -5847,7 +5848,7 @@ mod tests { let broad = producer.announce("room", Route::default()).unwrap(); let update = announced.try_next().expect("overlapping broad route"); - assert_eq!(update.path.as_str(), "room"); + assert_eq!(update.prefix.as_str(), "room"); assert_eq!(update.captures, None, "an overlap does not pin the wildcard"); drop(broad); @@ -5864,7 +5865,7 @@ mod tests { let mut announced = producer.consume().announced(); let update = announced.try_next().expect("one winning route"); - assert_eq!(update.path.as_str(), "room/alice"); + assert_eq!(update.prefix.as_str(), "room/alice"); assert_eq!(update.route.cost, Cost::default()); announced.assert_next_wait(); diff --git a/rs/moq-net/src/path/mod.rs b/rs/moq-net/src/path/mod.rs index 5c6bdca25a..6d92ad323e 100644 --- a/rs/moq-net/src/path/mod.rs +++ b/rs/moq-net/src/path/mod.rs @@ -76,8 +76,10 @@ enum Repr<'a> { /// delimiter boundaries, preventing issues like "foo" matching "foobar". /// /// Paths are automatically trimmed of leading and trailing slashes on creation, -/// making all slashes implicit at boundaries. -/// All paths are RELATIVE; you cannot join with a leading slash to make an absolute path. +/// making all slashes implicit at boundaries. A path names a point in the origin's +/// tree from its root; a leading slash never escapes that root. The same type names +/// an exact broadcast and the prefix a route or announcement covers, so a broadcast's +/// own path is the prefix it announces. See [`Relative`] for `..`-style references. /// /// Owned paths ([`PathOwned`]) share one reference-counted allocation: cloning, converting /// a shared path with [`Path::to_owned`], and suffix operations like [`Path::strip_prefix`] @@ -345,26 +347,26 @@ impl<'a> Path<'a> { } } - /// Resolve a [`PathRelative`] against this path. + /// Resolve a [`Relative`] against this path. /// /// A non-empty reference replaces the last segment of the base, matching relative URL /// resolution. `..` segments then pop another segment; other segments are appended. /// Excess `..` is a no-op once the base is empty (subsequent named segments still append). /// An empty `rel` returns this path as an owned copy. /// - /// [`PathRelative::new`] strips empty and redundant `.` segments, but preserves a lone `.` + /// [`Relative::new`] strips empty and redundant `.` segments, but preserves a lone `.` /// so it can reference the base's parent. /// /// # Examples /// ``` - /// use moq_net::{Path, PathRelative}; + /// use moq_net::{Path, path::Relative}; /// /// let base = Path::new("a/b/c"); - /// assert_eq!(base.resolve(&PathRelative::new("./d")).as_str(), "a/b/d"); - /// assert_eq!(base.resolve(&PathRelative::new(".")).as_str(), "a/b"); - /// assert_eq!(base.resolve(&PathRelative::new("../d")).as_str(), "a/d"); + /// assert_eq!(base.resolve(&Relative::new("./d")).as_str(), "a/b/d"); + /// assert_eq!(base.resolve(&Relative::new(".")).as_str(), "a/b"); + /// assert_eq!(base.resolve(&Relative::new("../d")).as_str(), "a/d"); /// ``` - pub fn resolve(&self, rel: &PathRelative<'_>) -> PathOwned { + pub fn resolve(&self, rel: &Relative<'_>) -> PathOwned { if rel.is_empty() { return self.to_owned(); } @@ -393,12 +395,12 @@ impl<'a> Path<'a> { } } - /// Resolve a [`PathRelative`], returning `None` if it escapes above the root. + /// Resolve a [`Relative`], returning `None` if it escapes above the root. /// /// Unlike [`Path::resolve`], this distinguishes a valid reference to the empty root /// path from excess `..` segments. Use it when an untrusted relative reference must /// not be clamped to the root. - pub fn try_resolve(&self, rel: &PathRelative<'_>) -> Option { + pub fn try_resolve(&self, rel: &Relative<'_>) -> Option { if rel.is_empty() { return Some(self.to_owned()); } @@ -460,13 +462,13 @@ impl<'a> Path<'a> { /// // A segment named `..` is a legal path component but an unnameable target. /// assert!(Path::new("a/..").relative(&base).is_none()); /// ``` - pub fn relative(&self, base: impl AsPath) -> Option { + pub fn relative(&self, base: impl AsPath) -> Option { let base = base.as_path(); // Only the empty reference can name a base whose last segment is itself `.` or `..`, // since resolution replaces that segment rather than emitting it. if *self == base { - return Some(PathRelative::empty()); + return Some(Relative::empty()); } // Resolution replaces the base's last segment, so walk from its parent. @@ -487,10 +489,10 @@ impl<'a> Path<'a> { if rel.is_empty() { // An empty reference resolves to the base itself, so name the parent explicitly. - return Some(PathRelative::new(".")); + return Some(Relative::new(".")); } - Some(PathRelativeOwned::from(rel.join("/"))) + Some(RelativeOwned::from(rel.join("/"))) } } @@ -597,17 +599,17 @@ where } } -/// An owned version of [`PathRelative`] with a `'static` lifetime. -pub type PathRelativeOwned = PathRelative<'static>; +/// An owned version of [`Relative`] with a `'static` lifetime. +pub type RelativeOwned = Relative<'static>; /// A relative broadcast path, used to reference one broadcast from another broadcast's content. /// /// Unlike [`Path`] (which is a complete reference within the broadcast namespace), -/// `PathRelative` may contain `.` and `..` segments to walk the namespace and is meaningful +/// `Relative` may contain `.` and `..` segments to walk the namespace and is meaningful /// only when resolved against a base [`Path`] via [`Path::resolve`]. The hang catalog uses it /// to point a rendition at a track published in a sibling broadcast (e.g. `./source`). /// -/// `PathRelative` has no `Encode`/`Decode` impl, so it never appears in announce/subscribe +/// `Relative` has no `Encode`/`Decode` impl, so it never appears in announce/subscribe /// frames. It does serialize via serde for off-wire use (e.g. as a field inside a catalog /// JSON payload, which itself travels as a track). /// @@ -618,20 +620,20 @@ pub type PathRelativeOwned = PathRelative<'static>; /// /// # Examples /// ``` -/// use moq_net::{Path, PathRelative}; +/// use moq_net::{Path, path::Relative}; /// -/// let rel = PathRelative::new("./source"); +/// let rel = Relative::new("./source"); /// assert_eq!(Path::new("a/b").resolve(&rel).as_str(), "a/source"); /// /// // Redundant `.` segments are stripped on creation. -/// assert_eq!(PathRelative::new("./a/./b").as_str(), "a/b"); -/// assert_eq!(PathRelative::new(".").as_str(), "."); +/// assert_eq!(Relative::new("./a/./b").as_str(), "a/b"); +/// assert_eq!(Relative::new(".").as_str(), "."); /// ``` #[derive(Debug, PartialEq, Eq, Hash, Clone, serde::Serialize)] -pub struct PathRelative<'a>(Cow<'a, str>); +pub struct Relative<'a>(Cow<'a, str>); -impl<'a> PathRelative<'a> { - /// Create a new `PathRelative` from a string slice. +impl<'a> Relative<'a> { + /// Create a new `Relative` from a string slice. /// /// Leading and trailing slashes are trimmed, consecutive internal slashes collapse to one, /// and redundant `.` segments are stripped. See the type-level doc for the full rules. @@ -651,8 +653,8 @@ impl<'a> PathRelative<'a> { } /// The empty relative path, which resolves to the base path itself. - pub fn empty() -> PathRelative<'static> { - PathRelative(Cow::Borrowed("")) + pub fn empty() -> Relative<'static> { + Relative(Cow::Borrowed("")) } /// True if the path is empty (resolves to the base path itself). @@ -666,34 +668,34 @@ impl<'a> PathRelative<'a> { } /// Copy into an owned version with a `'static` lifetime. - pub fn to_owned(&self) -> PathRelativeOwned { - PathRelative(Cow::Owned(self.0.to_string())) + pub fn to_owned(&self) -> RelativeOwned { + Relative(Cow::Owned(self.0.to_string())) } /// Convert into an owned version with a `'static` lifetime. - pub fn into_owned(self) -> PathRelativeOwned { - PathRelative(Cow::Owned(self.0.into_owned())) + pub fn into_owned(self) -> RelativeOwned { + Relative(Cow::Owned(self.0.into_owned())) } /// Reborrow without copying. - pub fn borrow(&'a self) -> PathRelative<'a> { - PathRelative(Cow::Borrowed(&self.0)) + pub fn borrow(&'a self) -> Relative<'a> { + Relative(Cow::Borrowed(&self.0)) } } -impl<'a> From<&'a str> for PathRelative<'a> { +impl<'a> From<&'a str> for Relative<'a> { fn from(s: &'a str) -> Self { Self::new(s) } } -impl<'a> From<&'a String> for PathRelative<'a> { +impl<'a> From<&'a String> for Relative<'a> { fn from(s: &'a String) -> Self { Self::new(s) } } -impl From for PathRelative<'_> { +impl From for Relative<'_> { fn from(s: String) -> Self { let trimmed = s.trim_start_matches('/').trim_end_matches('/'); @@ -725,19 +727,19 @@ fn normalize_relative_segments(trimmed: &str) -> String { } } -impl Default for PathRelative<'_> { +impl Default for Relative<'_> { fn default() -> Self { Self(Cow::Borrowed("")) } } -impl AsRef for PathRelative<'_> { +impl AsRef for Relative<'_> { fn as_ref(&self) -> &str { &self.0 } } -impl Display for PathRelative<'_> { +impl Display for Relative<'_> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } @@ -746,132 +748,13 @@ impl Display for PathRelative<'_> { // Owned-only deserialization. We use `String::deserialize` so that owned deserializers // (e.g. `serde_json::from_slice`) work. The borrowed form `<&str>::deserialize` requires // `'de: 'a`, which is unsatisfiable when `'a = 'static`. -impl<'de> serde::Deserialize<'de> for PathRelative<'static> { +impl<'de> serde::Deserialize<'de> for Relative<'static> { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; - Ok(PathRelative::from(s)) - } -} - -/// A deduplicated list of path prefixes. -/// -/// Automatically removes exact duplicates and overlapping prefixes on construction. -/// For example, `["demo", "demo/foo", "anon"]` becomes `["demo", "anon"]` since -/// `"demo"` already covers `"demo/foo"`. -#[derive(Debug, Clone, Default, Eq)] -pub struct PathPrefixes { - paths: Vec, -} - -impl PathPrefixes { - /// Create a new PathPrefixes, deduplicating and removing overlapping prefixes. - /// - /// Shorter prefixes subsume longer ones: `"demo"` covers `"demo/foo"`. - /// - /// Accepts anything iterable over path-like items: - /// ``` - /// use moq_net::PathPrefixes; - /// - /// let list = PathPrefixes::new(["demo", "demo/foo", "anon"]); - /// assert_eq!(list.len(), 2); // "demo/foo" subsumed by "demo" - /// ``` - pub fn new(paths: impl IntoIterator) -> Self { - let mut paths: Vec = paths.into_iter().map(|p| p.as_path().to_owned()).collect(); - - if paths.len() <= 1 { - return Self { paths }; - } - - // Sort by length so shorter (more permissive) prefixes come first. - // Tie-break lexicographically for canonical ordering. - paths.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.as_str().cmp(b.as_str()))); - paths.dedup(); - - let mut result: Vec = Vec::new(); - 'outer: for path in paths { - for existing in &result { - if path.has_prefix(existing) { - continue 'outer; - } - } - result.push(path); - } - - Self { paths: result } - } - - /// Returns `true` if the set contains no prefixes, so it matches nothing. - pub fn is_empty(&self) -> bool { - self.paths.is_empty() - } - - /// The number of prefixes, after redundant ones were collapsed. - pub fn len(&self) -> usize { - self.paths.len() - } - - /// Iterate the prefixes in the set. - pub fn iter(&self) -> std::slice::Iter<'_, PathOwned> { - self.paths.iter() - } -} - -impl std::ops::Deref for PathPrefixes { - type Target = [PathOwned]; - - fn deref(&self) -> &[PathOwned] { - &self.paths - } -} - -impl FromIterator for PathPrefixes { - fn from_iter>(iter: I) -> Self { - Self::new(iter) - } -} - -impl From> for PathPrefixes { - fn from(paths: Vec) -> Self { - Self::new(paths) - } -} - -impl<'a> PartialEq>> for PathPrefixes { - fn eq(&self, other: &Vec>) -> bool { - self.paths == *other - } -} - -impl<'a> PartialEq for Vec> { - fn eq(&self, other: &PathPrefixes) -> bool { - *self == other.paths - } -} - -impl PartialEq for PathPrefixes { - fn eq(&self, other: &Self) -> bool { - self.paths == other.paths - } -} - -impl IntoIterator for PathPrefixes { - type Item = PathOwned; - type IntoIter = std::vec::IntoIter; - - fn into_iter(self) -> Self::IntoIter { - self.paths.into_iter() - } -} - -impl<'a> IntoIterator for &'a PathPrefixes { - type Item = &'a PathOwned; - type IntoIter = std::slice::Iter<'a, PathOwned>; - - fn into_iter(self) -> Self::IntoIter { - self.paths.iter() + Ok(Relative::from(s)) } } @@ -1312,89 +1195,6 @@ mod tests { assert_eq!(prefix.strip_prefix("foo/bar/").unwrap().as_str(), ""); } - #[test] - fn test_prefix_list_dedup() { - // Exact duplicates are removed - let list = PathPrefixes::new(["demo", "demo"]); - assert_eq!(list.len(), 1); - assert_eq!(list[0], Path::new("demo")); - } - - #[test] - fn test_prefix_list_overlap() { - // "demo/foo" is redundant when "demo" exists - let list = PathPrefixes::new(["demo", "demo/foo", "anon"]); - assert_eq!(list.len(), 2); - assert!(list.iter().any(|p| p == &Path::new("demo"))); - assert!(list.iter().any(|p| p == &Path::new("anon"))); - } - - #[test] - fn test_prefix_list_overlap_reverse_order() { - // Order shouldn't matter - let list = PathPrefixes::new(["demo/foo", "demo"]); - assert_eq!(list.len(), 1); - assert_eq!(list[0], Path::new("demo")); - } - - #[test] - fn test_prefix_list_empty_covers_all() { - // Empty prefix covers everything - let list = PathPrefixes::new(["", "demo", "anon"]); - assert_eq!(list.len(), 1); - assert_eq!(list[0], Path::new("")); - } - - #[test] - fn test_prefix_list_no_overlap() { - // Unrelated prefixes are all kept - let list = PathPrefixes::new(["demo", "anon", "secret"]); - assert_eq!(list.len(), 3); - } - - #[test] - fn test_prefix_list_single() { - let list = PathPrefixes::new(["demo"]); - assert_eq!(list.len(), 1); - } - - #[test] - fn test_prefix_list_empty() { - let list = PathPrefixes::new(std::iter::empty::<&str>()); - assert!(list.is_empty()); - assert_eq!(list.len(), 0); - } - - #[test] - fn test_prefix_list_deep_overlap() { - // "a/b/c" is covered by "a/b" which is covered by "a" - let list = PathPrefixes::new(["a/b/c", "a/b", "a"]); - assert_eq!(list.len(), 1); - assert_eq!(list[0], Path::new("a")); - } - - #[test] - fn test_prefix_list_partial_name_not_overlap() { - // "demo" should NOT cover "demonstration" (different path component) - let list = PathPrefixes::new(["demo", "demonstration"]); - assert_eq!(list.len(), 2); - } - - #[test] - fn test_prefix_list_collect() { - let paths: Vec = vec!["demo".into(), "demo/foo".into()]; - let list: PathPrefixes = paths.into_iter().collect(); - assert_eq!(list.len(), 1); - assert_eq!(list[0], Path::new("demo")); - } - - #[test] - fn test_prefix_list_eq_vec() { - let list = PathPrefixes::new(["demo", "anon"]); - // Canonical order: sorted by length, then lexicographically - assert_eq!(list, vec!["anon".as_path(), "demo".as_path()]); - } - // Pointer-equality checks that owned paths share one allocation through the // clone / to_owned / strip_prefix flow used by origin announce fan-out. #[test] @@ -1477,44 +1277,36 @@ mod tests { assert!(rest.is_empty()); } - #[test] - fn test_prefix_list_canonical_order() { - // Same inputs in different order produce identical results - let a = PathPrefixes::new(["foo", "bar"]); - let b = PathPrefixes::new(["bar", "foo"]); - assert_eq!(a, b); - } - #[test] fn test_path_relative_normalize() { - assert_eq!(PathRelative::new("foo").as_str(), "foo"); - assert_eq!(PathRelative::new("/foo/").as_str(), "foo"); - assert_eq!(PathRelative::new("foo//bar").as_str(), "foo/bar"); - assert_eq!(PathRelative::new("../foo").as_str(), "../foo"); - assert_eq!(PathRelative::new("../../a/b").as_str(), "../../a/b"); - assert!(PathRelative::new("").is_empty()); + assert_eq!(Relative::new("foo").as_str(), "foo"); + assert_eq!(Relative::new("/foo/").as_str(), "foo"); + assert_eq!(Relative::new("foo//bar").as_str(), "foo/bar"); + assert_eq!(Relative::new("../foo").as_str(), "../foo"); + assert_eq!(Relative::new("../../a/b").as_str(), "../../a/b"); + assert!(Relative::new("").is_empty()); } #[test] fn test_path_relative_normalizes_dot_segments() { - assert_eq!(PathRelative::new(".").as_str(), "."); - assert_eq!(PathRelative::new("././").as_str(), "."); - assert_eq!(PathRelative::new("./foo").as_str(), "foo"); - assert_eq!(PathRelative::new("foo/./bar").as_str(), "foo/bar"); - assert_eq!(PathRelative::new("./../foo").as_str(), "../foo"); + assert_eq!(Relative::new(".").as_str(), "."); + assert_eq!(Relative::new("././").as_str(), "."); + assert_eq!(Relative::new("./foo").as_str(), "foo"); + assert_eq!(Relative::new("foo/./bar").as_str(), "foo/bar"); + assert_eq!(Relative::new("./../foo").as_str(), "../foo"); // From takes the same normalization. - assert_eq!(PathRelative::from("./foo".to_string()).as_str(), "foo"); - assert_eq!(PathRelative::from(".".to_string()).as_str(), "."); + assert_eq!(Relative::from("./foo".to_string()).as_str(), "foo"); + assert_eq!(Relative::from(".".to_string()).as_str(), "."); } #[test] fn test_resolve_replaces_base_name() { let base = Path::new("a/b"); - assert_eq!(base.resolve(&PathRelative::new("c")).as_str(), "a/c"); - assert_eq!(base.resolve(&PathRelative::new("c/d")).as_str(), "a/c/d"); + assert_eq!(base.resolve(&Relative::new("c")).as_str(), "a/c"); + assert_eq!(base.resolve(&Relative::new("c/d")).as_str(), "a/c/d"); assert_eq!( Path::new("foo.hang/catalog.pro") - .resolve(&PathRelative::new("./transcode.pro")) + .resolve(&Relative::new("./transcode.pro")) .as_str(), "foo.hang/transcode.pro" ); @@ -1523,44 +1315,44 @@ mod tests { #[test] fn test_resolve_empty_rel_returns_base() { let base = Path::new("a/b"); - assert_eq!(base.resolve(&PathRelative::new("")).as_str(), "a/b"); + assert_eq!(base.resolve(&Relative::new("")).as_str(), "a/b"); } #[test] fn test_resolve_single_dotdot() { let base = Path::new("a/b/c"); - assert_eq!(base.resolve(&PathRelative::new("../d")).as_str(), "a/d"); - assert_eq!(base.resolve(&PathRelative::new("..")).as_str(), "a"); + assert_eq!(base.resolve(&Relative::new("../d")).as_str(), "a/d"); + assert_eq!(base.resolve(&Relative::new("..")).as_str(), "a"); } #[test] fn test_resolve_multiple_dotdot() { let base = Path::new("a/b/c"); - assert_eq!(base.resolve(&PathRelative::new("../../x")).as_str(), "x"); - assert_eq!(base.resolve(&PathRelative::new("../../../x")).as_str(), "x"); + assert_eq!(base.resolve(&Relative::new("../../x")).as_str(), "x"); + assert_eq!(base.resolve(&Relative::new("../../../x")).as_str(), "x"); } #[test] fn test_resolve_dotdot_clamps_at_root() { let base = Path::new("a"); // Excess `..` clamps at the root instead of escaping it. - assert_eq!(base.resolve(&PathRelative::new("../../../foo")).as_str(), "foo"); - assert_eq!(base.resolve(&PathRelative::new("..")).as_str(), ""); + assert_eq!(base.resolve(&Relative::new("../../../foo")).as_str(), "foo"); + assert_eq!(base.resolve(&Relative::new("..")).as_str(), ""); } #[test] fn test_resolve_empty_base() { let base = Path::empty(); - assert_eq!(base.resolve(&PathRelative::new("foo")).as_str(), "foo"); - assert_eq!(base.resolve(&PathRelative::new("..")).as_str(), ""); + assert_eq!(base.resolve(&Relative::new("foo")).as_str(), "foo"); + assert_eq!(base.resolve(&Relative::new("..")).as_str(), ""); } #[test] fn test_resolve_dot_names_parent() { let base = Path::new("a/b"); - assert_eq!(base.resolve(&PathRelative::new(".")).as_str(), "a"); - assert_eq!(base.resolve(&PathRelative::new("./c")).as_str(), "a/c"); - assert_eq!(base.resolve(&PathRelative::new("./../c")).as_str(), "c"); + assert_eq!(base.resolve(&Relative::new(".")).as_str(), "a"); + assert_eq!(base.resolve(&Relative::new("./c")).as_str(), "a/c"); + assert_eq!(base.resolve(&Relative::new("./../c")).as_str(), "c"); } #[test] @@ -1568,18 +1360,18 @@ mod tests { // Naming the base within its parent yields the base unchanged, which lets the // caller compare resolved == base to detect a self-reference. let base = Path::new("a/b"); - assert_eq!(base.resolve(&PathRelative::new("./b")).as_str(), "a/b"); + assert_eq!(base.resolve(&Relative::new("./b")).as_str(), "a/b"); } #[test] fn test_try_resolve_distinguishes_root_from_escape() { let base = Path::new("top"); - assert_eq!(base.try_resolve(&PathRelative::new(".")).unwrap().as_str(), ""); - assert!(base.try_resolve(&PathRelative::new("..")).is_none()); + assert_eq!(base.try_resolve(&Relative::new(".")).unwrap().as_str(), ""); + assert!(base.try_resolve(&Relative::new("..")).is_none()); let nested = Path::new("a/b"); - assert_eq!(nested.try_resolve(&PathRelative::new("..")).unwrap().as_str(), ""); - assert!(nested.try_resolve(&PathRelative::new("../..")).is_none()); + assert_eq!(nested.try_resolve(&Relative::new("..")).unwrap().as_str(), ""); + assert!(nested.try_resolve(&Relative::new("../..")).is_none()); } #[test] diff --git a/rs/moq-net/tests/goaway.rs b/rs/moq-net/tests/goaway.rs index 20bd7b6219..a04bac3b72 100644 --- a/rs/moq-net/tests/goaway.rs +++ b/rs/moq-net/tests/goaway.rs @@ -445,7 +445,7 @@ async fn goaway_drains_routes(version: Version) { loop { let update = announced.next().await.expect("update"); if update.kind.is_active() - && update.path.as_str() == "test" + && update.prefix.as_str() == "test" && update.route.cost == moq_net::origin::Cost::DRAIN { break; diff --git a/rs/moq-relay/src/cluster.rs b/rs/moq-relay/src/cluster.rs index ed54bd3977..a1609ef9b2 100644 --- a/rs/moq-relay/src/cluster.rs +++ b/rs/moq-relay/src/cluster.rs @@ -1581,7 +1581,7 @@ impl Cluster { tokio::select! { ann = announced.next() => { let Some(update) = ann else { return; }; - let relative = update.path; + let relative = update.prefix; // The address to dial, which keeps its query: `run_remote` reads // `?cost=` and `?jwt=` off it. The key is only its identity. let peer = advertised_node_url(relative.as_str()); @@ -3004,7 +3004,7 @@ mod tests { // The self-registration route must be visible on the origin. let update = watcher.try_next().expect("self-registration must be published"); - assert_eq!(update.path.as_str(), ".internal/origins/rendezvous.example.com:4443"); + assert_eq!(update.prefix.as_str(), ".internal/origins/rendezvous.example.com:4443"); assert!(update.kind.is_active()); // run() must NOT have returned: dropping the broadcast (via run returning) @@ -3588,7 +3588,7 @@ mod tests { .await .expect("timed out waiting for from-node") .expect("origin closed"); - assert_eq!(update.path.as_str(), "from-node"); + assert_eq!(update.prefix.as_str(), "from-node"); let _from_fp = fingerprint.origin.create_broadcast("from-fingerprint").expect("create"); _from_fp.announce(Default::default()).expect("announce"); @@ -3598,7 +3598,7 @@ mod tests { .await .expect("timed out waiting for from-fingerprint") .expect("origin closed"); - if update.path.as_str() == "from-fingerprint" { + if update.prefix.as_str() == "from-fingerprint" { break; } } diff --git a/rs/moq-relay/src/internal.rs b/rs/moq-relay/src/internal.rs index de9d988cd6..a186d38768 100644 --- a/rs/moq-relay/src/internal.rs +++ b/rs/moq-relay/src/internal.rs @@ -911,7 +911,7 @@ mod tests { let update = announced.next().await.unwrap(); assert!(update.kind.is_active()); let bc = egress - .request_broadcast(moq_net::Path::new(update.path.as_str())) + .request_broadcast(moq_net::Path::new(update.prefix.as_str())) .await .unwrap(); let mut egress_sub = bc.track("video").unwrap().subscribe(None).await.unwrap(); diff --git a/rs/moq-relay/src/nodes.rs b/rs/moq-relay/src/nodes.rs index 23acd51222..28bf31ab77 100644 --- a/rs/moq-relay/src/nodes.rs +++ b/rs/moq-relay/src/nodes.rs @@ -175,7 +175,7 @@ impl Nodes { continue; } - let key = canonical_announced_node(update.path.as_str()); + let key = canonical_announced_node(update.prefix.as_str()); let route = update.route; let hop_ids = route.hops.iter().map(|origin| origin.id()).collect::>(); // An advertisement with no hops never crossed a link, so it is our own. @@ -386,7 +386,7 @@ mod tests { // bare unannounce ahead of relay-b's still-pending announce. let first_update = announced.try_next().expect("replayed announce"); assert_eq!( - canonical_announced_node(first_update.path.as_str()), + canonical_announced_node(first_update.prefix.as_str()), "https://relay-a.example/" ); drop(first); diff --git a/rs/moq-relay/src/web.rs b/rs/moq-relay/src/web.rs index 4de3463208..da850250c6 100644 --- a/rs/moq-relay/src/web.rs +++ b/rs/moq-relay/src/web.rs @@ -786,7 +786,7 @@ async fn serve_announced( while let Some(update) = announced.try_next() { if update.kind.is_active() { - broadcasts.push(update.path); + broadcasts.push(update.prefix); } } diff --git a/rs/moq-relay/tests/auth_lifetime.rs b/rs/moq-relay/tests/auth_lifetime.rs index 9bf5bfd9e4..852636e337 100644 --- a/rs/moq-relay/tests/auth_lifetime.rs +++ b/rs/moq-relay/tests/auth_lifetime.rs @@ -257,7 +257,7 @@ async fn connect_and_round_trip(url: &url::Url) -> (moq_tokio::Connection, moq_t .await .expect("announcement timeout") .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let bc = sub_consumer .request_broadcast("test") @@ -515,7 +515,7 @@ async fn http_routes_hold_a_lease() { .await .expect("announcement timeout") .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let http = reqwest::Client::new(); diff --git a/rs/moq-relay/tests/cluster_unknown.rs b/rs/moq-relay/tests/cluster_unknown.rs index a46aab8303..9ad71cdde3 100644 --- a/rs/moq-relay/tests/cluster_unknown.rs +++ b/rs/moq-relay/tests/cluster_unknown.rs @@ -191,7 +191,7 @@ async fn watch_announces(port: u16, window: Duration) -> Vec<(String, bool)> { let mut updates = Vec::new(); let deadline = tokio::time::Instant::now() + window; while let Ok(Some(update)) = tokio::time::timeout_at(deadline, announced.next()).await { - updates.push((update.path.as_str().to_string(), update.kind.is_active())); + updates.push((update.prefix.as_str().to_string(), update.kind.is_active())); } updates } diff --git a/rs/moq-relay/tests/drills.rs b/rs/moq-relay/tests/drills.rs index 12d4b2fbd2..bc152f5a04 100644 --- a/rs/moq-relay/tests/drills.rs +++ b/rs/moq-relay/tests/drills.rs @@ -578,7 +578,7 @@ async fn expect_announce(announced: &mut moq_net::announce::Consumer, path: &str .await .unwrap_or_else(|_| panic!("{who}: no announcement change within {TIMEOUT:?}")) .unwrap_or_else(|| panic!("{who}: the announcement stream closed")); - if update.path.as_str() == path && update.kind.is_active() == want { + if update.prefix.as_str() == path && update.kind.is_active() == want { return; } } @@ -615,7 +615,7 @@ async fn no_publisher_never_delivers() { let mut announced = subscribed.announced(); if let Ok(update) = tokio::time::timeout(quiet, announced.next()).await { - let path = update.map(|update| update.path.to_string()); + let path = update.map(|update| update.prefix.to_string()); panic!("the announcement stream reported {path:?} with no publisher"); } diff --git a/rs/moq-relay/tests/embed.rs b/rs/moq-relay/tests/embed.rs index 6b9940e4cb..6192deb733 100644 --- a/rs/moq-relay/tests/embed.rs +++ b/rs/moq-relay/tests/embed.rs @@ -166,7 +166,7 @@ async fn embed_and_stop(mut config: Config) { .await .expect("announcement timeout") .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let announced = tokio::time::timeout(TIMEOUT, consumer.request_broadcast("test")) .await diff --git a/rs/moq-relay/tests/goaway_cluster.rs b/rs/moq-relay/tests/goaway_cluster.rs index 9ff0bef87d..c2b6a061c5 100644 --- a/rs/moq-relay/tests/goaway_cluster.rs +++ b/rs/moq-relay/tests/goaway_cluster.rs @@ -246,7 +246,7 @@ async fn cluster_migrates_on_upstream_goaway_inner() { // re-prices the old route and the sibling announces its own). let mut announcements = cluster.origin.consume().announced(); let first = announcements.next().await.expect("initial announce"); - assert_eq!(first.path.as_str(), "cam"); + assert_eq!(first.prefix.as_str(), "cam"); // ── sibling A drains with a redirect to sibling B ──────────────── session_a @@ -455,7 +455,7 @@ async fn cluster_diamond_goaway_seamless_failover_inner() { let first = within("broadcast announced through the MID-A leg", announcements.next()) .await .expect("origin closed before the announce"); - assert_eq!(first.path.as_str(), "diamond"); + assert_eq!(first.prefix.as_str(), "diamond"); let bc = within("broadcast resolves on the subscriber origin", async { let consumer = sub_origin.consume(); diff --git a/rs/moq-relay/tests/runtime_uring.rs b/rs/moq-relay/tests/runtime_uring.rs index 4306dd1fc3..8e9f67d27a 100644 --- a/rs/moq-relay/tests/runtime_uring.rs +++ b/rs/moq-relay/tests/runtime_uring.rs @@ -160,7 +160,7 @@ async fn uring_workers_serve_webtransport_and_raw_quic() { .await .unwrap_or_else(|_| panic!("subscriber {index} announcement timeout")) .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let broadcast = tokio::time::timeout(TIMEOUT, consumer.request_broadcast("test")) .await @@ -316,7 +316,7 @@ async fn an_mtls_client_authenticates_without_a_token() { .await .expect("announcement timeout") .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let announced = tokio::time::timeout(TIMEOUT, consumer.request_broadcast("test")) .await diff --git a/rs/moq-relay/tests/runtime_workers.rs b/rs/moq-relay/tests/runtime_workers.rs index ed6a30d2a2..7f6040ec1d 100644 --- a/rs/moq-relay/tests/runtime_workers.rs +++ b/rs/moq-relay/tests/runtime_workers.rs @@ -123,7 +123,7 @@ async fn workers_serve_quic_and_share_one_origin() { .await .unwrap_or_else(|_| panic!("subscriber {index} announcement timeout")) .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let broadcast = tokio::time::timeout(TIMEOUT, consumer.request_broadcast("test")) .await diff --git a/rs/moq-relay/tests/smoke.rs b/rs/moq-relay/tests/smoke.rs index 61e4d34456..e1c619046c 100644 --- a/rs/moq-relay/tests/smoke.rs +++ b/rs/moq-relay/tests/smoke.rs @@ -210,7 +210,7 @@ async fn relay_websocket_round_trip_uses_newest_version() { .await .expect("announcement timeout") .expect("origin closed"); - let path = moq_net::Path::new(update.path.as_str()).to_owned(); + let path = moq_net::Path::new(update.prefix.as_str()).to_owned(); assert!(update.kind.is_active(), "expected announce, got retraction"); // Auth root for `/smoke` is "smoke"; the broadcast "test" announces underneath. assert_eq!(path.as_str(), "test"); @@ -401,7 +401,7 @@ async fn relay_websocket_root_path_upgrades() { .await .expect("announcement timeout") .expect("origin closed"); - let path = moq_net::Path::new(update.path.as_str()).to_owned(); + let path = moq_net::Path::new(update.prefix.as_str()).to_owned(); assert!(update.kind.is_active(), "expected announce, got retraction"); assert_eq!(path.as_str(), "test"); let bc = sub_consumer @@ -490,7 +490,7 @@ async fn two_publish_only_clients_coexist() { .expect("announcement timeout") .expect("origin closed"); if update.kind.is_active() { - seen.insert(update.path.as_str().to_owned()); + seen.insert(update.prefix.as_str().to_owned()); } } assert!( @@ -632,7 +632,7 @@ async fn internal_tcp_round_trip() { .await .expect("announcement timeout") .expect("origin closed"); - let path = moq_net::Path::new(update.path.as_str()).to_owned(); + let path = moq_net::Path::new(update.prefix.as_str()).to_owned(); assert!(update.kind.is_active(), "expected announce, got retraction"); assert_eq!(path.as_str(), "test"); let bc = sub_consumer @@ -746,7 +746,7 @@ async fn internal_unix_round_trip() { .await .expect("announcement timeout") .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await @@ -829,7 +829,7 @@ async fn path_round_trip(version: moq_net::Version, pub_url: url::Url, sub_url: .await .expect("announcement timeout") .expect("origin closed"); - let path = moq_net::Path::new(update.path.as_str()).to_owned(); + let path = moq_net::Path::new(update.prefix.as_str()).to_owned(); drop(track); drop(bc); diff --git a/rs/moq-room/src/room.rs b/rs/moq-room/src/room.rs index 8e08e1e2ee..22666ca7a4 100644 --- a/rs/moq-room/src/room.rs +++ b/rs/moq-room/src/room.rs @@ -81,7 +81,7 @@ impl Room { let Some(update) = ready!(self.announced.poll_next(waiter)) else { return Poll::Ready(None); }; - let path = update.path; + let path = update.prefix; let Some(parsed) = parse(&path) else { continue; }; diff --git a/rs/moq-rtc/src/egress.rs b/rs/moq-rtc/src/egress.rs index bec8cad0f8..51aec483da 100644 --- a/rs/moq-rtc/src/egress.rs +++ b/rs/moq-rtc/src/egress.rs @@ -188,7 +188,7 @@ impl EgressSource { } } -fn valid_reference(source: &moq_mux::Source, broadcast: Option<&moq_net::PathRelative<'_>>) -> bool { +fn valid_reference(source: &moq_mux::Source, broadcast: Option<&moq_net::path::Relative<'_>>) -> bool { source.resolve_reference(broadcast).is_some() } @@ -309,7 +309,7 @@ mod tests { use super::*; use hang::catalog::{AudioConfig, H264, VideoCodec, VideoConfig}; - use moq_net::PathRelative; + use moq_net::path::Relative; #[test] fn catalog_codecs_ignores_codecs_available_only_via_escaping_references() { @@ -318,7 +318,7 @@ mod tests { let mut catalog = Catalog::default(); let mut escaped_audio = AudioConfig::new(AudioCodec::Opus, 48_000, 2); - escaped_audio.broadcast = Some(PathRelative::new("../../source").to_owned()); + escaped_audio.broadcast = Some(Relative::new("../../source").to_owned()); catalog.audio.renditions.insert("opus".to_string(), escaped_audio); let mut escaped_video = VideoConfig::new(H264 { @@ -327,11 +327,11 @@ mod tests { level: 0x1e, inline: false, }); - escaped_video.broadcast = Some(PathRelative::new("../../source").to_owned()); + escaped_video.broadcast = Some(Relative::new("../../source").to_owned()); catalog.video.renditions.insert("h264".to_string(), escaped_video); let mut valid_video = VideoConfig::new(VideoCodec::VP8); - valid_video.broadcast = Some(PathRelative::new("./source").to_owned()); + valid_video.broadcast = Some(Relative::new("./source").to_owned()); catalog.video.renditions.insert("vp8".to_string(), valid_video); let (writes_tx, writes_rx) = mpsc::channel(1); diff --git a/rs/moq-rtc/src/lib.rs b/rs/moq-rtc/src/lib.rs index c394fa19bb..7217a4c8f6 100644 --- a/rs/moq-rtc/src/lib.rs +++ b/rs/moq-rtc/src/lib.rs @@ -107,7 +107,7 @@ mod tests { .await .expect("source announcement timed out") .expect("source origin closed"); - assert_eq!(announcement.path.as_str(), "source"); + assert_eq!(announcement.prefix.as_str(), "source"); assert!(announcement.kind.is_active(), "source was unannounced"); drop(announcements); diff --git a/rs/moq-stats/src/aggregate.rs b/rs/moq-stats/src/aggregate.rs index d9b0b786d6..b988bb0300 100644 --- a/rs/moq-stats/src/aggregate.rs +++ b/rs/moq-stats/src/aggregate.rs @@ -320,7 +320,7 @@ impl Merged { /// changed (only a non-sticky contribution leaving does; a sticky one is /// kept). fn apply_announce(&mut self, update: moq_net::announce::Update) -> bool { - let path = update.path; + let path = update.prefix; let absolute = self.announce.absolute(&path).to_owned(); // Only fold node-category routes; skip sibling categories a producer diff --git a/rs/moq-stats/src/consume.rs b/rs/moq-stats/src/consume.rs index 80b33ddbd1..c0a97ea601 100644 --- a/rs/moq-stats/src/consume.rs +++ b/rs/moq-stats/src/consume.rs @@ -188,7 +188,7 @@ mod tests { assert!(update.kind.is_active()); origin .consume() - .request_broadcast(moq_net::Path::new(update.path.as_str())) + .request_broadcast(moq_net::Path::new(update.prefix.as_str())) .await .expect("resolve") } diff --git a/rs/moq-stats/src/produce.rs b/rs/moq-stats/src/produce.rs index 4720731514..a6bbb693eb 100644 --- a/rs/moq-stats/src/produce.rs +++ b/rs/moq-stats/src/produce.rs @@ -988,10 +988,10 @@ mod tests { assert!(update.kind.is_active()); let broadcast = origin .consume() - .request_broadcast(moq_net::Path::new(update.path.as_str())) + .request_broadcast(moq_net::Path::new(update.prefix.as_str())) .await .expect("resolve"); - (update.path.as_str().to_string(), broadcast) + (update.prefix.as_str().to_string(), broadcast) } /// Advance past one publish interval so the task drains and writes frames. diff --git a/rs/moq-tokio/examples/clock.rs b/rs/moq-tokio/examples/clock.rs index a2e5979f1b..42912d89c1 100644 --- a/rs/moq-tokio/examples/clock.rs +++ b/rs/moq-tokio/examples/clock.rs @@ -112,14 +112,14 @@ async fn main() -> anyhow::Result<()> { tokio::select! { Some(update) = announced.next() => match update.kind.is_active() { true => { - tracing::info!(broadcast = %update.path, "broadcast is online, subscribing to track"); - let broadcast = consumer.request_broadcast(&update.path).await?; + tracing::info!(broadcast = %update.prefix, "broadcast is online, subscribing to track"); + let broadcast = consumer.request_broadcast(&update.prefix).await?; let track = broadcast .track(&track)?.subscribe(None).await?; clock = Some(Subscriber::new(track)); } false => { - tracing::warn!(broadcast = %update.path, "broadcast is offline, waiting..."); + tracing::warn!(broadcast = %update.prefix, "broadcast is offline, waiting..."); } }, res = reconnect.closed() => return Ok(res?), diff --git a/rs/moq-tokio/src/origin.rs b/rs/moq-tokio/src/origin.rs index 2c1fe78f2e..4249af6051 100644 --- a/rs/moq-tokio/src/origin.rs +++ b/rs/moq-tokio/src/origin.rs @@ -36,7 +36,7 @@ mod tests { broadcast.announce(Default::default()).expect("create broadcast"); let update = announced.next().await.expect("announce"); - assert_eq!(update.path.as_str(), "cam"); + assert_eq!(update.prefix.as_str(), "cam"); assert!(update.kind.is_active()); broadcast.finish(); diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index 28aa32bf19..5d623c39b0 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -1587,7 +1587,7 @@ mod tests { .await .expect("announce timeout") .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active()); let broadcast = consumer.request_broadcast("test").await.expect("resolve"); diff --git a/rs/moq-tokio/tests/backend.rs b/rs/moq-tokio/tests/backend.rs index e3f2141c7a..15d850c24b 100644 --- a/rs/moq-tokio/tests/backend.rs +++ b/rs/moq-tokio/tests/backend.rs @@ -180,7 +180,7 @@ async fn connect_test(config: ConnectTest<'_>) { .await .expect("announce timed out") .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await @@ -633,7 +633,7 @@ async fn iroh_connect() { .await .expect("announce timed out") .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await diff --git a/rs/moq-tokio/tests/broadcast.rs b/rs/moq-tokio/tests/broadcast.rs index c736e6b15e..f7322074fe 100644 --- a/rs/moq-tokio/tests/broadcast.rs +++ b/rs/moq-tokio/tests/broadcast.rs @@ -88,7 +88,7 @@ async fn broadcast_test(scheme: &str, client_version: Option<&str>, server_versi .expect("announce timed out") .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await @@ -201,7 +201,7 @@ async fn lite05_timestamp_roundtrip(scheme: &str) { .await .expect("announce timed out") .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await @@ -324,7 +324,7 @@ async fn lite05_fetch_roundtrip(scheme: &str) { .await .expect("announce timed out") .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await @@ -454,7 +454,7 @@ async fn lite05_fetch_during_subscribe(scheme: &str) { .await .expect("announce timed out") .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await @@ -568,7 +568,7 @@ async fn broadcast_moq_lite_05_default_timescale() { assert!(update.kind.is_active(), "expected announce"); let bc = tokio::time::timeout( TIMEOUT, - sub_consumer.request_broadcast(moq_net::Path::new(update.path.as_str())), + sub_consumer.request_broadcast(moq_net::Path::new(update.prefix.as_str())), ) .await .expect("request timed out") @@ -662,11 +662,11 @@ async fn broadcast_moq_transport_20_current_group_join() { .expect("connect failed"); let announced = next_announce(&mut announcements).await; - assert_eq!(announced.path.as_str(), "test"); + assert_eq!(announced.prefix.as_str(), "test"); assert!(announced.kind.is_active(), "expected an announce"); let remote = tokio::time::timeout( TIMEOUT, - sub_consumer.request_broadcast(moq_net::Path::new(announced.path.as_str())), + sub_consumer.request_broadcast(moq_net::Path::new(announced.prefix.as_str())), ) .await .expect("request timed out") @@ -764,28 +764,28 @@ async fn broadcast_moq_lite_06_announce_lifecycle() { // The initial set: "first" was announced before the session existed. let update = next_announce(&mut announcements).await; - assert_eq!(update.path.as_str(), "first"); + assert_eq!(update.prefix.as_str(), "first"); assert!(update.kind.is_active(), "expected initial announce"); // A live announce after the initial set. let second = pub_origin.create_broadcast("second").expect("create broadcast"); second.announce(Default::default()).expect("create broadcast"); let update = next_announce(&mut announcements).await; - assert_eq!(update.path.as_str(), "second"); + assert_eq!(update.prefix.as_str(), "second"); assert!(update.kind.is_active(), "expected live announce"); // Unannounce: retracted by announce id on the wire. Dropping the announcement // retracts the route; the broadcast's own end is independent. second.finish(); let update = next_announce(&mut announcements).await; - assert_eq!(update.path.as_str(), "second"); + assert_eq!(update.prefix.as_str(), "second"); assert!(!update.kind.is_active(), "expected retraction"); // Re-announce the same path: a fresh announce assigning a fresh id. let _second = pub_origin.create_broadcast("second").expect("create broadcast"); _second.announce(Default::default()).expect("create broadcast"); let update = next_announce(&mut announcements).await; - assert_eq!(update.path.as_str(), "second"); + assert_eq!(update.prefix.as_str(), "second"); assert!(update.kind.is_active(), "expected re-announce"); // Replace the route at "first": retract the original (retiring its announce @@ -793,19 +793,19 @@ async fn broadcast_moq_lite_06_announce_lifecycle() { // Await the retraction first so the events cannot coalesce away. first.finish(); let update = next_announce(&mut announcements).await; - assert_eq!(update.path.as_str(), "first"); + assert_eq!(update.prefix.as_str(), "first"); assert!(!update.kind.is_active(), "expected the replaced retraction"); let _replacement = pub_origin.create_broadcast("first").expect("create replacement"); _replacement.announce(Default::default()).expect("create replacement"); let update = next_announce(&mut announcements).await; - assert_eq!(update.path.as_str(), "first"); + assert_eq!(update.prefix.as_str(), "first"); assert!(update.kind.is_active(), "expected the replacement announce"); // A sentinel proves no stray event for "first" snuck in behind the replacement. let _sentinel = pub_origin.create_broadcast("sentinel").expect("create broadcast"); _sentinel.announce(Default::default()).expect("create broadcast"); let update = next_announce(&mut announcements).await; - assert_eq!(update.path.as_str(), "sentinel"); + assert_eq!(update.prefix.as_str(), "sentinel"); assert!(update.kind.is_active(), "expected sentinel announce"); drop(connection); @@ -948,7 +948,7 @@ async fn broadcast_route_migration() { // One path, announced once even though two sessions route it (the cheaper // route wins the advertisement). let update = next_announce(&mut announcements).await; - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce"); // Resolve and subscribe: the cheaper route (A) serves the track. @@ -1063,7 +1063,7 @@ async fn route_reannounce_test(version: Option<&str>) { .expect("connect failed"); let update = next_announce(&mut announcements).await; - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce"); let initial = update.route; @@ -1090,7 +1090,7 @@ async fn route_reannounce_test(version: Option<&str>) { // The subscriber sees the new chain as another active update for the prefix... let update = next_announce(&mut announcements).await; - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "a restart must not retract the route"); assert_ne!(initial.hops, update.route.hops, "route must change"); assert!( @@ -1382,7 +1382,7 @@ async fn max_age_test(version: &str) -> Duration { .expect("connect failed"); let update = next_announce(&mut announcements).await; - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce"); let broadcast = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await @@ -1695,7 +1695,7 @@ async fn broadcast_websocket() { .expect("announce timed out") .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await @@ -1817,7 +1817,7 @@ async fn broadcast_websocket_fallback() { .expect("announce timed out") .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await @@ -2088,7 +2088,7 @@ async fn quic_driver_task_inherits_connection_span() { assert!(update.kind.is_active(), "expected announce, got retraction"); let bc = tokio::time::timeout( TIMEOUT, - sub_consumer.request_broadcast(moq_net::Path::new(update.path.as_str())), + sub_consumer.request_broadcast(moq_net::Path::new(update.prefix.as_str())), ) .await .expect("request timed out") @@ -2203,7 +2203,7 @@ async fn resubscribe_keeps_flowing_moq_lite_03() { .await .expect("announce timeout") .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await @@ -2345,7 +2345,7 @@ async fn idle_subscription_releases_the_viewer_count() { assert!(update.kind.is_active(), "expected announce"); let bc = tokio::time::timeout( TIMEOUT, - sub_consumer.request_broadcast(moq_net::Path::new(update.path.as_str())), + sub_consumer.request_broadcast(moq_net::Path::new(update.prefix.as_str())), ) .await .expect("request timed out") @@ -2664,7 +2664,7 @@ async fn a_dead_session_unannounces_while_the_reconnect_retries() { .expect("connect failed"); let update = next_announce(&mut announcements).await; - assert_eq!(update.path.as_str(), "live"); + assert_eq!(update.prefix.as_str(), "live"); assert!(update.kind.is_active(), "expected the initial announce"); // The server kills the session; the loop starts redialing into the void. @@ -2676,7 +2676,7 @@ async fn a_dead_session_unannounces_while_the_reconnect_retries() { // The retraction lands promptly, while the connection is still retrying. let update = next_announce(&mut announcements).await; - assert_eq!(update.path.as_str(), "live"); + assert_eq!(update.prefix.as_str(), "live"); assert!(!update.kind.is_active(), "a dead session must retract, not linger"); drop(connection); @@ -2752,7 +2752,7 @@ async fn announce_interest_unauthorized_keeps_session_alive() { .await .expect("announce timed out") .expect("origin closed"); - assert_eq!(update.path.as_str(), "allowed/test"); + assert_eq!(update.prefix.as_str(), "allowed/test"); assert!(update.kind.is_active(), "expected announce, got retraction"); // The unauthorized "denied" interest must not have torn down the session. @@ -2841,7 +2841,7 @@ async fn wildcard_scope_test(version: &str, server_scope: &str) { // Exactly alice's chat: bob's is outside the server's grant, alice's audio and the // lobby are outside the client's. The match pins the room. let update = next_announce(&mut announcements).await; - assert_eq!(update.path.as_str(), "room/alice/chat"); + assert_eq!(update.prefix.as_str(), "room/alice/chat"); assert_eq!( update.captures, Some(vec!["alice".parse::().unwrap()]) @@ -2955,7 +2955,7 @@ async fn publish_only_client_to_subscribe_only_server() { .await .expect("announce timed out") .expect("origin closed"); - assert_eq!(update.path.as_str(), "allowed/test"); + assert_eq!(update.prefix.as_str(), "allowed/test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("allowed/test")) .await @@ -3136,7 +3136,7 @@ async fn goaway_test(scheme: &str, version: &str, expect_wire_timeout: bool) { .await .expect("announce timed out") .expect("origin closed"); - assert_eq!(update.path.as_str(), "test"); + assert_eq!(update.prefix.as_str(), "test"); assert!(update.kind.is_active(), "expected announce, got retraction"); let bc = tokio::time::timeout(TIMEOUT, sub_consumer.request_broadcast("test")) .await diff --git a/rs/moq-transcode/README.md b/rs/moq-transcode/README.md index 8b8c4adf7e..197090e8b1 100644 --- a/rs/moq-transcode/README.md +++ b/rs/moq-transcode/README.md @@ -38,7 +38,7 @@ broadcast. let mut config = moq_transcode::Config::default(); // The derivative is announced at `/transcode.hang`, so the source // renditions are referenced through its parent. -config.source = Some(moq_net::PathRelativeOwned::from(".".to_string())); +config.source = Some(moq_net::path::RelativeOwned::from(".".to_string())); let output = origin.create_broadcast( format!("{path}/transcode.hang"), diff --git a/rs/moq-transcode/src/catalog.rs b/rs/moq-transcode/src/catalog.rs index 29e89769d5..022ffa559e 100644 --- a/rs/moq-transcode/src/catalog.rs +++ b/rs/moq-transcode/src/catalog.rs @@ -2,7 +2,7 @@ //! against it, and fill the output catalog with rung + passthrough entries. use hang::catalog::{AV1, Video, VideoCodec, VideoConfig}; -use moq_net::PathRelativeOwned; +use moq_net::path::RelativeOwned; use crate::{Error, Ladder}; @@ -220,7 +220,7 @@ pub(crate) fn populate( out: &mut moq_mux::catalog::hang::Catalog, source: &moq_mux::catalog::hang::Catalog, rungs: &[Published], - source_rel: Option<&PathRelativeOwned>, + source_rel: Option<&RelativeOwned>, ) -> Result<(), Error> { out.video = Video::default(); out.audio = hang::catalog::Audio::default(); @@ -502,7 +502,7 @@ mod tests { video.insert("low", source(640, 360, None)).unwrap(); video.insert("high", source(1920, 1080, None)).unwrap(); let mut remote = source(3840, 2160, None); - remote.broadcast = Some(PathRelativeOwned::from("./other".to_string())); + remote.broadcast = Some(RelativeOwned::from("./other".to_string())); video.insert("remote", remote).unwrap(); let (name, config) = choose_source(&video).unwrap(); @@ -548,7 +548,7 @@ mod tests { .insert("video", source(1920, 1080, Some(6_000_000))) .unwrap(); let mut archive = hang::catalog::Archive::new("timeline.z"); - archive.replay = Some(PathRelativeOwned::from("./recordings/clip".to_string())); + archive.replay = Some(RelativeOwned::from("./recordings/clip".to_string())); archive.version = Some(hang::catalog::Archive::VERSION); child.archive = Some(archive.clone()); let clock = hang::catalog::Clock::new(moq_net::Timestamp::from_micros(1_751_846_400_000_000).unwrap()).unwrap(); diff --git a/rs/moq-transcode/src/config.rs b/rs/moq-transcode/src/config.rs index ba1a7203c1..ed0bb25a88 100644 --- a/rs/moq-transcode/src/config.rs +++ b/rs/moq-transcode/src/config.rs @@ -1,6 +1,6 @@ //! Transcoder configuration: the rung ladder and catalog wiring. -use moq_net::PathRelativeOwned; +use moq_net::path::RelativeOwned; use crate::Ladder; @@ -28,7 +28,7 @@ pub struct Config { /// and audio) through this path so players fetch them from the source /// directly; the transcoder never proxies or subscribes them. `None` omits /// them from the derivative catalog. - pub source: Option, + pub source: Option, /// Which video encoder implementation encodes the rungs. The default /// prefers hardware (NVENC on Linux, VideoToolbox on macOS, Media diff --git a/rs/moq-transcode/src/lib.rs b/rs/moq-transcode/src/lib.rs index 7ddf3e739d..32cb92487b 100644 --- a/rs/moq-transcode/src/lib.rs +++ b/rs/moq-transcode/src/lib.rs @@ -758,7 +758,7 @@ mod tests { ladder: Ladder::new([Rung::new(120, moq_net::bandwidth::Rate::from_bps(100_000))]).unwrap(), encoder: moq_video::encode::Kind::Software, decoder: moq_video::decode::Kind::Software, - source: Some(moq_net::PathRelativeOwned::from(".".to_string())), + source: Some(moq_net::path::RelativeOwned::from(".".to_string())), ..Default::default() }; @@ -1012,7 +1012,7 @@ mod tests { .unwrap(), encoder: moq_video::encode::Kind::Software, decoder: moq_video::decode::Kind::Software, - source: Some(moq_net::PathRelativeOwned::from(".".to_string())), + source: Some(moq_net::path::RelativeOwned::from(".".to_string())), ..Default::default() }; diff --git a/rs/moq-uring/benches/echo_noq.rs b/rs/moq-uring/benches/echo_noq.rs index aaa28142d7..650a71664e 100644 --- a/rs/moq-uring/benches/echo_noq.rs +++ b/rs/moq-uring/benches/echo_noq.rs @@ -164,7 +164,7 @@ mod linux { use linux::benchmark; #[cfg(not(target_os = "linux"))] -fn benchmark(_: &mut Criterion) {} +fn benchmark(_: &mut criterion::Criterion) {} criterion_group!(benches, benchmark); criterion_main!(benches); diff --git a/rs/moq-uring/tests/support.rs b/rs/moq-uring/tests/support.rs index 454d3b41d5..30ad51588a 100644 --- a/rs/moq-uring/tests/support.rs +++ b/rs/moq-uring/tests/support.rs @@ -1,5 +1,6 @@ //! Shared test certificates. +#![cfg(target_os = "linux")] #![allow(dead_code)] /// A self-signed localhost certificate on disk.