Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/lib/js/net.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ broadcast.announce();
- **One connection per URL, unless you opt out.** `new Connection({ url })` pools and reconnects with backoff, which the elements use. Supplying your own transport options, discovery, or origin selects a private loop.
- **GOAWAY moves the session.** The connection dials the replacement at once while the old session keeps serving its groups, up to `goaway.handover` (default 10s, or the relay's deadline when that is sooner). New requests keep opening on the old session until the replacement's route outranks it. An empty GOAWAY redials the same URL. A redirect stays on the same host unless `goaway.redirect` is `"follow"`.
- **One send estimate per connection.** `Bandwidth.Allocator` divides it by track priority, max-min fair within a tier. An idle track claims nothing. Publishers reserve against it so their targets sum to the estimate.
- **One track per name.** Concurrent subscriptions share one request and producer. An on-demand producer that replaces an ended one continues the name's group and datagram sequences; only a new broadcast restarts them. `createTrack` and `insertTrack` start at 0.
- **Timing is per track, with no default.** A track that declares a `timescale` carries a `timestamp` on every frame; one without is [untimed](/concept/moq-lite#subscriptions) and its frames carry none. Unlike Rust, omitting it means untimed.
- **Subscriber staleness is `maxDelay`.** It is media time. Passing the old `maxAge` key throws a `TypeError` naming `maxDelay`. Publisher retention is the separate `Track.Info.maxAge`.
- **Hidden paths** stay out of discovery unless the announce request opts in. See [hidden broadcasts](/concept/moq-lite#hidden-broadcasts).
Expand Down
1 change: 1 addition & 0 deletions doc/lib/rs/moq-net.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ What you use it for, beyond what the concept page already describes:

- **An origin outlives the session.** Broadcasts are created on the origin, and a reconnect announces them again. Closing the session does not delete them.
- **Requests can pin an epoch.** `consumer.request_broadcast(path, Some(epoch))` resolves only through a route announcing that [publisher epoch](/concept/moq-lite#publisher-epochs); `None` takes whichever route wins. The resolved consumer names its epoch in `info().epoch`, pinned or not.
- **One track per name.** Concurrent subscriptions share one request and producer. A producer that replaces an ended one continues the name's group and datagram sequences; only a new broadcast restarts them.
- **Publish only while someone is watching.** `demand()` on a track, group, or broadcast says whether a subscriber is attached, which is how capture and transcode skip work nobody asked for. Holding a broadcast consumer is not demand. A shared fetch stays up until its last reader leaves.
- **You drive the session, or `moq-tokio` does.** `connect` and `accept` return a session plus a driver that never reads the clock itself. `moq_net::time::run` polls it on tokio or in the browser. `moq-tokio` and `moq-wasm` hide that. A custom transport implements `moq_net::transport::poll`.
- **A session caps what its peer can hold.** `session::Limits` (via `Client::with_limits` and `Server::with_limits`) bounds announces and subscriptions per session; the defaults suit a relay mesh, so lower them for untrusted peers. Past either, the session closes with `TOO_MANY_REQUESTS`. On moq-transport drafts 14 to 16 they also size the request-ID window, which every request counts against, so very low limits can starve it. Peer-declared lengths are capped before they are buffered.
Expand Down
124 changes: 46 additions & 78 deletions js/net/src/broadcast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,102 +92,70 @@ test("dynamic track sequences continue across producer replacements", async () =
nextGeneration.close();
});

test("concurrent dynamic producers share a sequence namespace", async () => {
test("publishing-side subscriptions coalesce onto one request", async () => {
const broadcast = new BroadcastProducer();
const pulled = wireOf(broadcast).requested();
const firstSubscriber = broadcast.track("media").subscribe();
const secondSubscriber = broadcast.track("media").subscribe();
const firstRequest = await pulled;
const secondRequest = await wireOf(broadcast).requested();
if (!firstRequest || !secondRequest) throw new Error("expected requests");
const firstProducer = firstRequest.accept({ timescale: Timescale.MILLI });
const secondProducer = secondRequest.accept({ timescale: Timescale.MILLI });
const info = broadcast.track("media").info();
const a = broadcast.track("media").subscribe().ordered();
const b = broadcast.track("media").subscribe().ordered();

expect(firstProducer.appendGroup().sequence).toBe(0);
expect(secondProducer.appendGroup().sequence).toBe(1);
expect(firstProducer.appendGroup().sequence).toBe(2);

firstSubscriber.close();
secondSubscriber.close();
firstProducer.close();
secondProducer.close();
broadcast.close();
});

test("a sibling producer's groups do not settle an aborted end", async () => {
const broadcast = new BroadcastProducer();
const pulled = wireOf(broadcast).requested();
const firstSubscriber = broadcast.track("media").subscribe();
const secondSubscriber = broadcast.track("media").subscribe();
const firstRequest = await pulled;
const secondRequest = await wireOf(broadcast).requested();
if (!firstRequest || !secondRequest) throw new Error("expected requests");
const firstProducer = firstRequest.accept({ timescale: Timescale.MILLI });
const secondProducer = secondRequest.accept({ timescale: Timescale.MILLI });

const reader = firstProducer.subscribe();
firstProducer.finishAt(3);
for (let i = 0; i < 3; i++) secondProducer.appendGroup().close();
const boom = new Error("boom");
firstProducer.close(boom);
// One request serves the info lookup and every subscriber.
const request = await pulled;
expect(request?.name).toBe("media");
expect(await pendingRequest(broadcast)).toBeUndefined();

// The first producer never received the groups its end promised, so it was cut off.
expect(firstProducer.closed.peek()).toBe(boom);
await expect(reader.recvGroup()).rejects.toBe(boom);
if (!request) throw new Error("expected request");
const producer = request.accept({ timescale: Timescale.MILLI });
producer.writeString("hello");
expect(await a.readString()).toBe("hello");
expect(await b.readString()).toBe("hello");
await info;
// The lookup leaves the shared producer to its subscribers, and stops counting as demand.
expect(producer.closed.peek()).toBeUndefined();
a.close();
b.close();
await broadcast.demand().unused();
expect(producer.closed.peek()).toBeUndefined();

firstSubscriber.close();
secondSubscriber.close();
secondProducer.close();
producer.close();
broadcast.close();
});

test("a clean close ends at the track's own last group, not a sibling's", async () => {
test("an info lookup joining a subscription's request stays demand after the subscriber leaves", async () => {
const broadcast = new BroadcastProducer();
const demand = broadcast.demand();
const pulled = wireOf(broadcast).requested();
const firstSubscriber = broadcast.track("media").subscribe();
const secondSubscriber = broadcast.track("media").subscribe();
const firstRequest = await pulled;
const secondRequest = await wireOf(broadcast).requested();
if (!firstRequest || !secondRequest) throw new Error("expected requests");
const firstProducer = firstRequest.accept({ timescale: Timescale.MILLI });
const secondProducer = secondRequest.accept({ timescale: Timescale.MILLI });

const reader = firstProducer.subscribe();
firstProducer.appendGroup().close();
for (let i = 0; i < 3; i++) secondProducer.appendGroup().close();
firstProducer.close();
const subscriber = broadcast.track("media").subscribe();
const info = broadcast.track("media").info();
const request = await pulled;
if (!request) throw new Error("expected request");

// The sibling took 1..3, which the first producer will never send.
expect(reader.final()).toBe(1);
// Flush every pending notification, so only the lookup is left holding demand.
subscriber.close();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(request.demand().used.peek()).toBe(false);
expect(demand.used.peek()).toBe(true);

firstSubscriber.close();
secondSubscriber.close();
secondProducer.close();
request.accept({ timescale: Timescale.MILLI });
await info;
await demand.unused();
broadcast.close();
});

test("finishAt accepts an end below a sibling's groups", async () => {
test("an info lookup releases a request nobody subscribed to", async () => {
const broadcast = new BroadcastProducer();
const pulled = wireOf(broadcast).requested();
const firstSubscriber = broadcast.track("media").subscribe();
const secondSubscriber = broadcast.track("media").subscribe();
const firstRequest = await pulled;
const secondRequest = await wireOf(broadcast).requested();
if (!firstRequest || !secondRequest) throw new Error("expected requests");
const firstProducer = firstRequest.accept({ timescale: Timescale.MILLI });
const secondProducer = secondRequest.accept({ timescale: Timescale.MILLI });

const reader = firstProducer.subscribe();
firstProducer.appendGroup().close();
for (let i = 0; i < 3; i++) secondProducer.appendGroup().close();
expect(() => firstProducer.finishAt(0)).toThrow("track end 0 is below the next sequence 1");
firstProducer.finishAt(2);
expect(reader.final()).toBe(2);
const info = broadcast.track("media").info();
const request = await pulled;
if (!request) throw new Error("expected request");
const producer = request.accept();
await info;
expect(producer.closed.peek()).toBeDefined();

firstSubscriber.close();
secondSubscriber.close();
firstProducer.close();
secondProducer.close();
// The next subscription asks again.
const subscriber = broadcast.track("media").subscribe();
expect((await pendingRequest(broadcast))?.name).toBe("media");
subscriber.close();
broadcast.close();
});

Expand Down
Loading
Loading